返回 DeepSeek-Reasonix
updater_mac.go
根目录 / desktop / updater_mac.go
1 //go:build darwin
2
3 package main
4
5 import (
6 "encoding/json"
7 "errors"
8 "flag"
9 "fmt"
10 "io"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "reflect"
15 "strings"
16 "syscall"
17 "time"
18
19 "golang.org/x/sys/unix"
20
21 "reasonix/internal/repair"
22 )
23
24 const (
25 macBundleID = "com.wails.reasonix-desktop" // frozen bundle identity; renaming it would orphan installed apps
26 macUpdateHandoffArg = "--reasonix-mac-update-handoff"
27 macHandoffReadyFD = 3
28 macHandoffProceedFD = 4
29 macHandoffReadyWait = 5 * time.Second
30 // macUpdateHandoffLockTimeout covers concurrent Guard rollback/prepare during
31 // the swap; the PID wait runs before the lock so a long exit starves no repairs.
32 macUpdateHandoffLockTimeout = 2 * time.Minute
33 )
34
35 var (
36 // Test seams keep desktop tests independent of a real signed bundle and the executable path.
37 openCommand = func(args ...string) *exec.Cmd {
38 return exec.Command("/usr/bin/open", args...)
39 }
40 readMacUpdateHandoff = repair.ReadPendingUpdate
41 claimMacUpdateHandoff = repair.ClaimPendingAppBundleUpdateHandoffExact
42 cancelMacUpdateHandoff = repair.CancelPendingAppBundleUpdateHandoffExact
43 clearMacUpdateHandoff = repair.ClearClaimedAppBundleUpdateHandoff
44 verifyMacHandoffApp = verifyMacApp
45 cleanupMacHandoffStaging = repair.CleanupAppBundleUpdateHandoffStaging
46 cleanupMacHandoffReplacement = repair.CleanupAppBundleUpdateReplacement
47 macHandoffCopy = func(oldPath, newPath string) error {
48 return exec.Command("/usr/bin/ditto", oldPath, newPath).Run()
49 }
50 macHandoffRename = macUpdateRenameNoReplace
51 macHandoffLogPath = func() string {
52 cacheDir, err := updateCacheDir()
53 if err != nil {
54 return ""
55 }
56 return filepath.Join(cacheDir, "update-helper.log")
57 }
58 )
59
60 func applyMac(zipPath, targetVersion string, ownerPID int) error {
61 if !macSelfUpdateAllowed() {
62 return fmt.Errorf("macOS automatic update is not enabled for this build")
63 }
64 currentApp, err := currentMacAppBundle()
65 if err != nil {
66 return err
67 }
68 staging, err := os.MkdirTemp("", "reasonix-mac-update-*")
69 if err != nil {
70 return err
71 }
72 stagingOwner, err := os.Lstat(staging)
73 if err != nil {
74 return err
75 }
76 handedOff := false
77 defer func() {
78 if !handedOff {
79 _ = cleanupOwnedMacUpdateDirectory(staging, stagingOwner)
80 }
81 }()
82 if err := exec.Command("/usr/bin/ditto", "-x", "-k", zipPath, staging).Run(); err != nil {
83 return fmt.Errorf("extract macOS update: %w", err)
84 }
85 nextApp, err := findMacApp(staging)
86 if err != nil {
87 return err
88 }
89 if err := verifyMacApp(nextApp); err != nil {
90 return err
91 }
92 backupApp := currentApp + ".reasonix-update-backup"
93 exe, err := os.Executable()
94 if err != nil {
95 return err
96 }
97 tx, err := repair.PrepareAppBundleUpdateHandoff(
98 version,
99 targetVersion,
100 currentApp,
101 backupApp,
102 nextApp,
103 staging,
104 ownerPID,
105 )
106 if err != nil {
107 return err
108 }
109 readyReader, readyWriter, err := os.Pipe()
110 if err != nil {
111 if _, cancelErr := cancelMacUpdateHandoff(tx, macUpdateHandoffLockTimeout); cancelErr != nil {
112 return fmt.Errorf("create macOS update readiness pipe: %w; cancel prepared update: %w", err, cancelErr)
113 }
114 return fmt.Errorf("create macOS update readiness pipe: %w", err)
115 }
116 proceedReader, proceedWriter, err := os.Pipe()
117 if err != nil {
118 _ = readyReader.Close()
119 _ = readyWriter.Close()
120 if _, cancelErr := cancelMacUpdateHandoff(tx, macUpdateHandoffLockTimeout); cancelErr != nil {
121 return fmt.Errorf("create macOS update proceed pipe: %w; cancel prepared update: %w", err, cancelErr)
122 }
123 return fmt.Errorf("create macOS update proceed pipe: %w", err)
124 }
125 defer readyReader.Close()
126 defer proceedWriter.Close()
127 // Detach a self-subprocess that holds the shared repair mutation lock for the
128 // swap window: a shell helper cannot share Go flock keys (LockRepairMutations).
129 cmd := exec.Command(exe,
130 macUpdateHandoffArg,
131 "-to-version", tx.ToVersion,
132 "-created-at", tx.CreatedAt,
133 "-transaction-id", repair.UpdateTransactionID(tx),
134 "-owner-pid", fmt.Sprintf("%d", ownerPID),
135 "-ready-fd", fmt.Sprintf("%d", macHandoffReadyFD),
136 "-proceed-fd", fmt.Sprintf("%d", macHandoffProceedFD),
137 )
138 cmd.ExtraFiles = []*os.File{readyWriter, proceedReader}
139 if err := cmd.Start(); err != nil {
140 _ = readyWriter.Close()
141 _ = proceedReader.Close()
142 if _, cancelErr := cancelMacUpdateHandoff(tx, macUpdateHandoffLockTimeout); cancelErr != nil {
143 return fmt.Errorf("%w; cancel prepared update: %w", err, cancelErr)
144 }
145 return err
146 }
147 _ = readyWriter.Close()
148 _ = proceedReader.Close()
149 abortHandoff := func(cause error) error {
150 _ = proceedWriter.Close()
151 _ = cmd.Process.Kill()
152 _ = cmd.Wait()
153 cancelled, cancelErr := cancelMacUpdateHandoff(tx, macUpdateHandoffLockTimeout)
154 if cancelErr != nil {
155 return fmt.Errorf("%w; cancel prepared update: %w", cause, cancelErr)
156 }
157 if cleanupErr := cleanupMacHandoffStaging(cancelled); cleanupErr != nil {
158 return fmt.Errorf("%w; cleanup prepared update: %w", cause, cleanupErr)
159 }
160 return cause
161 }
162 if err := waitForMacHandoffReady(readyReader, macHandoffReadyWait); err != nil {
163 return abortHandoff(fmt.Errorf("macOS update helper did not become ready: %w", err))
164 }
165 if _, err := io.WriteString(proceedWriter, "go"); err != nil {
166 return abortHandoff(fmt.Errorf("release macOS update helper: %w", err))
167 }
168 if err := proceedWriter.Close(); err != nil {
169 return abortHandoff(fmt.Errorf("release macOS update helper: %w", err))
170 }
171 handedOff = true
172 return nil
173 }
174
175 func waitForMacHandoffReady(reader *os.File, timeout time.Duration) error {
176 if reader == nil {
177 return fmt.Errorf("readiness pipe is unavailable")
178 }
179 if err := reader.SetReadDeadline(time.Now().Add(timeout)); err != nil {
180 return err
181 }
182 var response macHandoffReadyResponse
183 if err := json.NewDecoder(io.LimitReader(reader, 64<<10)).Decode(&response); err != nil {
184 return err
185 }
186 switch response.Status {
187 case "ready":
188 return nil
189 case "error":
190 phase := strings.TrimSpace(response.Phase)
191 if phase == "" {
192 phase = "startup"
193 }
194 detail := strings.TrimSpace(response.Error)
195 if detail == "" {
196 detail = "unknown helper error"
197 }
198 return fmt.Errorf("helper failed during %s: %s", phase, detail)
199 default:
200 return fmt.Errorf("unexpected readiness response %q", response.Status)
201 }
202 }
203
204 type macHandoffReadyResponse struct {
205 Status string `json:"status"`
206 Phase string `json:"phase,omitempty"`
207 Error string `json:"error,omitempty"`
208 }
209
210 func writeMacHandoffReadyResponse(fd int, response macHandoffReadyResponse) error {
211 if fd == 0 {
212 return nil
213 }
214 ready := os.NewFile(uintptr(fd), "reasonix-update-ready")
215 if ready == nil {
216 return fmt.Errorf("readiness pipe is unavailable")
217 }
218 defer ready.Close()
219 return json.NewEncoder(ready).Encode(response)
220 }
221
222 func reportMacHandoffStartupFailure(cfg macUpdateHandoffConfig, phase string, err error) {
223 if err == nil {
224 return
225 }
226 _ = writeMacHandoffReadyResponse(cfg.ReadyFD, macHandoffReadyResponse{
227 Status: "error",
228 Phase: phase,
229 Error: err.Error(),
230 })
231 }
232
233 // maybeRunMacUpdateHandoff handles the detached self-update child before the shell starts.
234 func maybeRunMacUpdateHandoff(args []string) (handled bool, exitCode int) {
235 if len(args) == 0 || args[0] != macUpdateHandoffArg {
236 return false, 0
237 }
238 cfg, err := parseMacUpdateHandoffArgs(args[1:])
239 if err != nil {
240 fmt.Fprintln(os.Stderr, "macOS update handoff:", err)
241 return true, 2
242 }
243 return true, runMacUpdateHandoff(cfg)
244 }
245
246 type macUpdateHandoffConfig struct {
247 ToVersion string
248 CreatedAt string
249 TransactionID string
250 // OwnerPID must exit before the bundle swap: the shell parent process under Electron.
251 OwnerPID int
252 ReadyFD int
253 ProceedFD int
254 }
255
256 func parseMacUpdateHandoffArgs(args []string) (macUpdateHandoffConfig, error) {
257 fs := flag.NewFlagSet("reasonix-mac-update-handoff", flag.ContinueOnError)
258 var cfg macUpdateHandoffConfig
259 fs.StringVar(&cfg.ToVersion, "to-version", "", "pending update target version")
260 fs.StringVar(&cfg.CreatedAt, "created-at", "", "pending update creation timestamp")
261 fs.StringVar(&cfg.TransactionID, "transaction-id", "", "complete pending update identity")
262 fs.IntVar(&cfg.OwnerPID, "owner-pid", 0, "process that must exit before the bundle swap")
263 fs.IntVar(&cfg.ReadyFD, "ready-fd", 0, "parent readiness pipe")
264 fs.IntVar(&cfg.ProceedFD, "proceed-fd", 0, "parent proceed pipe")
265 if err := fs.Parse(args); err != nil {
266 return macUpdateHandoffConfig{}, err
267 }
268 if fs.NArg() != 0 {
269 return macUpdateHandoffConfig{}, fmt.Errorf("unexpected handoff arguments")
270 }
271 cfg.ToVersion = strings.TrimSpace(cfg.ToVersion)
272 cfg.CreatedAt = strings.TrimSpace(cfg.CreatedAt)
273 cfg.TransactionID = strings.TrimSpace(cfg.TransactionID)
274 if cfg.ToVersion == "" || cfg.CreatedAt == "" || cfg.TransactionID == "" || cfg.OwnerPID <= 0 {
275 return macUpdateHandoffConfig{}, fmt.Errorf("missing required handoff arguments")
276 }
277 if (cfg.ReadyFD == 0) != (cfg.ProceedFD == 0) ||
278 (cfg.ReadyFD != 0 && (cfg.ReadyFD < 3 || cfg.ProceedFD < 3 || cfg.ReadyFD == cfg.ProceedFD)) {
279 return macUpdateHandoffConfig{}, fmt.Errorf("invalid handoff pipe arguments")
280 }
281 return cfg, nil
282 }
283
284 func runMacUpdateHandoff(cfg macUpdateHandoffConfig) int {
285 logFile := appendMacHandoffLog(macHandoffLogPath())
286 logf := func(format string, args ...any) {
287 msg := fmt.Sprintf(format, args...)
288 fmt.Fprintln(os.Stderr, msg)
289 if logFile != nil {
290 fmt.Fprintln(logFile, msg)
291 }
292 }
293 if logFile != nil {
294 defer logFile.Close()
295 }
296 cleanupStaging := func(tx *repair.UpdateTransaction) {
297 if err := cleanupMacHandoffStaging(tx); err != nil {
298 logf("preserving update staging: %v", err)
299 }
300 }
301 pending, err := readMacUpdateHandoff()
302 if err != nil {
303 logf("cannot read pending update handoff: %v", err)
304 reportMacHandoffStartupFailure(cfg, "read-pending-transaction", err)
305 return 1
306 }
307 if !macHandoffIdentityMatches(pending, cfg) {
308 err := fmt.Errorf("pending update does not match handoff identity")
309 logf("%v", err)
310 reportMacHandoffStartupFailure(cfg, "validate-transaction-identity", err)
311 return 1
312 }
313 if err := completeMacHandoffHandshake(cfg); err != nil {
314 logf("macOS update parent handshake failed: %v", err)
315 if cancelled, cancelErr := cancelMacUpdateHandoff(pending, macUpdateHandoffLockTimeout); cancelErr == nil {
316 cleanupStaging(cancelled)
317 if !macProcessAlive(cancelled.HandoffOwnerPID) {
318 _ = openCommand(cancelled.TargetPath).Start()
319 }
320 } else {
321 logf("failed to cancel unacknowledged update handoff: %v", cancelErr)
322 }
323 return 1
324 }
325 logf("macOS update handoff started for PID %d", pending.HandoffOwnerPID)
326
327 // Wait for the exact desktop PID before taking mutation locks so a long
328 // exit wait does not block unrelated project repairs.
329 if err := waitForPIDExit(pending.HandoffOwnerPID, 60*time.Second); err != nil {
330 logf("timed out waiting for PID %d to exit: %v", pending.HandoffOwnerPID, err)
331 if cancelled, cancelErr := cancelMacUpdateHandoff(pending, macUpdateHandoffLockTimeout); cancelErr == nil {
332 cleanupStaging(cancelled)
333 } else {
334 logf("failed to cancel timed-out handoff: %v", cancelErr)
335 }
336 return 1
337 }
338
339 // Claim re-reads the full pending transaction while holding both its state
340 // lock and the same target locks as Guard rollback.
341 claimed, release, err := claimMacUpdateHandoff(
342 cfg.ToVersion,
343 cfg.CreatedAt,
344 cfg.TransactionID,
345 macUpdateHandoffLockTimeout,
346 )
347 if err != nil {
348 logf("failed to claim pending update handoff: %v", err)
349 cancelled, cancelErr := cancelMacUpdateHandoff(pending, macUpdateHandoffLockTimeout)
350 if cancelErr != nil {
351 logf("failed to cancel rejected update handoff: %v", cancelErr)
352 return 1
353 }
354 cleanupStaging(cancelled)
355 if verifyErr := verifyMacHandoffApp(cancelled.TargetPath); verifyErr != nil {
356 logf("original app bundle no longer verifies: %v", verifyErr)
357 return 1
358 }
359 _ = openCommand(cancelled.TargetPath).Start()
360 return 1
361 }
362 if !reflect.DeepEqual(pending, claimed) {
363 release()
364 logf("pending update changed while waiting for the desktop process")
365 return 1
366 }
367 defer release()
368 oldApp := claimed.TargetPath
369 newApp := claimed.HandoffAppPath
370 backupApp := claimed.BackupPath
371 clearPending := func() error {
372 if err := clearMacUpdateHandoff(claimed); err != nil {
373 logf("failed to clear pending update handoff: %v", err)
374 return err
375 }
376 return nil
377 }
378 if err := verifyMacHandoffApp(newApp); err != nil {
379 logf("replacement app bundle no longer verifies: %v", err)
380 if clearErr := clearPending(); clearErr != nil {
381 return 1
382 }
383 cleanupStaging(claimed)
384 _ = openCommand(oldApp).Start()
385 return 1
386 }
387 if err := repair.VerifyAppBundleUpdateHandoffSource(claimed); err != nil {
388 logf("replacement app bundle source changed: %v", err)
389 if clearErr := clearPending(); clearErr != nil {
390 return 1
391 }
392 cleanupStaging(claimed)
393 _ = openCommand(oldApp).Start()
394 return 1
395 }
396 if err := repair.VerifyAppBundleUpdateHandoffOriginal(claimed); err != nil {
397 logf("installed app bundle changed before swap: %v", err)
398 if clearErr := clearPending(); clearErr != nil {
399 return 1
400 }
401 cleanupStaging(claimed)
402 _ = openCommand(oldApp).Start()
403 return 1
404 }
405
406 publishedReplacement := false
407 rollback := func() error {
408 logf("rolling back macOS update")
409 failedApp := ""
410 retainedFailedApp := false
411 failedAppVerified := false
412 if publishedReplacement {
413 var retainErr error
414 failedApp, retainErr = retainMacHandoffNode(oldApp, "reasonix-update-failed")
415 if retainErr != nil {
416 if !os.IsNotExist(retainErr) {
417 return fmt.Errorf("retain failed replacement bundle: %w", retainErr)
418 }
419 } else {
420 retainedFailedApp = true
421 if err := repair.VerifyAppBundleUpdateHandoffReplacement(claimed, failedApp); err != nil {
422 // Preserve the changed replacement at the failed path and restore
423 // the independently verified backup; putting an unverified bundle
424 // back at the live path would make the failed rollback executable.
425 logf("preserving changed replacement bundle at %s: %v", failedApp, err)
426 } else {
427 failedAppVerified = true
428 }
429 }
430 }
431 if err := macHandoffRename(backupApp, oldApp); err != nil {
432 if retainedFailedApp && failedAppVerified {
433 if verifyErr := repair.VerifyAppBundleUpdateHandoffReplacement(claimed, failedApp); verifyErr != nil {
434 return fmt.Errorf("restore backup bundle: %w (retained replacement changed: %w)", err, verifyErr)
435 }
436 if compensateErr := macHandoffRename(failedApp, oldApp); compensateErr != nil {
437 return fmt.Errorf("restore backup bundle: %w (failed to restore replacement bundle: %w)", err, compensateErr)
438 }
439 }
440 return fmt.Errorf("restore backup bundle: %w", err)
441 }
442 if err := repair.VerifyAppBundleUpdateHandoffOriginal(claimed); err != nil {
443 rejected, retainErr := retainMacHandoffNode(oldApp, "reasonix-update-rejected")
444 if retainErr != nil {
445 return fmt.Errorf("restored backup bundle changed: %w (retain rejected bundle: %w)", err, retainErr)
446 }
447 if retainedFailedApp && failedAppVerified {
448 if verifyErr := repair.VerifyAppBundleUpdateHandoffReplacement(claimed, failedApp); verifyErr != nil {
449 return fmt.Errorf("restored backup bundle changed: %w (rejected bundle retained at %s; prior live bundle changed: %w)", err, rejected, verifyErr)
450 }
451 if compensateErr := macHandoffRename(failedApp, oldApp); compensateErr != nil {
452 return fmt.Errorf("restored backup bundle changed: %w (rejected bundle retained at %s; restore prior live bundle: %w)", err, rejected, compensateErr)
453 }
454 }
455 return fmt.Errorf("restored backup bundle changed: %w (rejected bundle retained at %s)", err, rejected)
456 }
457 if retainedFailedApp && failedAppVerified {
458 if err := cleanupMacHandoffReplacement(claimed, failedApp); err != nil {
459 logf("preserving failed replacement bundle: %v", err)
460 }
461 }
462 if clearErr := clearPending(); clearErr != nil {
463 return fmt.Errorf("clear restored update handoff: %w", clearErr)
464 }
465 _ = exec.Command("/usr/bin/xattr", "-dr", "com.apple.quarantine", oldApp).Run()
466 if err := openCommand("-n", oldApp).Run(); err != nil {
467 _ = openCommand(oldApp).Run()
468 }
469 cleanupStaging(claimed)
470 return nil
471 }
472
473 if err := macHandoffRename(oldApp, backupApp); err != nil {
474 logf("failed to move current app bundle to backup: %v", err)
475 if clearErr := clearPending(); clearErr != nil {
476 return 1
477 }
478 cleanupStaging(claimed)
479 _ = openCommand(oldApp).Start()
480 return 1
481 }
482 if err := repair.VerifyAppBundleUpdateHandoffBackup(claimed); err != nil {
483 logf("rollback backup changed during swap: %v", err)
484 if rollbackErr := rollback(); rollbackErr != nil {
485 logf("failed to restore backup bundle: %v", rollbackErr)
486 }
487 return 1
488 }
489
490 installRoot, err := os.MkdirTemp(filepath.Dir(oldApp), ".reasonix-update-install-*")
491 if err != nil {
492 logf("failed to create replacement staging directory: %v", err)
493 if rollbackErr := rollback(); rollbackErr != nil {
494 logf("failed to restore backup bundle: %v", rollbackErr)
495 }
496 return 1
497 }
498 installRootOwner, err := os.Lstat(installRoot)
499 if err != nil {
500 logf("failed to bind replacement staging directory: %v", err)
501 if rollbackErr := rollback(); rollbackErr != nil {
502 logf("failed to restore backup bundle: %v", rollbackErr)
503 }
504 return 1
505 }
506 defer func() {
507 if cleanupErr := cleanupOwnedMacUpdateDirectory(installRoot, installRootOwner); cleanupErr != nil {
508 logf("preserving replacement staging directory: %v", cleanupErr)
509 }
510 }()
511 installApp := filepath.Join(installRoot, filepath.Base(oldApp))
512 if err := macHandoffCopy(newApp, installApp); err != nil {
513 logf("failed to stage replacement app bundle: %v", err)
514 if rollbackErr := rollback(); rollbackErr != nil {
515 logf("failed to restore backup bundle: %v", rollbackErr)
516 }
517 return 1
518 }
519 if err := repair.VerifyAppBundleUpdateHandoffSource(claimed); err != nil {
520 logf("replacement app bundle source changed during copy: %v", err)
521 if rollbackErr := rollback(); rollbackErr != nil {
522 logf("failed to restore backup bundle: %v", rollbackErr)
523 }
524 return 1
525 }
526 if err := repair.VerifyAppBundleUpdateHandoffReplacement(claimed, installApp); err != nil {
527 logf("staged replacement app bundle differs from verified source: %v", err)
528 if rollbackErr := rollback(); rollbackErr != nil {
529 logf("failed to restore backup bundle: %v", rollbackErr)
530 }
531 return 1
532 }
533 if err := verifyMacHandoffApp(installApp); err != nil {
534 logf("staged replacement app bundle no longer verifies: %v", err)
535 if rollbackErr := rollback(); rollbackErr != nil {
536 logf("failed to restore backup bundle: %v", rollbackErr)
537 }
538 return 1
539 }
540 if err := repair.VerifyAppBundleUpdateHandoffBackup(claimed); err != nil {
541 logf("rollback backup changed while staging replacement: %v", err)
542 if rollbackErr := rollback(); rollbackErr != nil {
543 logf("failed to restore backup bundle: %v", rollbackErr)
544 }
545 return 1
546 }
547 if err := macHandoffRename(installApp, oldApp); err != nil {
548 logf("failed to publish replacement app bundle: %v", err)
549 if rollbackErr := rollback(); rollbackErr != nil {
550 logf("failed to restore backup bundle: %v", rollbackErr)
551 }
552 return 1
553 }
554 publishedReplacement = true
555 if err := repair.VerifyAppBundleUpdateHandoffTarget(claimed); err != nil {
556 logf("installed app bundle differs from verified source: %v", err)
557 if rollbackErr := rollback(); rollbackErr != nil {
558 logf("failed to restore backup bundle: %v", rollbackErr)
559 }
560 return 1
561 }
562 if err := verifyMacHandoffApp(oldApp); err != nil {
563 logf("installed app bundle no longer verifies: %v", err)
564 if rollbackErr := rollback(); rollbackErr != nil {
565 logf("failed to restore backup bundle: %v", rollbackErr)
566 }
567 return 1
568 }
569 _ = exec.Command("/usr/bin/xattr", "-dr", "com.apple.quarantine", oldApp).Run()
570 if err := openCommand("-n", oldApp).Run(); err != nil {
571 logf("LaunchServices rejected the replacement app bundle: %v", err)
572 if rollbackErr := rollback(); rollbackErr != nil {
573 logf("failed to restore backup bundle: %v", rollbackErr)
574 }
575 return 1
576 }
577 logf("replacement app bundle launched")
578 cleanupStaging(claimed)
579 return 0
580 }
581
582 func macHandoffIdentityMatches(pending *repair.UpdateTransaction, cfg macUpdateHandoffConfig) bool {
583 return strings.TrimSpace(pending.ToVersion) == cfg.ToVersion &&
584 strings.TrimSpace(pending.CreatedAt) == cfg.CreatedAt &&
585 repair.UpdateTransactionID(pending) == cfg.TransactionID &&
586 pending.HandoffOwnerPID > 0 && pending.HandoffOwnerPID == cfg.OwnerPID
587 }
588
589 func completeMacHandoffHandshake(cfg macUpdateHandoffConfig) error {
590 if cfg.ReadyFD == 0 && cfg.ProceedFD == 0 {
591 return nil
592 }
593 proceed := os.NewFile(uintptr(cfg.ProceedFD), "reasonix-update-proceed")
594 if proceed == nil {
595 return fmt.Errorf("handoff pipe is unavailable")
596 }
597 if err := writeMacHandoffReadyResponse(cfg.ReadyFD, macHandoffReadyResponse{Status: "ready"}); err != nil {
598 _ = proceed.Close()
599 return fmt.Errorf("signal readiness: %w", err)
600 }
601 buf := make([]byte, len("go"))
602 if _, err := io.ReadFull(proceed, buf); err != nil {
603 _ = proceed.Close()
604 return fmt.Errorf("wait for parent release: %w", err)
605 }
606 if err := proceed.Close(); err != nil {
607 return fmt.Errorf("wait for parent release: %w", err)
608 }
609 if string(buf) != "go" {
610 return fmt.Errorf("unexpected parent release response")
611 }
612 return nil
613 }
614
615 func retainMacHandoffNode(path, suffix string) (string, error) {
616 for attempt := range 16 {
617 retained := fmt.Sprintf(
618 "%s.%s-%d-%d",
619 path,
620 suffix,
621 time.Now().UTC().UnixNano(),
622 attempt,
623 )
624 if err := macHandoffRename(path, retained); err != nil {
625 if os.IsExist(err) {
626 continue
627 }
628 return "", err
629 }
630 return retained, nil
631 }
632 return "", fmt.Errorf("cannot allocate retained macOS update path")
633 }
634
635 var macUpdateCleanupAfterRename = func(string, string) {}
636
637 // macUpdateRenameNoReplace prefers RENAME_EXCL and falls back on volumes such
638 // as exFAT to an existence check plus os.Rename. The fallback is best-effort:
639 // callers hold Reasonix's mutation locks, and os.ErrExist only means the
640 // destination was observed; it is not an atomic no-replace guarantee.
641 func macUpdateRenameNoReplace(oldPath, newPath string) error {
642 return macRenameNoReplace(macExclusiveRename, oldPath, newPath)
643 }
644
645 func macExclusiveRename(oldPath, newPath string) error {
646 return unix.RenameatxNp(unix.AT_FDCWD, oldPath, unix.AT_FDCWD, newPath, unix.RENAME_EXCL)
647 }
648
649 func macRenameNoReplace(exclusive func(string, string) error, oldPath, newPath string) error {
650 err := exclusive(oldPath, newPath)
651 if err == nil || !(errors.Is(err, syscall.ENOTSUP) || errors.Is(err, syscall.ENOSYS)) {
652 return err
653 }
654 if _, statErr := os.Lstat(newPath); statErr == nil {
655 return fmt.Errorf("macOS update rename target already exists (best-effort under Reasonix mutation lock): %w", os.ErrExist)
656 } else if !os.IsNotExist(statErr) {
657 return statErr
658 }
659 if err := os.Rename(oldPath, newPath); err != nil {
660 return fmt.Errorf("macOS update rename (best-effort under Reasonix mutation lock): %w", err)
661 }
662 return nil
663 }
664
665 func cleanupOwnedMacUpdateDirectory(path string, owner os.FileInfo) error {
666 if strings.TrimSpace(path) == "" || owner == nil || !owner.IsDir() {
667 return fmt.Errorf("macOS update cleanup identity is incomplete")
668 }
669 for attempt := range 16 {
670 cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
671 err := macUpdateRenameNoReplace(path, cleanup)
672 if err != nil {
673 if os.IsNotExist(err) {
674 return nil
675 }
676 if os.IsExist(err) {
677 continue
678 }
679 return err
680 }
681 macUpdateCleanupAfterRename(path, cleanup)
682 actual, statErr := os.Lstat(cleanup)
683 if statErr != nil {
684 return statErr
685 }
686 if !os.SameFile(owner, actual) {
687 if restoreErr := macUpdateRenameNoReplace(cleanup, path); restoreErr != nil {
688 return fmt.Errorf("macOS update directory changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
689 }
690 return fmt.Errorf("macOS update directory changed before cleanup")
691 }
692 return os.RemoveAll(cleanup)
693 }
694 return fmt.Errorf("cannot allocate macOS update cleanup path")
695 }
696
697 func appendMacHandoffLog(path string) *os.File {
698 path = strings.TrimSpace(path)
699 if path == "" {
700 return nil
701 }
702 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
703 return nil
704 }
705 f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
706 if err != nil {
707 return nil
708 }
709 return f
710 }
711
712 func waitForPIDExit(pid int, timeout time.Duration) error {
713 deadline := time.Now().Add(timeout)
714 for {
715 if !macProcessAlive(pid) {
716 return nil
717 }
718 if time.Now().After(deadline) {
719 return fmt.Errorf("process still running")
720 }
721 time.Sleep(200 * time.Millisecond)
722 }
723 }
724
725 func macProcessAlive(pid int) bool {
726 if pid <= 0 {
727 return false
728 }
729 proc, err := os.FindProcess(pid)
730 if err != nil {
731 return false
732 }
733 // Signal 0 probes existence without delivering a real signal.
734 err = proc.Signal(syscall.Signal(0))
735 return err == nil
736 }
737
738 func findMacApp(root string) (string, error) {
739 direct := filepath.Join(root, "Reasonix.app")
740 if _, err := os.Stat(filepath.Join(direct, "Contents", "Info.plist")); err == nil {
741 return direct, nil
742 }
743 var found string
744 err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
745 if err != nil || found != "" {
746 return err
747 }
748 if d.IsDir() && strings.HasSuffix(path, ".app") {
749 if _, statErr := os.Stat(filepath.Join(path, "Contents", "Info.plist")); statErr == nil {
750 found = path
751 return filepath.SkipDir
752 }
753 }
754 return nil
755 })
756 if err != nil {
757 return "", err
758 }
759 if found == "" {
760 return "", fmt.Errorf("update: no .app bundle found in macOS update archive")
761 }
762 return found, nil
763 }
764
765 func verifyMacApp(appPath string) error {
766 info := filepath.Join(appPath, "Contents", "Info.plist")
767 out, err := exec.Command("/usr/libexec/PlistBuddy", "-c", "Print :CFBundleIdentifier", info).Output()
768 if err != nil {
769 return fmt.Errorf("read macOS bundle identifier: %w", err)
770 }
771 if got := strings.TrimSpace(string(out)); got != macBundleID {
772 return fmt.Errorf("update: bundle identifier %q does not match %q", got, macBundleID)
773 }
774 if err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", appPath).Run(); err != nil {
775 return fmt.Errorf("verify macOS code signature: %w", err)
776 }
777 if err := exec.Command("/usr/sbin/spctl", "--assess", "--type", "execute", appPath).Run(); err != nil {
778 return fmt.Errorf("assess macOS notarization: %w", err)
779 }
780 return nil
781 }
782
782 lines GO