package main import ( "archive/zip" "bufio" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "sauce.pizzawednes.day/kayomn/ini-gusher" ) func (game *Game) CleanDeployedMods() error { // Clean up currently deployed files first. for _, filePath := range game.DeployedFilePaths { var gameFilePath = filepath.Join(game.Path, filePath) if removeError := os.Remove(gameFilePath); removeError != nil { if !(os.IsNotExist(removeError)) { return removeError } } } game.DeployedFilePaths = game.DeployedFilePaths[:0] // Then restore all files overwritten by previously deployment. for _, filePath := range game.OverwrittenFilePaths { var backupDirPath, backupDirPathError = game.cachePath("overwritten") if backupDirPathError != nil { return backupDirPathError } if renameError := os.Rename(filepath.Join(backupDirPath, filePath), filepath.Join(game.Path, filePath)); renameError != nil { return renameError } } game.OverwrittenFilePaths = game.OverwrittenFilePaths[:0] return nil } func (game *Game) DeployMod(name string) error { var mod, exists = game.Mods[name] if !(exists) { return fmt.Errorf("mod does not exist: %s", name) } var archivePath, archivePathError = game.configPath(name) if archivePathError != nil { return archivePathError } switch mod.Format { case "zip": archivePath += ".zip" var zipReadCloser, zipReadCloserOpenError = zip.OpenReader(archivePath) if zipReadCloserOpenError != nil { return zipReadCloserOpenError } defer func() { if closeError := zipReadCloser.Close(); closeError != nil { panic(closeError) } }() for _, zipFile := range zipReadCloser.File { var deployPath = filepath.Join(game.Path, zipFile.Name) var fileMode = zipFile.Mode() if dirError := os.MkdirAll(filepath.Dir(deployPath), fileMode); dirError != nil { return dirError } if zipFile.FileInfo().IsDir() { // All work is done for creating a directory, rest is just for files. continue } var file, openFileError = os.OpenFile( deployPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fileMode) if openFileError != nil { if !(os.IsExist(openFileError)) { return openFileError } var backupPath, backupPathError = game.cachePath("overwritten") // deployPath if backupPathError != nil { return backupPathError } backupPath = filepath.Join(backupPath, zipFile.Name) if dirError := os.MkdirAll(filepath.Dir(backupPath), 0755); dirError != nil { return dirError } if renameError := os.Rename(deployPath, backupPath); renameError != nil { return renameError } game.OverwrittenFilePaths = append(game.OverwrittenFilePaths, zipFile.Name) file, openFileError = os.OpenFile( deployPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fileMode) if openFileError != nil { return openFileError } } var zipReadCloser, zipFileOpenError = zipFile.Open() if zipFileOpenError != nil { return zipFileOpenError } defer func() { if syncError := file.Sync(); syncError != nil { panic(syncError) } if closeError := file.Close(); closeError != nil { panic(closeError) } }() if _, copyError := io.Copy(file, zipReadCloser); copyError != nil { return copyError } game.DeployedFilePaths = append(game.DeployedFilePaths, zipFile.Name) } default: return fmt.Errorf("unsupported mod format: %s", mod.Format) } return nil } type Game struct { ID string DeployedFilePaths []string OverwrittenFilePaths []string Mods map[string]Mod Path string } func (game *Game) InstallMod(archivePath string) error { var archiveBaseName = filepath.Base(archivePath) var archiveExtension = filepath.Ext(archiveBaseName) if len(archiveExtension) == 0 { return fmt.Errorf("unknown archive format: %s", archiveExtension) } var name = strings.TrimSuffix(archiveBaseName, archiveExtension) if _, exists := game.Mods[name]; exists { return fmt.Errorf("mod with name already exists: `%s`", name) } // Copy archive into installation directory. { var installPath, installPathError = game.configPath(name) if installPathError != nil { return installPathError } var sourceFile, sourceOpenError = os.Open(archivePath) if sourceOpenError != nil { return sourceOpenError } defer sourceFile.Close() var targetFile, targetCreateError = os.Create(installPath) if targetCreateError != nil { return targetCreateError } defer targetFile.Close() if _, copyError := io.Copy(targetFile, sourceFile); copyError != nil { return copyError } } game.Mods[name] = Mod{ Format: archiveExtension[1:], Source: archivePath, Version: "", } return nil } func (game *Game) Load() error { // Read deployed files from disk. var deployedListPath, deployedListPathError = game.cachePath("deployed.txt") if deployedListPathError != nil { return deployedListPathError } if file, openError := os.Open(deployedListPath); openError == nil { defer func() { if closeError := file.Close(); closeError != nil { panic(closeError) } }() var scanner = bufio.NewScanner(file) for scanner.Scan() { game.DeployedFilePaths = append(game.DeployedFilePaths, scanner.Text()) } } else if !(os.IsNotExist(openError)) { return openError } // Read overwritten files from disk. var overwriteDirPath, overwriteDirPathError = game.cachePath("overwritten") if overwriteDirPathError != nil { return overwriteDirPathError } if _, statError := os.Stat(overwriteDirPath); statError == nil { if walkError := filepath.WalkDir(overwriteDirPath, func( path string, dirEntry fs.DirEntry, walkError error) error { if walkError != nil { return walkError } if !(dirEntry.IsDir()) { game.OverwrittenFilePaths = append(game.OverwrittenFilePaths, path) } return nil }); walkError != nil { return walkError } } else if !(os.IsNotExist(statError)) { return statError } // Read mod info from disk. var modInfoPath, modInfoPathError = game.configPath("mods.ini") if modInfoPathError != nil { return modInfoPathError } if file, openError := os.Open(modInfoPath); openError == nil { defer func() { if closeError := file.Close(); closeError != nil { panic(closeError) } }() var parser = ini.NewParser(file) for entry := parser.Parse(); !(parser.IsEnd()); entry = parser.Parse() { var mod = game.Mods[entry.Section] switch entry.Key { case "format": mod.Format = entry.Value case "source": mod.Source = entry.Value case "version": mod.Version = entry.Value } game.Mods[entry.Section] = mod } if parserError := parser.Err(); parserError != nil { return parserError } } else if !(os.IsNotExist(openError)) { return openError } return nil } type Mod struct { Format string Source string Version string } func (game *Game) RemoveMods(names []string) error { for _, name := range names { if _, exists := game.Mods[name]; !(exists) { return fmt.Errorf("unknown mod: `%s`", name) } var path, pathError = game.configPath(name) if pathError != nil { return pathError } if removeError := os.RemoveAll(path); removeError != nil { return removeError } delete(game.Mods, name) } return nil } func (game *Game) RenameMod(modName string, newName string) error { if _, exists := game.Mods[modName]; !(exists) { return fmt.Errorf("no mod with that name exists") } if _, nameTaken := game.Mods[newName]; nameTaken { return fmt.Errorf("a mod with the new name already exists") } var modPath, modPathError = game.configPath(modName) if modPathError != nil { return modPathError } var newPath, newPathError = game.configPath(modName) if newPathError != nil { return newPathError } if renameError := os.Rename(modPath, newPath); renameError != nil { return renameError } var mod = game.Mods[modName] game.Mods[newName] = mod delete(game.Mods, modName) return nil } func (game *Game) Save() error { // Write deployed files to disk. var deployedListPath, deployedListPathError = game.cachePath("deployed.txt") if deployedListPathError != nil { return deployedListPathError } var deployedListFile, deployedListCreateError = os.Create(deployedListPath) if deployedListCreateError != nil { return deployedListCreateError } defer func() { if syncError := deployedListFile.Sync(); syncError != nil { panic(syncError) } if closeError := deployedListFile.Close(); closeError != nil { panic(closeError) } }() var deployedListWriter = bufio.NewWriter(deployedListFile) defer func() { if flushError := deployedListWriter.Flush(); flushError != nil { panic(flushError) } }() for _, filePath := range game.DeployedFilePaths { if _, writeError := deployedListWriter.WriteString(filePath); writeError != nil { return writeError } if writeError := deployedListWriter.WriteByte('\n'); writeError != nil { return writeError } } // Read mod info from disk. var modInfoPath, modInfoPathError = game.configPath("mods.ini") if modInfoPathError != nil { return modInfoPathError } var modInfoFile, modInfoCreateError = os.Create(modInfoPath) if modInfoCreateError != nil { return modInfoCreateError } defer func() { if closeError := modInfoFile.Close(); closeError != nil { panic(closeError) } }() var modInfoEntries = make([]ini.Entry, 0, len(game.Mods)*3) for name, mod := range game.Mods { modInfoEntries = append(modInfoEntries, ini.Entry{ Section: name, Key: "source", Value: mod.Source, }) modInfoEntries = append(modInfoEntries, ini.Entry{ Section: name, Key: "format", Value: mod.Format, }) modInfoEntries = append(modInfoEntries, ini.Entry{ Section: name, Key: "version", Value: mod.Version, }) } return ini.Write(modInfoFile, modInfoEntries) } func WithGame(gameName string, action func(*Game) error) error { var supportedGames = []string{"fallout4", "falloutnv", "skyrim"} for _, supportedGame := range supportedGames { if gameName == supportedGame { var game = Game{ ID: supportedGame, OverwrittenFilePaths: make([]string, 0, 512), DeployedFilePaths: make([]string, 0, 512), Mods: make(map[string]Mod), Path: "/home/kayomn/.steam/steam/steamapps/common/Fallout 4/Data", } if loadError := game.Load(); loadError != nil { return loadError } if actionError := action(&game); actionError != nil { return actionError } if saveError := game.Save(); saveError != nil { return saveError } return nil } } return fmt.Errorf("%s: game not supported", gameName) } func (game *Game) cachePath(path string) (string, error) { var dirPath, pathError = os.UserCacheDir() if pathError != nil { return "", pathError } dirPath = filepath.Join(dirPath, "modman", game.ID) if mkdirError := os.MkdirAll(dirPath, 0755); mkdirError != nil { return "", mkdirError } return filepath.Join(dirPath, path), nil } func (game *Game) configPath(path string) (string, error) { var dirPath, pathError = os.UserConfigDir() if pathError != nil { return "", pathError } dirPath = filepath.Join(dirPath, "modman", game.ID) if mkdirError := os.MkdirAll(dirPath, 0755); mkdirError != nil { return "", mkdirError } return filepath.Join(dirPath, path), nil }