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