返回 DeepSeek-Reasonix
main_windows.go
根目录 / desktop / cmd / update-helper / main_windows.go
1 //go:build windows
2
3 package main
4
5 import (
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "flag"
10 "fmt"
11 "io"
12 "log"
13 "os"
14 "path/filepath"
15 "strings"
16 "sync"
17 "syscall"
18 "time"
19
20 "golang.org/x/sys/windows"
21
22 "reasonix/desktop/internal/winuninstall"
23 "reasonix/internal/installlayout"
24 "reasonix/internal/proc"
25 "reasonix/internal/repair"
26 )
27
28 const parentExitTimeout = 2 * time.Minute
29
30 var (
31 waitForProcessExitFn = waitForProcessExit
32 runInstallerFn = runInstaller
33 startRelaunchFn = startRelaunch
34 claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
35 installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
36 recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
37 stageInstallerFn = stageVerifiedInstaller
38 claimInstallerExecutionFn = claimVerifiedInstallerForExecution
39 lstatUpdateStagingFn = os.Lstat
40 reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
41 verifyDesktopHandoffFn = verifyDesktopHandoff
42 )
43
44 func main() {
45 os.Exit(run(os.Args[1:]))
46 }
47
48 func run(args []string) int {
49 var parentPID uint
50 var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
51 fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
52 fs.SetOutput(os.Stderr)
53 fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
54 fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
55 fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
56 fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
57 fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
58 fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
59 fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
60 fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
61 fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
62 if err := fs.Parse(args); err != nil {
63 return 2
64 }
65 logger := newLogger()
66 if installer == "" {
67 logger.Print("missing --installer")
68 return 2
69 }
70 if !validSHA256(installerSHA256) {
71 logger.Print("missing or invalid --installer-sha256")
72 return 2
73 }
74 if toVersion == "" {
75 logger.Print("missing --to-version")
76 return 2
77 }
78 if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
79 logger.Printf("unsupported --install-layout %q", installLayout)
80 return 2
81 }
82 if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
83 logger.Print("missing --created-at")
84 return 2
85 }
86 if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
87 logger.Print("missing --transaction-id")
88 return 2
89 }
90 if installDir == "" {
91 logger.Print("missing --install-dir")
92 return 2
93 }
94 if parentPID != 0 {
95 if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
96 logger.Printf("wait for parent process %d: %v", parentPID, err)
97 notifyHandoffBlockedFn(false)
98 return 1
99 }
100 }
101 if installLayout == installlayout.InstallLayoutVersionedV1 {
102 return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
103 }
104 recoverExisting := func(reason string) int {
105 if relaunch != "" {
106 if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
107 logger.Printf("relaunch after %s: %v", reason, relaunchErr)
108 }
109 }
110 return 1
111 }
112 // The detached helper runs from the update cache, so a plain
113 // repair.ReadPendingUpdate would validate the transaction against the cache
114 // directory and reject every legitimate flat install. Claim through the
115 // explicit install-local launcher path instead; the repair package validates
116 // the complete transaction identity and exact release-unit path set while it
117 // acquires the pending + target locks.
118 claimTargets := windowsReleaseUnitPaths(installDir)
119 claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
120 // The old desktop may acquire the pending lock during its normal shutdown.
121 // Wait for that exact process first, then claim the transaction and hold both
122 // pending and target locks across the installer replacement window.
123 claimed, releaseClaim, err := claimPendingFileUpdateFn(
124 toVersion,
125 createdAt,
126 transactionID,
127 claimLauncher,
128 claimTargets,
129 parentExitTimeout,
130 )
131 if err != nil {
132 logger.Printf("claim pending update: %v", err)
133 return recoverExisting("pending update claim failure")
134 }
135 defer releaseClaim()
136 recoverUnstarted := func() int {
137 releaseClaim()
138 if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
139 logger.Printf("cancel unstarted update: %v", cancelErr)
140 }
141 if relaunch != "" {
142 if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
143 logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
144 }
145 }
146 return 1
147 }
148 claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
149 if err != nil {
150 logger.Printf("bind update installer: %v", err)
151 return recoverUnstarted()
152 }
153 defer func() {
154 if cleanupErr := cleanupInstaller(); cleanupErr != nil {
155 logger.Printf("preserving update installer staging: %v", cleanupErr)
156 }
157 }()
158 stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
159 if err != nil {
160 logger.Printf("create update staging: %v", err)
161 return recoverUnstarted()
162 }
163 stagingOwner, err := lstatUpdateStagingFn(stagingDir)
164 if err != nil {
165 logger.Printf("bind update staging: %v", err)
166 return recoverUnstarted()
167 }
168 defer func() {
169 if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
170 logger.Printf("preserving update staging: %v", cleanupErr)
171 }
172 }()
173 releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
174 if err != nil {
175 logger.Printf("recheck staged installer: %v", err)
176 return recoverUnstarted()
177 }
178 installerErr := runInstallerFn(claimedInstaller, stagingDir)
179 releaseInstallerExecution()
180 if installerErr != nil {
181 logger.Printf("extract installer payload: %v", installerErr)
182 return recoverUnstarted()
183 }
184 publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
185 if err != nil {
186 logger.Printf("publish staged release unit: %v", err)
187 if !publishStarted {
188 releaseClaim()
189 if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
190 logger.Printf("cancel unstarted update: %v", cancelErr)
191 }
192 }
193 if relaunch != "" {
194 if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
195 logger.Printf("relaunch after publish failure: %v", relaunchErr)
196 }
197 }
198 return 1
199 }
200 if receipts == nil {
201 // Versioned-v1 activation: no flat publish receipts and no health
202 // probation. Clear the pending transaction and relaunch the launcher.
203 releaseClaim()
204 if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
205 logger.Printf("clear pending after versioned activate: %v", cancelErr)
206 }
207 if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
208 logger.Printf("clear apply failure after versioned activate: %v", clearErr)
209 }
210 } else {
211 if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
212 logger.Printf("record installed release unit: %v", err)
213 if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
214 logger.Printf("record install failure: %v", markErr)
215 }
216 if relaunch != "" {
217 if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
218 logger.Printf("relaunch after failed install verification: %v", relaunchErr)
219 }
220 }
221 return 1
222 }
223 if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
224 logger.Printf("clear completed update marker: %v", err)
225 }
226 }
227 return relaunchPublishedInstall(logger, relaunch, installDir, "relaunch")
228 }
229
230 func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
231 recoverExisting := func() int {
232 if relaunch != "" {
233 if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
234 logger.Printf("relaunch after versioned update failure: %v", err)
235 }
236 }
237 return 1
238 }
239 if _, err := installlayout.ReadCurrent(installDir); err != nil {
240 logger.Printf("read versioned install pointer: %v", err)
241 return recoverExisting()
242 }
243 claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
244 if err != nil {
245 logger.Printf("bind versioned update installer: %v", err)
246 return recoverExisting()
247 }
248 defer func() {
249 if cleanupErr := cleanupInstaller(); cleanupErr != nil {
250 logger.Printf("preserving update installer staging: %v", cleanupErr)
251 }
252 }()
253 stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
254 if err != nil {
255 logger.Printf("create versioned update staging: %v", err)
256 return recoverExisting()
257 }
258 stagingOwner, err := lstatUpdateStagingFn(stagingDir)
259 if err != nil {
260 logger.Printf("bind versioned update staging: %v", err)
261 return recoverExisting()
262 }
263 defer func() {
264 if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
265 logger.Printf("preserving update staging: %v", cleanupErr)
266 }
267 }()
268 releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
269 if err != nil {
270 logger.Printf("recheck staged versioned installer: %v", err)
271 return recoverExisting()
272 }
273 installerErr := runInstallerFn(claimedInstaller, stagingDir)
274 releaseInstallerExecution()
275 if installerErr != nil {
276 logger.Printf("extract versioned installer payload: %v", installerErr)
277 return recoverExisting()
278 }
279 claimed := &repair.UpdateTransaction{
280 SchemaVersion: 1,
281 ToVersion: toVersion,
282 TargetKind: "file",
283 TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
284 }
285 if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
286 logger.Printf("activate versioned release: %v", err)
287 return recoverExisting()
288 }
289 if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
290 // The release is already active and must not be rolled back for stale
291 // Add/Remove Programs metadata. A later update or full installer retries
292 // this idempotent reconciliation.
293 logger.Printf("reconcile Windows uninstall registration: %v", err)
294 }
295 return relaunchPublishedInstall(logger, relaunch, installDir, "relaunch versioned release")
296 }
297
298 func relaunchPublishedInstall(logger *log.Logger, relaunch, installDir, failVerb string) int {
299 if err := verifyDesktopHandoffFn(installDir, false); err != nil {
300 logger.Printf("installed; restart incomplete: %v", err)
301 notifyHandoffBlockedFn(true)
302 return 1
303 }
304 path := preferRelaunchPath(relaunch, installDir)
305 if path == "" {
306 logger.Print("installed; no stable restart target is available")
307 notifyHandoffBlockedFn(true)
308 return 1
309 }
310 if err := startRelaunchFn(path, installDir); err != nil {
311 logger.Printf("%s: %v", failVerb, err)
312 notifyHandoffBlockedFn(true)
313 return 1
314 }
315 if err := verifyDesktopHandoffFn(installDir, true); err != nil {
316 logger.Printf("installed; restart unconfirmed: %v", err)
317 notifyHandoffBlockedFn(true)
318 return 1
319 }
320 _ = installlayout.RetainPreviousVersions(installDir, 0)
321 return 0
322 }
323
324 // preferRelaunchPath chooses the install-root launcher after a versioned
325 // activation. A retained versions/<old>/ desktop path is never restarted.
326 func preferRelaunchPath(relaunch, installDir string) string {
327 if path, err := installlayout.StableRelaunchPath(installDir); err == nil && path != "" {
328 return path
329 }
330 if installlayout.IsSupersededVersionedDesktop(installDir, relaunch) {
331 return ""
332 }
333 return relaunch
334 }
335
336 func validSHA256(value string) bool {
337 value = strings.TrimSpace(value)
338 if len(value) != sha256.Size*2 {
339 return false
340 }
341 _, err := hex.DecodeString(value)
342 return err == nil
343 }
344
345 func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
346 if !validSHA256(expectedSHA256) {
347 return "", nil, fmt.Errorf("installer SHA-256 is invalid")
348 }
349 sourceInfo, err := os.Lstat(sourcePath)
350 if err != nil {
351 return "", nil, err
352 }
353 if !sourceInfo.Mode().IsRegular() {
354 return "", nil, fmt.Errorf("installer is not a regular file")
355 }
356 dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
357 if err != nil {
358 return "", nil, err
359 }
360 owner, err := os.Lstat(dir)
361 if err != nil {
362 return "", nil, err
363 }
364 cleanup := func() error {
365 return cleanupOwnedWindowsUpdateDirectory(dir, owner)
366 }
367 fail := func(err error) (string, func() error, error) {
368 _ = cleanup()
369 return "", nil, err
370 }
371 source, err := os.Open(sourcePath)
372 if err != nil {
373 return fail(err)
374 }
375 defer source.Close()
376 stagedPath := filepath.Join(dir, "reasonix-installer.exe")
377 staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
378 if err != nil {
379 return fail(err)
380 }
381 hash := sha256.New()
382 _, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
383 syncErr := staged.Sync()
384 closeErr := staged.Close()
385 switch {
386 case copyErr != nil:
387 return fail(copyErr)
388 case syncErr != nil:
389 return fail(syncErr)
390 case closeErr != nil:
391 return fail(closeErr)
392 case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
393 return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
394 }
395 if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
396 return fail(err)
397 }
398 return stagedPath, cleanup, nil
399 }
400
401 func verifyInstallerSHA256(path, expectedSHA256 string) error {
402 info, err := os.Lstat(path)
403 if err != nil {
404 return err
405 }
406 if !info.Mode().IsRegular() {
407 return fmt.Errorf("installer is not a regular file")
408 }
409 f, err := os.Open(path)
410 if err != nil {
411 return err
412 }
413 defer f.Close()
414 hash := sha256.New()
415 if _, err := io.Copy(hash, f); err != nil {
416 return err
417 }
418 if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
419 return fmt.Errorf("installer SHA-256 mismatch")
420 }
421 return nil
422 }
423
424 func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
425 if !validSHA256(expectedSHA256) {
426 return nil, fmt.Errorf("installer SHA-256 is invalid")
427 }
428 pathUTF16, err := windows.UTF16PtrFromString(path)
429 if err != nil {
430 return nil, err
431 }
432 handle, err := windows.CreateFile(
433 pathUTF16,
434 windows.GENERIC_READ,
435 windows.FILE_SHARE_READ,
436 nil,
437 windows.OPEN_EXISTING,
438 windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
439 0,
440 )
441 if err != nil {
442 return nil, err
443 }
444 file := os.NewFile(uintptr(handle), path)
445 fail := func(err error) (func(), error) {
446 _ = file.Close()
447 return nil, err
448 }
449 info, err := file.Stat()
450 if err != nil {
451 return fail(err)
452 }
453 if !info.Mode().IsRegular() {
454 return fail(fmt.Errorf("installer is not a regular file"))
455 }
456 hash := sha256.New()
457 if _, err := io.Copy(hash, file); err != nil {
458 return fail(err)
459 }
460 if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
461 return fail(fmt.Errorf("installer SHA-256 mismatch"))
462 }
463 var once sync.Once
464 return func() {
465 once.Do(func() { _ = file.Close() })
466 }, nil
467 }
468
469 func installStagedWindowsReleaseUnit(
470 claimed *repair.UpdateTransaction,
471 stagingDir string,
472 ) (bool, []repair.FileUpdateInstallReceipt, error) {
473 // v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
474 // fail closed without mutating the live install or leaving flat pending state.
475 if !preferVersionedWindowsActivation(stagingDir) {
476 return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
477 }
478 if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
479 return false, nil, fmt.Errorf("versioned activate: %w", err)
480 }
481 return true, nil, nil
482 }
483
484 func windowsReleaseUnitPaths(installDir string) []string {
485 if installDir == "" {
486 return nil
487 }
488 names := []string{
489 "reasonix-desktop.exe",
490 "reasonix-guard.exe",
491 "reasonix-launcher.exe",
492 "reasonix-update-helper.exe",
493 "reasonix-cli.exe",
494 "Reasonix.exe",
495 }
496 paths := make([]string, 0, len(names))
497 for _, name := range names {
498 paths = append(paths, filepath.Join(installDir, name))
499 }
500 return paths
501 }
502
503 func newLogger() *log.Logger {
504 dir, err := os.UserCacheDir()
505 if err == nil {
506 dir = filepath.Join(dir, "Reasonix", "updates")
507 if err := os.MkdirAll(dir, 0o700); err == nil {
508 if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
509 return log.New(f, "", log.LstdFlags)
510 }
511 }
512 }
513 return log.New(os.Stderr, "", log.LstdFlags)
514 }
515
516 func waitForProcessExit(pid uint32, timeout time.Duration) error {
517 h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
518 if err != nil {
519 if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
520 return nil
521 }
522 return err
523 }
524 defer windows.CloseHandle(h)
525 waitMS := uint32(timeout / time.Millisecond)
526 result, err := windows.WaitForSingleObject(h, waitMS)
527 if err != nil {
528 return err
529 }
530 switch result {
531 case windows.WAIT_OBJECT_0:
532 return nil
533 case uint32(windows.WAIT_TIMEOUT):
534 return fmt.Errorf("timed out after %s", timeout)
535 default:
536 return fmt.Errorf("unexpected wait result %d", result)
537 }
538 }
539
540 func runInstaller(installer, installDir string) error {
541 cmd := proc.VisibleCommand(installer)
542 // Keep the helper itself hidden, but let the NSIS update-progress window be
543 // visible. /REASONIXSTAGE makes the signed installer extract only; the helper
544 // performs every live replacement through the claimed transaction.
545 cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
546 return cmd.Run()
547 }
548
549 func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
550 if path == "" || owner == nil || !owner.IsDir() {
551 return fmt.Errorf("Windows update cleanup identity is incomplete")
552 }
553 for attempt := range 16 {
554 cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
555 from, err := windows.UTF16PtrFromString(path)
556 if err != nil {
557 return err
558 }
559 to, err := windows.UTF16PtrFromString(cleanup)
560 if err != nil {
561 return err
562 }
563 if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
564 if os.IsNotExist(err) {
565 return nil
566 }
567 if os.IsExist(err) {
568 continue
569 }
570 return err
571 }
572 actual, err := os.Lstat(cleanup)
573 if err != nil {
574 return err
575 }
576 if !os.SameFile(owner, actual) {
577 restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
578 restoreTo, toErr := windows.UTF16PtrFromString(path)
579 if fromErr != nil || toErr != nil {
580 return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
581 }
582 if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
583 return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
584 }
585 return fmt.Errorf("Windows update staging changed before cleanup")
586 }
587 return os.RemoveAll(cleanup)
588 }
589 return fmt.Errorf("cannot allocate Windows update cleanup path")
590 }
591
592 func startRelaunch(relaunch, installDir string) error {
593 cmd := proc.VisibleCommand(relaunch)
594 cmd.Dir = installDir
595 cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
596 cmd.Env = append(os.Environ(), "REASONIX_NONINTERACTIVE=1")
597 return cmd.Start()
598 }
599
599 lines GO