| 1 | //go:build windows |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "reasonix/internal/config" |
| 10 | "reasonix/internal/desktopinstance" |
| 11 | "strings" |
| 12 | |
| 13 | "reasonix/desktop/internal/update" |
| 14 | "reasonix/internal/installlayout" |
| 15 | "reasonix/internal/repair" |
| 16 | ) |
| 17 | |
| 18 | // activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a |
| 19 | // staged NSIS payload whose signed manifest names every member: |
| 20 | // |
| 21 | // InstallRoot/ |
| 22 | // Reasonix.exe (canonical GUI entry) |
| 23 | // reasonix-launcher.exe (only when preserving an existing entry) |
| 24 | // reasonix-cli.exe (small CLI entry) |
| 25 | // current.json |
| 26 | // versions/<version>/ |
| 27 | // reasonix-desktop.exe |
| 28 | // reasonix-cli.exe |
| 29 | // reasonix-update-helper.exe |
| 30 | // app/... (Electron shell tree, schema 2 manifests) |
| 31 | // |
| 32 | // Any failure before the current.json pointer swap keeps the previous version active; the helper never rolls back. |
| 33 | func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error { |
| 34 | if claimed == nil { |
| 35 | return fmt.Errorf("versioned activate: transaction is nil") |
| 36 | } |
| 37 | installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath))) |
| 38 | // When the claimed primary is already under versions/<ver>/, climb to root. |
| 39 | if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" { |
| 40 | installRoot = root |
| 41 | } |
| 42 | version := strings.TrimSpace(claimed.ToVersion) |
| 43 | if err := installlayout.ValidateVersionName(version); err != nil { |
| 44 | // Accept bare product versions from NSIS (1.20.0 → v1.20.0). |
| 45 | if !strings.HasPrefix(version, "v") { |
| 46 | version = "v" + version |
| 47 | } |
| 48 | if err := installlayout.ValidateVersionName(version); err != nil { |
| 49 | return fmt.Errorf("versioned activate: %w", err) |
| 50 | } |
| 51 | } |
| 52 | stagingDir = filepath.Clean(strings.TrimSpace(stagingDir)) |
| 53 | |
| 54 | hashes, err := loadWindowsPayloadManifest(stagingDir, strings.TrimSpace(claimed.ToVersion)) |
| 55 | if err != nil { |
| 56 | return fmt.Errorf("versioned activate: %w", err) |
| 57 | } |
| 58 | versionNames := update.WindowsPayloadVersionMembers(hashes) |
| 59 | members, err := stagedWindowsPayloadMembers(stagingDir, hashes, versionNames) |
| 60 | if err != nil { |
| 61 | return fmt.Errorf("versioned activate: %w", err) |
| 62 | } |
| 63 | rootFiles, err := stagedWindowsPayloadMembers(stagingDir, hashes, []string{"reasonix-launcher.exe"}) |
| 64 | if err != nil { |
| 65 | return fmt.Errorf("versioned activate: %w", err) |
| 66 | } |
| 67 | launcherSrc := rootFiles[0].Path |
| 68 | cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe") |
| 69 | const cliEntry = "app/resources/bin/reasonix-cli-launcher.exe" |
| 70 | if _, ok := hashes[cliEntry]; ok { |
| 71 | entry, entryErr := stagedWindowsPayloadMembers(stagingDir, hashes, []string{cliEntry}) |
| 72 | if entryErr != nil { |
| 73 | return fmt.Errorf("versioned activate: %w", entryErr) |
| 74 | } |
| 75 | cliSrc = entry[0].Path |
| 76 | } |
| 77 | |
| 78 | requestID := repair.UpdateTransactionID(claimed) |
| 79 | if requestID == "" { |
| 80 | requestID = "helper-" + version |
| 81 | } |
| 82 | release, err := desktopinstance.PrepareInstall(installRoot, config.ReasonixHomeDir(), false) |
| 83 | if err != nil { |
| 84 | return err |
| 85 | } |
| 86 | defer release() |
| 87 | if err := installlayout.ActivateVersion(installlayout.ActivationRequest{ |
| 88 | InstallRoot: installRoot, |
| 89 | Version: version, |
| 90 | RequestID: requestID, |
| 91 | CheckProcesses: func() error { return desktopinstance.CheckInstallVacant(installRoot, config.ReasonixHomeDir()) }, |
| 92 | Members: members, |
| 93 | RequiredNames: versionNames, |
| 94 | WindowsRootEntries: &installlayout.WindowsRootEntrySources{ |
| 95 | LauncherPath: launcherSrc, |
| 96 | CLIEntryPath: cliSrc, |
| 97 | }, |
| 98 | }); err != nil { |
| 99 | return err |
| 100 | } |
| 101 | |
| 102 | // Remove flat release-unit leftovers so the install root is the thin layout. |
| 103 | // Do not remove the launcher/CLI/alias we just wrote. |
| 104 | for _, name := range []string{ |
| 105 | "reasonix-desktop.exe", |
| 106 | "reasonix-guard.exe", |
| 107 | "reasonix-update-helper.exe", // helper lives only under versions/ |
| 108 | } { |
| 109 | _ = os.Remove(filepath.Join(installRoot, name)) |
| 110 | } |
| 111 | // Best-effort retention GC of older version trees. |
| 112 | _ = installlayout.CleanupStaleStaging(installRoot, 0) |
| 113 | return nil |
| 114 | } |
| 115 | |
| 116 | // preferVersionedWindowsActivation reports whether the staged payload is |
| 117 | // complete enough for versioned-v1 activation. |
| 118 | func preferVersionedWindowsActivation(stagingDir string) bool { |
| 119 | for _, name := range []string{ |
| 120 | "reasonix-desktop.exe", |
| 121 | "reasonix-cli.exe", |
| 122 | "reasonix-update-helper.exe", |
| 123 | "reasonix-launcher.exe", |
| 124 | } { |
| 125 | info, err := os.Lstat(filepath.Join(stagingDir, name)) |
| 126 | if err != nil || !info.Mode().IsRegular() { |
| 127 | return false |
| 128 | } |
| 129 | } |
| 130 | return true |
| 131 | } |
| 132 |