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