package updater import ( "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "seanime/internal/constants" "seanime/internal/util" "slices" "strings" "syscall" "time" "github.com/rs/zerolog" "github.com/samber/lo" "github.com/samber/mo" ) const ( tempReleaseDir = "seanime_new_release" backupDirName = "backup_restore_if_failed" ) type ( SelfUpdater struct { logger *zerolog.Logger breakLoopCh chan struct{} originalExePath mo.Option[string] updater *Updater fallbackDest string tmpExecutableName string } ) func NewSelfUpdater() *SelfUpdater { logger := util.NewLogger() ret := &SelfUpdater{ logger: logger, breakLoopCh: make(chan struct{}), originalExePath: mo.None[string](), updater: New(constants.Version, logger, nil), } ret.tmpExecutableName = "seanime.exe.old" switch runtime.GOOS { case "windows": ret.tmpExecutableName = "seanime.exe.old" default: ret.tmpExecutableName = "seanime.old" } go func() { // Delete all files with the .old extension exePath := getExePath() entries, err := os.ReadDir(filepath.Dir(exePath)) if err != nil { return } for _, entry := range entries { if strings.HasSuffix(entry.Name(), ".old") { _ = os.RemoveAll(filepath.Join(filepath.Dir(exePath), entry.Name())) } } }() return ret } // Started returns a channel that will be closed when the app loop should be broken func (su *SelfUpdater) Started() <-chan struct{} { return su.breakLoopCh } func (su *SelfUpdater) StartSelfUpdate(fallbackDestination string) { su.fallbackDest = fallbackDestination close(su.breakLoopCh) } // recover will just print a message and attempt to download the latest release func (su *SelfUpdater) recover(assetUrl string) { if su.originalExePath.IsAbsent() { return } if su.fallbackDest != "" { su.logger.Info().Str("dest", su.fallbackDest).Msg("selfupdate: Attempting to download the latest release") _, _ = su.updater.DownloadLatestRelease(assetUrl, su.fallbackDest) } su.logger.Error().Msg("selfupdate: Failed to install update. Update downloaded to 'seanime_new_release'") } func getExePath() string { exe, err := os.Executable() // /path/to/seanime.exe if err != nil { return "" } exePath, err := filepath.EvalSymlinks(exe) // /path/to/seanime.exe if err != nil { return "" } return exePath } func (su *SelfUpdater) Run() error { exePath := getExePath() su.originalExePath = mo.Some(exePath) exeDir := filepath.Dir(exePath) // /path/to var files []string switch runtime.GOOS { case "windows": files = []string{ "seanime.exe", "LICENSE", } default: files = []string{ "seanime", "LICENSE", } } // Get the new assets su.logger.Info().Msg("selfupdate: Fetching latest release info") // Get the latest release release, err := su.updater.GetLatestRelease() if err != nil { su.logger.Error().Err(err).Msg("selfupdate: Failed to get latest release") return err } // Find the asset assetName := su.updater.GetReleaseName(release.Version) asset, ok := lo.Find(release.Assets, func(asset ReleaseAsset) bool { return asset.Name == assetName }) if !ok { su.logger.Error().Msg("selfupdate: Asset not found") return err } su.logger.Info().Msg("selfupdate: Downloading latest release") // Download the asset to exeDir/seanime_tmp newReleaseDir, err := su.updater.DownloadLatestReleaseN(asset.BrowserDownloadUrl, exeDir, tempReleaseDir) if err != nil { su.logger.Error().Err(err).Msg("selfupdate: Failed to download latest release") return err } // DEVNOTE: Past this point, the application will be broken // Use "recover" to attempt to recover the application su.logger.Info().Msg("selfupdate: Creating backup") // Delete the backup directory if it exists _ = os.RemoveAll(filepath.Join(exeDir, backupDirName)) // Create the backup directory backupDir := filepath.Join(exeDir, backupDirName) _ = os.MkdirAll(backupDir, 0755) // Backup the current assets // Copy the files to the backup directory // seanime.exe + /backup_restore_if_failed/seanime.exe // LICENSE + /backup_restore_if_failed/LICENSE for _, file := range files { // We don't check for errors here because we don't want to stop the update process if LICENSE is not found for example _ = copyFile(filepath.Join(exeDir, file), filepath.Join(backupDir, file)) } su.logger.Info().Msg("selfupdate: Renaming assets") time.Sleep(2 * time.Second) renamingFailed := false failedEntryNames := make([]string, 0) // Rename the current assets // seanime.exe -> seanime.exe.old // LICENSE -> LICENSE.old for _, file := range files { err = os.Rename(filepath.Join(exeDir, file), filepath.Join(exeDir, file+".old")) // We care about the error ONLY if the file is the executable if err != nil && (file == "seanime" || file == "seanime.exe") { renamingFailed = true failedEntryNames = append(failedEntryNames, file) //su.recover() su.logger.Error().Err(err).Msg("selfupdate: Failed to rename entry") //return err } } if renamingFailed { fmt.Println("---------------------------------") fmt.Println("A second attempt will be made in 30 seconds") fmt.Println("---------------------------------") time.Sleep(30 * time.Second) // Here `failedEntryNames` should only contain NECESSARY files that failed to rename for _, entry := range failedEntryNames { err = os.Rename(filepath.Join(exeDir, entry), filepath.Join(exeDir, entry+".old")) if err != nil { su.logger.Error().Err(err).Msg("selfupdate: Failed to rename entry") su.recover(asset.BrowserDownloadUrl) return err } } } // Now all the files have been renamed, we can move the new release to the exeDir su.logger.Info().Msg("selfupdate: Moving assets") // Move the new release elements to the exeDir err = moveContents(newReleaseDir, exeDir) if err != nil { su.recover(asset.BrowserDownloadUrl) su.logger.Error().Err(err).Msg("selfupdate: Failed to move assets") return err } _ = os.Chmod(su.originalExePath.MustGet(), 0755) // Delete the new release directory _ = os.RemoveAll(newReleaseDir) // Start the new executable su.logger.Info().Msg("selfupdate: Starting new executable") switch runtime.GOOS { case "windows": err = openWindows(su.originalExePath.MustGet()) case "darwin": err = openMacOS(su.originalExePath.MustGet()) case "linux": err = openLinux(su.originalExePath.MustGet()) default: su.logger.Fatal().Msg("selfupdate: Unsupported platform") } // Remove .old files (will fail on Windows for executable) // Remove seanime.exe.old and LICENSE.old for _, file := range files { _ = os.RemoveAll(filepath.Join(exeDir, file+".old")) } // Remove the backup directory _ = os.RemoveAll(backupDir) os.Exit(0) return nil } func openWindows(path string) error { cmd := util.NewCmd("cmd", "/c", "start", "cmd", "/k", path) return cmd.Start() } func openMacOS(path string) error { script := fmt.Sprintf(` tell application "Terminal" do script "%s" activate end tell`, path) cmd := exec.Command("osascript", "-e", script) return cmd.Start() } func openLinux(path string) error { // Filter out the -update flag or we end up in an infinite update loop filteredArgs := slices.DeleteFunc(os.Args, func(s string) bool { return s == "-update" }) // Replace the current process with the updated executable return syscall.Exec(path, filteredArgs, os.Environ()) } // moveContents moves contents of newReleaseDir to exeDir without deleting existing files func moveContents(newReleaseDir, exeDir string) error { // Ensure exeDir exists if err := os.MkdirAll(exeDir, 0755); err != nil { return err } // Copy contents of newReleaseDir to exeDir return copyDir(newReleaseDir, exeDir) } // copyFile copies a single file from src to dst func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() // Create destination directory if it does not exist if err = os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } destinationFile, err := os.Create(dst) if err != nil { return err } defer destinationFile.Close() _, err = io.Copy(destinationFile, sourceFile) return err } // copyDir recursively copies a directory tree, attempting to preserve permissions. func copyDir(src string, dst string) error { src = filepath.Clean(src) dst = filepath.Clean(dst) info, err := os.Stat(src) if err != nil { return err } if err := os.MkdirAll(dst, info.Mode()); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { return err } for _, entry := range entries { srcPath := filepath.Join(src, entry.Name()) dstPath := filepath.Join(dst, entry.Name()) if entry.IsDir() { if err := copyDir(srcPath, dstPath); err != nil { return err } } else { if err := copyFile(srcPath, dstPath); err != nil { return err } } } return nil }