| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "runtime" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/config" |
| 13 | "reasonix/internal/installlayout" |
| 14 | "reasonix/internal/repair" |
| 15 | ) |
| 16 | |
| 17 | // errShellMissing identifies a flat release unit whose desktop cannot start: |
| 18 | // the desktop needs the Electron app/ tree beside it, and the 1.18–1.19.2 file |
| 19 | // updaters publish only the three binaries they know by name. |
| 20 | var errShellMissing = errors.New("migrate: flat release unit has no Electron desktop shell") |
| 21 | |
| 22 | const migrateLogName = "legacy-migrate.log" |
| 23 | |
| 24 | // refuseShellMissing commits nothing for an absent or incomplete shell: no |
| 25 | // version directory, no current.json, no health mark and no flat cleanup. |
| 26 | // Where an old updater's transaction is still pending it restores the release |
| 27 | // unit that transaction replaced. |
| 28 | func refuseShellMissing(installRoot, activeVersion string, relaunch bool, cause error) error { |
| 29 | refusal := fmt.Errorf("%w: %s has no %s/ tree", errShellMissing, installRoot, installlayout.AppShellDirName) |
| 30 | if cause != nil { |
| 31 | refusal = fmt.Errorf("%w: %s/ tree is unusable: %w", errShellMissing, installlayout.AppShellDirName, cause) |
| 32 | } |
| 33 | result, rollbackErr := rollbackLegacyPendingUpdate(installRoot, activeVersion) |
| 34 | switch { |
| 35 | case rollbackErr != nil: |
| 36 | refusal = fmt.Errorf("%w; restore the previous release: %w", refusal, rollbackErr) |
| 37 | case result.RolledBack: |
| 38 | refusal = fmt.Errorf("%w; restored %s", refusal, result.FromVersion) |
| 39 | if relaunch { |
| 40 | if err := startRestoredRelease(installRoot); err != nil { |
| 41 | refusal = fmt.Errorf("%w; start the restored release: %w", refusal, err) |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | logMigrateRefusal(installRoot, result.RolledBack, refusal) |
| 46 | return refusal |
| 47 | } |
| 48 | |
| 49 | func rollbackLegacyPendingUpdate(installRoot, activeVersion string) (repair.UpdateRollbackResult, error) { |
| 50 | // Windows cannot replace the running migrator image, and its installer |
| 51 | // carries the shell into update mode, so this state is Unix-only. |
| 52 | if runtime.GOOS == "windows" { |
| 53 | return repair.UpdateRollbackResult{}, nil |
| 54 | } |
| 55 | tx, err := readLegacyPendingUpdate(installRoot, activeVersion) |
| 56 | if err != nil || tx == nil { |
| 57 | return repair.UpdateRollbackResult{}, err |
| 58 | } |
| 59 | result, err := repair.RollbackPendingUpdateExact(tx) |
| 60 | if err == nil && result.MixedInstall { |
| 61 | err = fmt.Errorf("rollback left a mix of two releases") |
| 62 | } |
| 63 | return result, err |
| 64 | } |
| 65 | |
| 66 | // startRestoredRelease relaunches the way the legacy updater itself does: |
| 67 | // through its Guard when the restored unit has one, else the desktop directly. |
| 68 | func startRestoredRelease(installRoot string) error { |
| 69 | if guard := optionalRegular(filepath.Join(installRoot, "reasonix-guard")); guard != "" { |
| 70 | return startDetached(installRoot, guard, "launch", "--detach") |
| 71 | } |
| 72 | if desktop := optionalRegular(filepath.Join(installRoot, installlayout.DesktopBinaryName())); desktop != "" { |
| 73 | return startDetached(installRoot, desktop) |
| 74 | } |
| 75 | return fmt.Errorf("restored release has no entry point") |
| 76 | } |
| 77 | |
| 78 | func startDetached(dir, path string, args ...string) error { |
| 79 | cmd := exec.Command(path, args...) |
| 80 | cmd.Dir = dir |
| 81 | return cmd.Start() |
| 82 | } |
| 83 | |
| 84 | // logMigrateRefusal persists the refusal: a migrator relaunched by an old |
| 85 | // updater has no terminal, so stderr alone reaches nobody. |
| 86 | func logMigrateRefusal(installRoot string, rolledBack bool, refusal error) { |
| 87 | home := config.ReasonixHomeDir() |
| 88 | if home == "" { |
| 89 | return |
| 90 | } |
| 91 | path := filepath.Join(home, "desktop-shell", "logs", migrateLogName) |
| 92 | if os.MkdirAll(filepath.Dir(path), 0o700) != nil { |
| 93 | return |
| 94 | } |
| 95 | f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) |
| 96 | if err != nil { |
| 97 | return |
| 98 | } |
| 99 | defer f.Close() |
| 100 | _, _ = fmt.Fprintf(f, "%s event=legacy_migrate_refused code=shell_missing version=%s rolled_back=%t root=%q detail=%q\n", |
| 101 | time.Now().UTC().Format(time.RFC3339), version, rolledBack, installRoot, refusal.Error()) |
| 102 | } |
| 103 |