| 1 | //go:build windows |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/installlayout" |
| 12 | "reasonix/internal/repair" |
| 13 | ) |
| 14 | |
| 15 | // activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a |
| 16 | // staged NSIS payload: |
| 17 | // |
| 18 | // InstallRoot/ |
| 19 | // reasonix-launcher.exe |
| 20 | // Reasonix.exe (launcher alias when present or portable) |
| 21 | // reasonix-cli.exe (CLI entry; full binary for now) |
| 22 | // current.json |
| 23 | // versions/<version>/ |
| 24 | // reasonix-desktop.exe |
| 25 | // reasonix-cli.exe |
| 26 | // reasonix-update-helper.exe |
| 27 | // |
| 28 | // Any failure before the current.json pointer swap leaves the previous active |
| 29 | // version unchanged. The helper never counts crashes or selects prior versions. |
| 30 | func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error { |
| 31 | if claimed == nil { |
| 32 | return fmt.Errorf("versioned activate: transaction is nil") |
| 33 | } |
| 34 | installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath))) |
| 35 | // When the claimed primary is already under versions/<ver>/, climb to root. |
| 36 | if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" { |
| 37 | installRoot = root |
| 38 | } |
| 39 | version := strings.TrimSpace(claimed.ToVersion) |
| 40 | if err := installlayout.ValidateVersionName(version); err != nil { |
| 41 | // Accept bare product versions from NSIS (1.20.0 → v1.20.0). |
| 42 | if !strings.HasPrefix(version, "v") { |
| 43 | version = "v" + version |
| 44 | } |
| 45 | if err := installlayout.ValidateVersionName(version); err != nil { |
| 46 | return fmt.Errorf("versioned activate: %w", err) |
| 47 | } |
| 48 | } |
| 49 | stagingDir = filepath.Clean(strings.TrimSpace(stagingDir)) |
| 50 | |
| 51 | desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe") |
| 52 | cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe") |
| 53 | helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe") |
| 54 | launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe") |
| 55 | for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} { |
| 56 | info, err := os.Lstat(path) |
| 57 | if err != nil { |
| 58 | return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err) |
| 59 | } |
| 60 | if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { |
| 61 | return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path)) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | requestID := repair.UpdateTransactionID(claimed) |
| 66 | if requestID == "" { |
| 67 | requestID = "helper-" + version |
| 68 | } |
| 69 | if err := installlayout.ActivateVersion(installlayout.ActivationRequest{ |
| 70 | InstallRoot: installRoot, |
| 71 | Version: version, |
| 72 | RequestID: requestID, |
| 73 | Members: []installlayout.Member{ |
| 74 | {Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700}, |
| 75 | {Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700}, |
| 76 | {Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700}, |
| 77 | }, |
| 78 | RootMembers: []installlayout.Member{ |
| 79 | {Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700}, |
| 80 | {Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700}, |
| 81 | {Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700}, |
| 82 | }, |
| 83 | RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"}, |
| 84 | }); err != nil { |
| 85 | return err |
| 86 | } |
| 87 | |
| 88 | // Remove flat release-unit leftovers so the install root is the thin layout. |
| 89 | // Do not remove the launcher/CLI/alias we just wrote. |
| 90 | for _, name := range []string{ |
| 91 | "reasonix-desktop.exe", |
| 92 | "reasonix-guard.exe", |
| 93 | "reasonix-update-helper.exe", // helper lives only under versions/ |
| 94 | } { |
| 95 | _ = os.Remove(filepath.Join(installRoot, name)) |
| 96 | } |
| 97 | // Best-effort retention GC of older version trees. |
| 98 | _ = installlayout.RetainPreviousVersions(installRoot, 0) |
| 99 | _ = installlayout.CleanupStaleStaging(installRoot, 0) |
| 100 | return nil |
| 101 | } |
| 102 | |
| 103 | // preferVersionedWindowsActivation reports whether the staged payload is |
| 104 | // complete enough for versioned-v1 activation. |
| 105 | func preferVersionedWindowsActivation(stagingDir string) bool { |
| 106 | for _, name := range []string{ |
| 107 | "reasonix-desktop.exe", |
| 108 | "reasonix-cli.exe", |
| 109 | "reasonix-update-helper.exe", |
| 110 | "reasonix-launcher.exe", |
| 111 | } { |
| 112 | info, err := os.Lstat(filepath.Join(stagingDir, name)) |
| 113 | if err != nil || !info.Mode().IsRegular() { |
| 114 | return false |
| 115 | } |
| 116 | } |
| 117 | return true |
| 118 | } |
| 119 |