Files
seanime-docker/seanime-2.9.10/internal/updater/selfupdate.go
2025-09-20 14:08:38 +01:00

357 lines
8.8 KiB
Go

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
}