package main import ( "context" _ "embed" "fmt" "log" "os" "path/filepath" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/updater" giteaprovider "AniTrack/updater/gitea" ) //go:embed updater.pub var updaterPublicKey []byte // swapPathProven verifies the exact move the update helper will perform: // a rename from the framework staging area (os.TempDir, where it creates // wails-update-*) onto the running executable's directory. A bare Rename // fails across filesystems (EXDEV: tmpfs /tmp vs persistent $HOME is the // Arch/Fedora default), and the helper's recovery path then deletes the // working binary while failing to restore it — stranding the user with no // app. If this probe fails we skip updates entirely: a loud skip beats a // destructive attempt. Observed in the wild on 2.0.0 (helper log: twenty // "invalid cross-device link" attempts, orphaned .bak, nothing running). func swapPathProven() error { exe, err := os.Executable() if err != nil { return fmt.Errorf("cannot locate executable: %w", err) } return renameProbeOK(os.TempDir(), filepath.Dir(exe)) } // renameProbeOK proves a file can be renamed from tmpDir into targetDir and // cleans up after itself. It exercises the same call (os.Rename) across the // same two directories the helper will use, so any EXDEV or permission // failure surfaces here, harmlessly, instead of mid-swap. func renameProbeOK(tmpDir, targetDir string) error { tmp, err := os.CreateTemp(tmpDir, "anitrack-update-probe-*") if err != nil { return fmt.Errorf("cannot stage update files: %w", err) } tmpName := tmp.Name() if err := tmp.Close(); err != nil { os.Remove(tmpName) return fmt.Errorf("cannot stage update files: %w", err) } probe := filepath.Join(targetDir, ".anitrack-update-probe") if err := os.Rename(tmpName, probe); err != nil { os.Remove(tmpName) return fmt.Errorf("cannot move update into %s: %w", targetDir, err) } if err := os.Remove(probe); err != nil { return fmt.Errorf("cannot clean up probe in %s: %w", targetDir, err) } return nil } // maybeEnableUpdater wires self-updates: desktop production builds only. // Mobile stays on Obtainium; dev builds never phone home. The startup check // is headless — the builtin window opens only when an update is found, via a // second CheckAndInstall (one redundant index round-trip, negligible). func maybeEnableUpdater(app *application.App) { if !updaterAutoCheck { return } if !application.System.IsDesktop() { return } if appVersion() == "" { log.Println("updater: disabled: empty version") return } if err := swapPathProven(); err != nil { log.Printf("updater: disabled: %s", err) return } gh, err := giteaprovider.New(giteaprovider.Config{ BaseURL: "https://git.linuxhg.com", Owner: "john-okeefe", Repo: "Anitrack", AssetName: "AniTrack-linux-amd64", // Beta channel (rc tags) for testing: ANITRACK_UPDATER_CHANNEL=beta. AllowPrerelease: os.Getenv("ANITRACK_UPDATER_CHANNEL") == "beta", }) if err != nil { log.Printf("updater: %s", err) return } if err := app.Updater.Init(updater.Config{ CurrentVersion: appVersion(), Providers: []updater.Provider{gh}, PublicKey: updaterPublicKey, Window: &updater.BuiltinWindow{ CSS: ":root { --accent: #4d9fff; }", }, }); err != nil { log.Printf("updater: init: %s", err) return } go func() { rel, err := app.Updater.Check(context.Background()) if err != nil { log.Printf("updater: check: %s", err) return } if rel == nil { return } log.Printf("updater: %s available, opening installer", rel.Version) if err := app.Updater.CheckAndInstall(context.Background()); err != nil { log.Printf("updater: install: %s", err) } }() }