local-operator-ui 0.29.9 → 0.29.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/out/main/index.js +938 -55
  2. package/out/preload/index.js +28 -0
  3. package/out/renderer/assets/{_basePickBy-C6Zpso0Q.js → _basePickBy-Mh4LvZtr.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-C1DyLv6t.js → _baseUniq-Ddqf2qus.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-B5SKXAv5.js → agent-details-page-_Jd0nrDd.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-D64F7S88.js → agent-hub-page-BgcFYK_I.js} +1 -1
  7. package/out/renderer/assets/{agents-page-BJK0Pq9n.js → agents-page-Bzt02gvZ.js} +2 -2
  8. package/out/renderer/assets/{arc-C5QSgRE-.js → arc-Wq5zilfw.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-_1BluMs5.js → architectureDiagram-IEHRJDOE-BgGUPdtW.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CGBO2CqU.js → blockDiagram-JOT3LUYC-zuVqLazV.js} +1 -1
  11. package/out/renderer/assets/{browser-page-BwHs3-XU.js → browser-page-pAWzQxJc.js} +1 -1
  12. package/out/renderer/assets/{browser-webauthn-prompt-Bku2Q0DD.js → browser-webauthn-prompt-WWfeO5BO.js} +1 -1
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CTzOtZaJ.js → c4Diagram-VJAJSXHY-BZlnspux.js} +1 -1
  14. package/out/renderer/assets/channel-DIDtkANS.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-D4V7GOyK.js → chunk-4BMEZGHF-Z25s8XqA.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-C2JjWyq4.js → chunk-A2AXSNBT-D1HDCpVe.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-i5O0M59b.js → chunk-AEK57VVT-CWLoU0Yy.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-CQ-FSZuM.js → chunk-D6G4REZN-fLJWNE0d.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-BwQQ0vAi.js → chunk-RZ5BOZE2-zFisZ3Bn.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-DhDwyjwr.js → chunk-XZIHB7SX-C652H4at.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-BRX16AlT.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BRX16AlT.js +1 -0
  23. package/out/renderer/assets/clone-C-0WYjyY.js +1 -0
  24. package/out/renderer/assets/{compact-pagination-CkriC8GH.js → compact-pagination-BsbXn023.js} +1 -1
  25. package/out/renderer/assets/{dagre-OKDRZEBW-DdcDClZN.js → dagre-OKDRZEBW-DYZlyP5d.js} +1 -1
  26. package/out/renderer/assets/{diagram-SSKATNLV-Bg_QnBVO.js → diagram-SSKATNLV-B0OKNpJo.js} +1 -1
  27. package/out/renderer/assets/{diagram-VNBRO52H-CPziysfK.js → diagram-VNBRO52H-CJLMAhLj.js} +1 -1
  28. package/out/renderer/assets/{erDiagram-Q7BY3M3F-_ol5WsYH.js → erDiagram-Q7BY3M3F-CKAYEoTF.js} +1 -1
  29. package/out/renderer/assets/{flowDiagram-4HSFHLVR-Bmu20sRQ.js → flowDiagram-4HSFHLVR-ezQ5Jkqg.js} +1 -1
  30. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DzIMMuhC.js → ganttDiagram-APWFNJXF-BIxwrZ2J.js} +1 -1
  31. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-B-OgztQz.js → gitGraphDiagram-7IBYFJ6S-CqjZG5Gb.js} +1 -1
  32. package/out/renderer/assets/{graph-B_JuzgF_.js → graph-DM0nDAL-.js} +1 -1
  33. package/out/renderer/assets/{index-BWYtIPrm.js → index-B5sZWXq9.js} +205 -205
  34. package/out/renderer/assets/{index-BukjYK9S.js → index-Cd5-6u8z.js} +1 -1
  35. package/out/renderer/assets/{index-BPazDpDx.js → index-u6D1aQ89.js} +1 -1
  36. package/out/renderer/assets/{infoDiagram-PH2N3AL5-Bi062Ijx.js → infoDiagram-PH2N3AL5-CDQ4VfWc.js} +1 -1
  37. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BDtZIJa2.js → journeyDiagram-U35MCT3I-bFAnVzya.js} +1 -1
  38. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DnZ6Y5H5.js → kanban-definition-NDS4AKOZ-ChHUyxAY.js} +1 -1
  39. package/out/renderer/assets/{layout-Ca77fqmE.js → layout-BQMS78tr.js} +1 -1
  40. package/out/renderer/assets/{legacy-agents-page-Chbmzrq4.js → legacy-agents-page-Ck3aczor.js} +1 -1
  41. package/out/renderer/assets/{mermaid.core-BkcuejWg.js → mermaid.core-BT59k05n.js} +5 -5
  42. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BSewSz0i.js → mindmap-definition-ALO5MXBD-CCkq6xC8.js} +1 -1
  43. package/out/renderer/assets/{parseISO-DtZurrLZ.js → parseISO-BVEZWva6.js} +1 -1
  44. package/out/renderer/assets/{pieDiagram-IB7DONF6-BkAZXmpD.js → pieDiagram-IB7DONF6-BPocaRpd.js} +1 -1
  45. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DbuM9Vo7.js → quadrantDiagram-7GDLP6J5-Dt1zoh-O.js} +1 -1
  46. package/out/renderer/assets/{radar-MK3ICKWK-CHIyxMEL.js → radar-MK3ICKWK-F4siVqiq.js} +1 -1
  47. package/out/renderer/assets/{radient-auth-buttons-DAhpbmde.js → radient-auth-buttons-rGBUZhsE.js} +1 -1
  48. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D3Uob2JK.js → requirementDiagram-KVF5MWMF-IOAHTqS0.js} +1 -1
  49. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CJCHGLBa.js → sankeyDiagram-QLVOVGJD-7GPo6sx9.js} +1 -1
  50. package/out/renderer/assets/{schedules-page-BrJT9xxG.js → schedules-page-eiOj0-XU.js} +1 -1
  51. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-Dez-Y-IR.js → sequenceDiagram-X6HHIX6F-CP23YiqZ.js} +1 -1
  52. package/out/renderer/assets/{settings-page-DLhpz6kA.js → settings-page-Hl26O6tY.js} +1 -1
  53. package/out/renderer/assets/{stateDiagram-DGXRK772-CZ4fm__h.js → stateDiagram-DGXRK772-Bkv97oQP.js} +1 -1
  54. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-D9DsTx2Q.js +1 -0
  55. package/out/renderer/assets/{timeline-definition-BDJGKUSR-DERFLdV1.js → timeline-definition-BDJGKUSR-DajX_kZf.js} +1 -1
  56. package/out/renderer/assets/{use-agent-like-mutation-DUtwAxLu.js → use-agent-like-mutation-BJ_2nNzY.js} +1 -1
  57. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BKGu1d8V.js → xychartDiagram-VJFVF3MP-IHMYiHU7.js} +1 -1
  58. package/out/renderer/index.html +1 -1
  59. package/package.json +2 -2
  60. package/out/renderer/assets/channel-Df29J43d.js +0 -1
  61. package/out/renderer/assets/classDiagram-GIVACNV2-svv09F8A.js +0 -1
  62. package/out/renderer/assets/classDiagram-v2-COTLJTTW-svv09F8A.js +0 -1
  63. package/out/renderer/assets/clone-B2YTR_FA.js +0 -1
  64. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DQcOVGs7.js +0 -1
package/out/main/index.js CHANGED
@@ -2450,6 +2450,141 @@ const MACOS_ENTITLEMENT_POLICY = {
2450
2450
  unrestrictedEntitlementKeys,
2451
2451
  unrestrictedEntitlementPrefixes
2452
2452
  };
2453
+ const DITTO_PATH = "/usr/bin/ditto";
2454
+ const SHIPIT_RELATIVE_PATH = path$1.join(
2455
+ "Contents",
2456
+ "Frameworks",
2457
+ "Squirrel.framework",
2458
+ "Versions",
2459
+ "Current",
2460
+ "Resources",
2461
+ "ShipIt"
2462
+ );
2463
+ const INSTALLER_TOKEN = "local-operator-update-installer";
2464
+ const UPDATE_STAGING_DIR = "update-staging";
2465
+ const INSTALLER_POLL_SECONDS = "0.2";
2466
+ const EXTRACTION_TIMEOUT_MS = 10 * 6e4;
2467
+ function shipItPathInBundle(bundlePath) {
2468
+ if (!bundlePath) return null;
2469
+ return path$1.join(bundlePath, SHIPIT_RELATIVE_PATH);
2470
+ }
2471
+ function updateStagingRoot(userDataDir) {
2472
+ return path$1.join(userDataDir, UPDATE_STAGING_DIR);
2473
+ }
2474
+ function updateStagingDir(input) {
2475
+ return path$1.join(
2476
+ updateStagingRoot(input.userDataDir),
2477
+ `${input.version}-${input.nonce}`
2478
+ );
2479
+ }
2480
+ function stagingStatePath(stagingRoot) {
2481
+ return path$1.join(stagingRoot, "state.plist");
2482
+ }
2483
+ function installerLogPath(shipItCacheDir2) {
2484
+ return path$1.join(shipItCacheDir2, "ShipIt_stderr.log");
2485
+ }
2486
+ function extractionArguments(zipPath, stagingRoot) {
2487
+ return ["-x", "-k", zipPath, stagingRoot];
2488
+ }
2489
+ function evaluateStagedBundle(input) {
2490
+ const { reading } = input;
2491
+ if (!reading.exists) {
2492
+ return {
2493
+ ok: false,
2494
+ detail: `the extraction produced no bundle at ${reading.appPath}`
2495
+ };
2496
+ }
2497
+ if (reading.bundleIdentifier !== input.expectedBundleIdentifier) {
2498
+ return {
2499
+ ok: false,
2500
+ detail: `the staged bundle is ${reading.bundleIdentifier ?? "unsigned for an identifier"}, not ${input.expectedBundleIdentifier}`
2501
+ };
2502
+ }
2503
+ if (reading.version !== input.expectedVersion) {
2504
+ return {
2505
+ ok: false,
2506
+ detail: `the staged bundle reports version ${reading.version ?? "unknown"}, not ${input.expectedVersion}`
2507
+ };
2508
+ }
2509
+ if (!reading.executablePresent) {
2510
+ return {
2511
+ ok: false,
2512
+ detail: `the staged bundle at ${reading.appPath} has no executable of its own`
2513
+ };
2514
+ }
2515
+ if (reading.architectures != null && reading.architectures.length > 0 && !reading.architectures.includes(input.runningArchitecture)) {
2516
+ return {
2517
+ ok: false,
2518
+ detail: `the staged bundle is built for ${reading.architectures.join(
2519
+ ", "
2520
+ )} and this app is running as ${input.runningArchitecture}`
2521
+ };
2522
+ }
2523
+ return {
2524
+ ok: true,
2525
+ detail: `${input.expectedBundleIdentifier} ${input.expectedVersion}${reading.architectures ? ` (${reading.architectures.join(", ")})` : ""}`
2526
+ };
2527
+ }
2528
+ function sameVolume(stagingDevice, targetDevice) {
2529
+ return stagingDevice != null && stagingDevice === targetDevice;
2530
+ }
2531
+ function fileUrlForDirectory(path2) {
2532
+ const withSlash = path2.endsWith("/") ? path2 : `${path2}/`;
2533
+ return `file://${encodeURI(withSlash)}`;
2534
+ }
2535
+ function shipItStatePlist(input) {
2536
+ return {
2537
+ bundleIdentifier: input.bundleIdentifier,
2538
+ targetBundleURL: fileUrlForDirectory(input.targetBundlePath),
2539
+ updateBundleURL: fileUrlForDirectory(input.updateBundlePath),
2540
+ launchAfterInstallation: true,
2541
+ useUpdateBundleName: true
2542
+ };
2543
+ }
2544
+ function buildInstallerSpawn(input) {
2545
+ const script = `#!/bin/sh
2546
+ # ${INSTALLER_TOKEN}
2547
+ #
2548
+ # Why this exists: the app is about to quit so an install can replace it, and the
2549
+ # installer must not be running its own check for a running instance of the app
2550
+ # while one is still there - ShipIt asks that question once, as its last check
2551
+ # before the swap, and an instance of the target app answers it with "abort"
2552
+ # (SQRLInstallerErrorDomain Code=-9, the 2026-09-18 09:37 install). So this waits
2553
+ # for the app's pid to go, and then replaces itself with the installer: one
2554
+ # process, one pid for the app to record, and no shell left in the way.
2555
+ while kill -0 "$1" 2>/dev/null; do
2556
+ sleep ${INSTALLER_POLL_SECONDS}
2557
+ done
2558
+ exec "$LO_UPDATE_INSTALLER_SHIPIT" "$LO_UPDATE_INSTALLER_LABEL" "$LO_UPDATE_INSTALLER_STATE" >>"$LO_UPDATE_INSTALLER_LOG" 2>&1
2559
+ `;
2560
+ return {
2561
+ script,
2562
+ env: {
2563
+ LO_UPDATE_INSTALLER_SHIPIT: input.shipItPath,
2564
+ LO_UPDATE_INSTALLER_LABEL: input.label,
2565
+ LO_UPDATE_INSTALLER_STATE: input.statePath,
2566
+ LO_UPDATE_INSTALLER_LOG: input.logPath
2567
+ },
2568
+ // `sh -c <script> <name> <pid>`: the name is $0 and the pid is $1, so the
2569
+ // script text carries no path and no number that a process listing of it
2570
+ // would not otherwise show.
2571
+ args: ["-c", script, "lo-update-installer", String(input.appPid)]
2572
+ };
2573
+ }
2574
+ function installerIsAlive(input) {
2575
+ if (input.pid == null || input.commandLine == null) return false;
2576
+ const line = input.commandLine;
2577
+ if (line.includes(INSTALLER_TOKEN)) return true;
2578
+ return input.shipItPath != null && input.stagingRoot != null && line.includes(input.shipItPath) && line.includes(input.stagingRoot);
2579
+ }
2580
+ function installerElsewhere(input) {
2581
+ if (!input.processList || !input.shipItPath || !input.stagingRoot) {
2582
+ return false;
2583
+ }
2584
+ return input.processList.split("\n").some(
2585
+ (line) => line.includes(input.shipItPath) && line.includes(input.stagingRoot)
2586
+ );
2587
+ }
2453
2588
  const PENDING_INSTALL_MARKER_FILE = "pending-update-install.json";
2454
2589
  const INSTALL_DISK_SLACK_BYTES = 256 * 1024 * 1024;
2455
2590
  const DOWNLOAD_PAGE_URL = "https://local-operator.com/download";
@@ -2639,7 +2774,7 @@ function matchArtifactMetadata(filePath, files, updatePath) {
2639
2774
  return null;
2640
2775
  }
2641
2776
  function requiredDiskBytes(input) {
2642
- return input.artifactSize + input.installedBundleSize * 2 + INSTALL_DISK_SLACK_BYTES;
2777
+ return input.artifactSize + input.installedBundleSize * 3 + INSTALL_DISK_SLACK_BYTES;
2643
2778
  }
2644
2779
  function formatGiB(bytes) {
2645
2780
  return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
@@ -2720,7 +2855,7 @@ function verifyStagedArtifact(input) {
2720
2855
  const installedBundleSize = input.installedBundleSize ?? 0;
2721
2856
  const needed = requiredDiskBytes({ artifactSize, installedBundleSize });
2722
2857
  if (input.freeBytes < needed) {
2723
- const footprint = installedBundleSize ? `${formatGiB(artifactSize)} artifact + two ${formatGiB(
2858
+ const footprint = installedBundleSize ? `${formatGiB(artifactSize)} artifact + three ${formatGiB(
2724
2859
  installedBundleSize
2725
2860
  )} app copies + ${formatGiB(INSTALL_DISK_SLACK_BYTES)} slack` : `${formatGiB(artifactSize)} artifact + ${formatGiB(INSTALL_DISK_SLACK_BYTES)} slack (the installed app could not be measured)`;
2726
2861
  return {
@@ -2837,7 +2972,10 @@ function parsePendingInstallMarker(raw) {
2837
2972
  targetVersion: parsed.targetVersion,
2838
2973
  artifactPath: typeof parsed.artifactPath === "string" ? parsed.artifactPath : "",
2839
2974
  startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : "",
2840
- watchdogPid: typeof parsed.watchdogPid === "number" ? parsed.watchdogPid : null
2975
+ watchdogPid: typeof parsed.watchdogPid === "number" ? parsed.watchdogPid : null,
2976
+ // Absent on a marker written before this field existed, and absent is the
2977
+ // same answer as the fallback's `null`: ask the install's own launchd job.
2978
+ installerPid: typeof parsed.installerPid === "number" ? parsed.installerPid : null
2841
2979
  };
2842
2980
  } catch {
2843
2981
  return null;
@@ -3039,9 +3177,48 @@ function isInstallInFlight(input) {
3039
3177
  const age = pendingInstallAgeSeconds(input.marker, input.now);
3040
3178
  if (age === null) return false;
3041
3179
  if (age < 0 || age > PENDING_INSTALL_RECENCY_SECONDS) return false;
3180
+ if (input.marker.installerPid != null) return input.installerRunning;
3042
3181
  if (input.jobState === "running") return true;
3043
3182
  return input.jobState === "registered" && age <= PENDING_INSTALL_HANDOFF_SECONDS;
3044
3183
  }
3184
+ function installLivenessText(liveness, installerPid) {
3185
+ if (liveness.decidedBy !== "installer-pid") {
3186
+ return `install job ${liveness.jobState}`;
3187
+ }
3188
+ const named = installerPid ?? "unknown";
3189
+ if (liveness.installerSignal === "elsewhere") {
3190
+ return `the install this app started is still running (pid ${named} has gone; its ShipIt is still at work)`;
3191
+ }
3192
+ if (liveness.installerSignal === "pid") {
3193
+ return `the installer this app started (pid ${named}) is still running`;
3194
+ }
3195
+ return `the installer this app started (pid ${named}) has gone`;
3196
+ }
3197
+ function installLivenessNow(input) {
3198
+ const pid = input.marker?.installerPid ?? null;
3199
+ if (pid == null) {
3200
+ return {
3201
+ installerRunning: false,
3202
+ jobState: input.jobState(),
3203
+ decidedBy: "install-job",
3204
+ // No pid was asked about, so no pid-shaped fact decided.
3205
+ installerSignal: null
3206
+ };
3207
+ }
3208
+ const pidIsLive = installerIsAlive({
3209
+ pid,
3210
+ commandLine: input.installerCommandLine(),
3211
+ shipItPath: input.shipItPath,
3212
+ stagingRoot: input.stagingRoot
3213
+ });
3214
+ const elsewhere = pidIsLive ? false : input.installerElsewhere();
3215
+ return {
3216
+ installerRunning: pidIsLive || elsewhere,
3217
+ jobState: "unread",
3218
+ decidedBy: "installer-pid",
3219
+ installerSignal: pidIsLive ? "pid" : elsewhere ? "elsewhere" : null
3220
+ };
3221
+ }
3045
3222
  function evaluateLaunchDuringInstall(input) {
3046
3223
  const { marker } = input;
3047
3224
  if (!marker) return { kind: "open" };
@@ -3050,7 +3227,8 @@ function evaluateLaunchDuringInstall(input) {
3050
3227
  runningVersion: input.runningVersion,
3051
3228
  installInFlight: isInstallInFlight({
3052
3229
  marker,
3053
- jobState: input.jobState,
3230
+ jobState: input.liveness.jobState,
3231
+ installerRunning: input.liveness.installerRunning,
3054
3232
  now: input.now
3055
3233
  })
3056
3234
  });
@@ -3125,7 +3303,7 @@ function installLaunchHoldNotice(marker, options = {}) {
3125
3303
  const relaunch = options.relaunchEnsured === false ? "No relaunch could be arranged for this install, so start Local Operator by hand when it finishes." : "Local Operator will open by itself when the install finishes.";
3126
3304
  return {
3127
3305
  title: "Local Operator is still updating",
3128
- body: `The update to version ${marker.targetVersion} is still installing — this can take a few minutes, and opening the app now would cancel it. ${relaunch} Clicking this notice will not open it any sooner.`
3306
+ body: `The update to version ${marker.targetVersion} is still installing — opening the app now would cancel it. ${relaunch} Clicking this notice will not open it any sooner.`
3129
3307
  };
3130
3308
  }
3131
3309
  function shipItJobLabel(bundleId) {
@@ -3209,15 +3387,36 @@ function buildWatchdogPlan(input) {
3209
3387
  # osascript blocked, no notifier at all), so no notification failure may change
3210
3388
  # what this script decides or what it exits with.
3211
3389
  #
3212
- # With no job label to ask about, the job cannot be consulted at all, so the swap
3213
- # state is the whole signal: the script waits for (b) rather than spending an
3214
- # appear window on a job it cannot see and then relaunching on no evidence.
3390
+ # With no liveness signal to ask about, the install cannot be consulted at all,
3391
+ # so the swap state is the whole signal: the script waits for (b) rather than
3392
+ # spending an appear window on an install it cannot see and then relaunching on no
3393
+ # evidence. That covers both "no job label" and "no probe", and it is why an
3394
+ # install the app started itself - which has no job by construction - is asked
3395
+ # about by the installer's pid instead (install_live).
3396
+ #
3397
+ # "The install is over" is therefore install_live: the installer's own pid when
3398
+ # the app spawned it, and ShipIt's launchd job otherwise. The job is Squirrel's own
3399
+ # signal and is not deleted - a marker or a watchdog planned by the older version
3400
+ # still has only that one - but it is demoted, for a reason measured on this
3401
+ # machine: the job stays REGISTERED long after a successful install, so asking
3402
+ # launchd first would hold a finished install open for the whole bound.
3215
3403
  set -u
3216
3404
  APP_PID="\${LO_UPDATE_WATCHDOG_APP_PID:-}"
3217
3405
  BUNDLE="\${LO_UPDATE_WATCHDOG_APP_BUNDLE:-}"
3218
3406
  NAME="\${LO_UPDATE_WATCHDOG_APP_NAME:-}"
3219
3407
  SHIPIT_JOB="\${LO_UPDATE_WATCHDOG_SHIPIT_JOB:-}"
3408
+ # The installer's own pid, when the app started the installer itself. An install
3409
+ # started that way has no launchd job to ask about, so this is the whole liveness
3410
+ # signal and the job label above is not consulted at all - see install_live.
3411
+ INSTALLER_PID="\${LO_UPDATE_WATCHDOG_INSTALLER_PID:-}"
3220
3412
  TARGET_VERSION="\${LO_UPDATE_WATCHDOG_TARGET_VERSION:-}"
3413
+ # The two facts that identify this install's installer when the recorded pid is no
3414
+ # longer it: this app's own ShipIt, and this app's own staging root. Empty (an
3415
+ # older plan, or a platform the app could not resolve them on) means the second
3416
+ # half of installer_running declines rather than guessing - the pid alone decides,
3417
+ # which is exactly what this script did before review R2-1.
3418
+ SHIPIT_PATH="\${LO_UPDATE_WATCHDOG_SHIPIT_PATH:-}"
3419
+ STAGING_ROOT="\${LO_UPDATE_WATCHDOG_STAGING_ROOT:-}"
3221
3420
  # The two probes, from the platform the plan was built for. Empty means the
3222
3421
  # platform has no such tool and the signal is unavailable - not "the job is not
3223
3422
  # loaded" and not "the swap has not landed". shipit_loaded, swap_landed and
@@ -3230,6 +3429,57 @@ if [ -z "$APP_PID" ] || [ -z "$BUNDLE" ]; then
3230
3429
  fi
3231
3430
  now() { date +%s; }
3232
3431
  app_running() { kill -0 "$APP_PID" 2>/dev/null; }
3432
+ # Whether ANY process is this install's ShipIt, for the pid that stopped being it.
3433
+ #
3434
+ # The same two facts installerElsewhere requires of a line in its listing: this
3435
+ # app's OWN ShipIt path AND a state plist under this app's OWN staging root. Both,
3436
+ # never either - this machine runs several other applications' ShipIt processes,
3437
+ # and a live foreign one must not be read as this install's.
3438
+ #
3439
+ # One listing per call and only reached when the recorded pid has stopped
3440
+ # answering, which is the same economy the app's own reader keeps: a live pid is
3441
+ # the whole answer (installer_running below).
3442
+ shipit_elsewhere() {
3443
+ [ -n "$SHIPIT_PATH" ] && [ -n "$STAGING_ROOT" ] || return 1
3444
+ ps -Ao command= -ww 2>/dev/null | grep -F "$SHIPIT_PATH" | grep -F "$STAGING_ROOT" >/dev/null 2>&1
3445
+ }
3446
+ # The installer the app spawned, alive or gone. A pid with nothing under it is
3447
+ # gone, which is the same question app_running asks of the app and the same one
3448
+ # the app's own reap asks of a watchdog.
3449
+ #
3450
+ # TWO STEPS, because the app's own reader has two (review R2-1: installLivenessNow
3451
+ # asks installerIsAlive, and installerElsewhere before it declares the installer
3452
+ # dead). One pid is a snapshot: the spawner execs ShipIt so the common case keeps
3453
+ # the pid, but a ShipIt that re-execs or forks internally would leave the recorded
3454
+ # pid dead while an install it is in the middle of continues - and a script that
3455
+ # read that as "the install is over" would relaunch the app into it, which is the
3456
+ # Code=-9 abort this whole change exists to remove.
3457
+ #
3458
+ # The one asymmetry is deliberate and it is in the FIRST step only: the app also
3459
+ # reads the pid's command line (installerIsAlive), because a false "alive" there
3460
+ # would make recovery refuse forever. Here the only cost of a stale pid being
3461
+ # reused is waiting out the bound, and the bound is what ends it - so this asks
3462
+ # kill -0 and spends no listing on a pid that answers. The second step is not
3463
+ # optional in the same way: its false negative is the direction that starts the
3464
+ # app into a live install.
3465
+ installer_running() {
3466
+ [ -n "$INSTALLER_PID" ] || return 1
3467
+ if kill -0 "$INSTALLER_PID" 2>/dev/null; then return 0; fi
3468
+ shipit_elsewhere
3469
+ }
3470
+ # "The install is still running", from whichever signal this install has: the pid
3471
+ # the app spawned, or - for an install Squirrel submitted - the job. There is
3472
+ # never both: an install the app started itself has no job, and the fallback
3473
+ # install has no pid. Asking launchd first would be wrong rather than merely
3474
+ # redundant, because this machine keeps the job REGISTERED long after a
3475
+ # successful install and a registered job is not a running one.
3476
+ install_live() {
3477
+ if [ -n "$INSTALLER_PID" ]; then
3478
+ installer_running
3479
+ return $?
3480
+ fi
3481
+ shipit_loaded
3482
+ }
3233
3483
  # Tell the user what is going on, in the only way available: the app is dead
3234
3484
  # while this runs, so this is osascript and nothing else - no app process, no
3235
3485
  # updater. Backgrounded and status-dropped, because a notification is best-effort
@@ -3378,8 +3628,14 @@ swap_landed() {
3378
3628
  }
3379
3629
  job_known=0
3380
3630
  [ -n "$SHIPIT_PROBE" ] && [ -n "$SHIPIT_JOB" ] && job_known=1
3631
+ # Whether there is any liveness signal to ask at all. An absent signal is not "the
3632
+ # install is over": with neither one the script has the swap state and the bound,
3633
+ # exactly as it does on a platform with no probes.
3634
+ signal_known=0
3635
+ [ -n "$INSTALLER_PID" ] && signal_known=1
3636
+ [ "$job_known" -eq 1 ] && signal_known=1
3381
3637
  decided() {
3382
- if [ "$job_known" -eq 1 ] && ! shipit_loaded; then return 0; fi
3638
+ if [ "$signal_known" -eq 1 ] && ! install_live; then return 0; fi
3383
3639
  swap_landed && return 0
3384
3640
  return 1
3385
3641
  }
@@ -3390,28 +3646,69 @@ while app_running; do
3390
3646
  if [ "$(now)" -ge "$deadline" ]; then break; fi
3391
3647
  sleep ${intervalSeconds}
3392
3648
  done
3393
- # 1b. The app is gone, so an install is at work: say so a few seconds in, so the
3394
- # window that just vanished comes with an explanation. This is the moment the
3395
- # operator of 2026-09-13 was left guessing, and reopening the app four
3396
- # minutes later is what aborted their install. Skipped when the app is still
3397
- # running - a quit the user cancelled, where nothing is being installed and
3398
- # nothing should be claimed.
3649
+ # 1b. The app is gone and an install is at work, so the window that just
3650
+ # vanished owes the person an explanation a few seconds in. Only the PAUSE is
3651
+ # here: the notice is raised at 2b below, once the liveness question has an
3652
+ # answer (QA round 2, Q1).
3653
+ #
3654
+ # It used to be raised from here, and that is the one thing this step could
3655
+ # not do on Squirrel's path: the job is submitted as part of the quit, so
3656
+ # launchd can answer 113 for the first seconds of the appear window below -
3657
+ # which is exactly why that window exists - and a gate evaluated once, before
3658
+ # the thing it gates on has appeared, suppressed the only notice the user was
3659
+ # going to get in the moment they most needed it.
3399
3660
  if ! app_running; then
3400
3661
  sleep ${announceSeconds}
3401
- notify "Installing the update. Keep Local Operator closed until it opens again by itself — this can take a few minutes."
3402
3662
  fi
3403
- # 2. ShipIt's job is submitted as part of the quit, so it may not be loaded the
3404
- # instant the app is gone: give it a bounded window to appear before treating
3405
- # "no job" as "the install is over". Skipped when there is no label to ask
3406
- # about: an appear window for an unaskable job is 30s of pretending, and it
3407
- # used to end by relaunching with no evidence about the install at all.
3408
- if [ "$job_known" -eq 1 ]; then
3663
+ # 2. An install Squirrel submitted starts as part of the quit, so it may not be
3664
+ # visible the instant the app is gone: give it a bounded window to appear
3665
+ # before treating "nothing there" as "the install is over". Two cases skip it,
3666
+ # and the second is UX U6:
3667
+ #
3668
+ # - no signal to ask about: an appear window for an unaskable install is
3669
+ # ${appearSeconds}s of pretending, and it used to end by relaunching with
3670
+ # no evidence at all;
3671
+ # - an installer THIS APP spawned itself. That pid exists before the quit, so
3672
+ # the window cannot tell a late start from no start at all: if it is gone at
3673
+ # the first poll the install is over (or never began), and waiting out the
3674
+ # window only pushes the failure report and the app's return half a minute
3675
+ # further away.
3676
+ if [ "$signal_known" -eq 1 ] && [ -z "$INSTALLER_PID" ]; then
3409
3677
  appear_deadline=$(( $(now) + ${appearSeconds} ))
3410
- while ! shipit_loaded; do
3678
+ while ! install_live; do
3411
3679
  if [ "$(now)" -ge "$appear_deadline" ] || [ "$(now)" -ge "$deadline" ]; then break; fi
3412
3680
  sleep ${intervalSeconds}
3413
3681
  done
3414
3682
  fi
3683
+ # 2b. The window that vanished, explained - and it is raised HERE rather than at
3684
+ # 1b so that on Squirrel's path it is asked once the job it asks about has
3685
+ # been submitted (QA round 2, Q1). The two conditions are the ones the notice
3686
+ # has to meet, each of them a defect it once had (UX U1):
3687
+ #
3688
+ # - the app is still gone: a quit the user cancelled installs nothing, and
3689
+ # nothing should be claimed;
3690
+ # - the install is STILL RUNNING at the moment the notice is raised. Naming
3691
+ # a duration was the other half of that defect: it promised minutes, and
3692
+ # on the direct path the whole window is 2-5 s - so the notice arrived
3693
+ # telling a person to keep closed an app ShipIt had already reopened. The
3694
+ # instruction that matters, "don't reopen it", is the same on both paths
3695
+ # and is all this says now.
3696
+ #
3697
+ # What moving it costs: on Squirrel's path the notice now waits for the job to
3698
+ # appear (the loop above breaks the moment it does, so that is a poll
3699
+ # interval, not the whole window), and a job that never appears raises no
3700
+ # notice at all - correct, because nothing is installing and the app is about
3701
+ # to come back with its own failure panel. On the direct path the loop is
3702
+ # skipped and this is the same instant the old code used.
3703
+ #
3704
+ # With no signal to ask about, the notice is unconditional: an install that
3705
+ # cannot be asked about cannot be shown to be over either, and staying silent
3706
+ # there would be the original defect back again.
3707
+ if ! app_running; then
3708
+ if [ "$signal_known" -eq 0 ] || install_live; then
3709
+ notify "Installing the update. Keep Local Operator closed until it opens again by itself."
3710
+ fi
3711
+ fi
3415
3712
  # 3. Wait for the install to be decided - by the job going, or by the swap
3416
3713
  # landing. The soft bound ends this wait only when there is no loaded job
3417
3714
  # answering for the install: see the hold below.
@@ -3419,7 +3716,7 @@ holding=0
3419
3716
  while :; do
3420
3717
  if decided; then break; fi
3421
3718
  if [ "$(now)" -ge "$deadline" ]; then
3422
- if [ "$job_known" -eq 1 ] && shipit_loaded; then
3719
+ if [ "$signal_known" -eq 1 ] && install_live; then
3423
3720
  holding=1
3424
3721
  notify "The update is still installing. Keep Local Operator closed; it will open again when the install finishes."
3425
3722
  fi
@@ -3457,9 +3754,23 @@ if [ "$holding" -eq 1 ]; then
3457
3754
  fi
3458
3755
  # Let Squirrel's own relaunch (which happens as the job finishes) land first.
3459
3756
  sleep ${settleSeconds}
3460
- # 4. Nothing to do if Squirrel relaunched the app or the user started it. This is
3461
- # what makes the script a no-op when the install succeeded, by construction
3462
- # rather than by claim.
3757
+ # 4. A landed swap gets a LAUNCH OF ITS OWN, and the only difference is -g, which
3758
+ # is UX U7. ShipIt relaunches the app itself once it has swapped - that
3759
+ # instruction IS the state plist this app writes (launchAfterInstallation) - so
3760
+ # on a successful install the app was already back, under a new pid, when this
3761
+ # ran, and the unflagged open -a below fronted a window the user was working in
3762
+ # ~40 s after an install that takes seconds. The check above cannot see that
3763
+ # instance: it holds the OLD pid, which is dead by then, and a name probe must
3764
+ # not be used here (the script's own text is in the watchdog's command line, so
3765
+ # "is anything named like the app running" answers yes about the watchdog). So
3766
+ # the launch still happens - the app has to be running whether or not ShipIt's
3767
+ # own relaunch worked - and -g is what keeps it from taking focus.
3768
+ if swap_landed; then
3769
+ if [ -n "$NAME" ] && [ -x "$BUNDLE/Contents/MacOS/$NAME" ]; then
3770
+ open -g -a "$BUNDLE" >/dev/null 2>&1 || "$BUNDLE/Contents/MacOS/$NAME" >/dev/null 2>&1 &
3771
+ fi
3772
+ exit 0
3773
+ fi
3463
3774
  if app_running; then exit 0; fi
3464
3775
  if [ -n "$NAME" ] && [ -x "$BUNDLE/Contents/MacOS/$NAME" ]; then
3465
3776
  open -a "$BUNDLE" >/dev/null 2>&1 || "$BUNDLE/Contents/MacOS/$NAME" >/dev/null 2>&1 &
@@ -3473,6 +3784,15 @@ exit 0
3473
3784
  LO_UPDATE_WATCHDOG_APP_BUNDLE: input.appBundlePath,
3474
3785
  LO_UPDATE_WATCHDOG_APP_NAME: input.executableName,
3475
3786
  LO_UPDATE_WATCHDOG_SHIPIT_JOB: input.shipItJob ?? "",
3787
+ // Empty rather than absent when the app spawned no installer: the script
3788
+ // reads this with a default (`:-`) and its emptiness is the whole answer to
3789
+ // "which liveness signal is this install's" (see `install_live`).
3790
+ LO_UPDATE_WATCHDOG_INSTALLER_PID: input.installerPid != null ? String(input.installerPid) : "",
3791
+ // The identity pair, empty rather than absent for the same reason as the pid:
3792
+ // the script reads them with `:-` defaults and an empty value is the whole
3793
+ // answer to "can this install's installer be recognised under another pid".
3794
+ LO_UPDATE_WATCHDOG_SHIPIT_PATH: input.shipItPath ?? "",
3795
+ LO_UPDATE_WATCHDOG_STAGING_ROOT: input.stagingRoot ?? "",
3476
3796
  LO_UPDATE_WATCHDOG_TARGET_VERSION: input.targetVersion ?? "",
3477
3797
  // Empty off macOS, which the script reads as "this signal is not available
3478
3798
  // here" rather than as a negative answer (see `watchdogSignals`).
@@ -17875,6 +18195,7 @@ function serverUpdateFailureSentence(input) {
17875
18195
  const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
17876
18196
  const VERSION_CLEAN_REGEX = /^v/i;
17877
18197
  const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
18198
+ const ARCHITECTURE_SEPARATOR_REGEX = /\s+/;
17878
18199
  const GLOBAL_UPDATE_TIMEOUT_MS = 15 * 60 * 1e3;
17879
18200
  const SOURCE_REBUILD_TIMEOUT_MS = 30 * 60 * 1e3;
17880
18201
  const BUNDLE_ID_REGEX = /^[\w.-]+$/;
@@ -17938,6 +18259,112 @@ function readCommandOutput(command, args) {
17938
18259
  return null;
17939
18260
  }
17940
18261
  }
18262
+ function runExtraction(zipPath, stagingRoot, options = {}) {
18263
+ const command = options.command ?? DITTO_PATH;
18264
+ const timeoutMs = options.timeoutMs ?? EXTRACTION_TIMEOUT_MS;
18265
+ return new Promise((resolve) => {
18266
+ let settled = false;
18267
+ const finish = (result) => {
18268
+ if (settled) return;
18269
+ settled = true;
18270
+ clearTimeout(timer);
18271
+ resolve(result);
18272
+ };
18273
+ const child = node_child_process.spawn(command, extractionArguments(zipPath, stagingRoot), {
18274
+ /*
18275
+ * Its own process group, so the bound below can take down the whole tree.
18276
+ * A kill by pid alone leaves anything the extractor started running, and
18277
+ * this is the one process in the change that is killed rather than waited
18278
+ * for: the same teardown discipline the app applies to its own children,
18279
+ * for the same reason - a half-written staging tree with a live process
18280
+ * inside it is worse than no staging tree.
18281
+ */
18282
+ detached: true,
18283
+ stdio: ["ignore", "ignore", "pipe"]
18284
+ });
18285
+ let stderr = "";
18286
+ child.stderr?.on("data", (chunk) => {
18287
+ stderr += String(chunk);
18288
+ });
18289
+ const timer = setTimeout(() => {
18290
+ logger.warn(
18291
+ `The update bundle's extraction did not finish within ${timeoutMs} ms; stopping it.`,
18292
+ LogFileType.UPDATE_SERVICE
18293
+ );
18294
+ killExtractionTree(
18295
+ child.pid,
18296
+ (message) => logger.warn(message, LogFileType.UPDATE_SERVICE)
18297
+ );
18298
+ finish({
18299
+ ok: false,
18300
+ reason: `it was still running after ${timeoutMs} ms`
18301
+ });
18302
+ }, timeoutMs);
18303
+ child.on("error", (error) => finish({ ok: false, reason: String(error) }));
18304
+ child.on(
18305
+ "close",
18306
+ (code) => finish(
18307
+ code === 0 ? { ok: true } : {
18308
+ ok: false,
18309
+ reason: `ditto exited ${code}${stderr.trim() ? `: ${stderr.trim()}` : ""}`
18310
+ }
18311
+ )
18312
+ );
18313
+ });
18314
+ }
18315
+ function killExtractionTree(pid, warn) {
18316
+ if (pid == null) return;
18317
+ try {
18318
+ process.kill(-pid, "SIGKILL");
18319
+ } catch (groupError) {
18320
+ try {
18321
+ process.kill(pid, "SIGKILL");
18322
+ } catch (error) {
18323
+ warn(
18324
+ `Could not stop the wedged extraction (pid ${pid}): ${String(error)}`
18325
+ );
18326
+ }
18327
+ }
18328
+ }
18329
+ function isExecutableFile(path2) {
18330
+ try {
18331
+ fs.accessSync(path2, fs.constants.X_OK);
18332
+ return fs.statSync(path2).isFile();
18333
+ } catch {
18334
+ return false;
18335
+ }
18336
+ }
18337
+ function deviceId(target2) {
18338
+ try {
18339
+ return fs.statSync(target2).dev;
18340
+ } catch {
18341
+ return null;
18342
+ }
18343
+ }
18344
+ function readPlistValue(plistPath, key) {
18345
+ return readCommandOutput("/usr/bin/plutil", [
18346
+ "-extract",
18347
+ key,
18348
+ "raw",
18349
+ "-o",
18350
+ "-",
18351
+ plistPath
18352
+ ]);
18353
+ }
18354
+ function readStagedBundle(appPath) {
18355
+ const plistPath = path$1.join(appPath, "Contents", "Info.plist");
18356
+ const executableName = readPlistValue(plistPath, "CFBundleExecutable");
18357
+ const executablePath = executableName == null ? null : path$1.join(appPath, "Contents", "MacOS", executableName);
18358
+ const architectures2 = executablePath != null && fs.existsSync(executablePath) ? (readCommandOutput("/usr/bin/lipo", ["-archs", executablePath]) ?? "").split(ARCHITECTURE_SEPARATOR_REGEX).filter((entry) => entry.length > 0) : null;
18359
+ return {
18360
+ appPath,
18361
+ exists: fs.existsSync(appPath),
18362
+ bundleIdentifier: readPlistValue(plistPath, "CFBundleIdentifier"),
18363
+ version: readPlistValue(plistPath, "CFBundleShortVersionString"),
18364
+ executablePresent: executablePath != null && fs.existsSync(executablePath),
18365
+ architectures: architectures2 && architectures2.length > 0 ? architectures2 : null
18366
+ };
18367
+ }
17941
18368
  const INSTALLER_PROBE_TIMEOUT_MS = 5e3;
17942
18369
  const DRIFT_HOLD_REPORT = {
17943
18370
  "no-drift": "nothing to do",
@@ -18020,6 +18447,34 @@ function probeShipItInstallJobState() {
18020
18447
  return result.error || result.status == null ? null : { status: result.status, output: result.stdout ?? "" };
18021
18448
  });
18022
18449
  }
18450
+ function installLivenessFor(input) {
18451
+ const commandLine = input.installerCommandLine ?? defaultInstallerCommandLine;
18452
+ const processList = input.processList ?? defaultProcessList;
18453
+ const bundlePath = input.bundlePath ?? (() => appBundleFromExecutable(process.execPath));
18454
+ const shipItPath = shipItPathInBundle(bundlePath());
18455
+ const stagingRoot = updateStagingRoot(electron.app.getPath("userData"));
18456
+ return installLivenessNow({
18457
+ marker: input.marker,
18458
+ installerCommandLine: () => {
18459
+ const pid = input.marker?.installerPid ?? null;
18460
+ return pid == null ? null : commandLine(pid);
18461
+ },
18462
+ installerElsewhere: () => installerElsewhere({
18463
+ processList: processList(),
18464
+ shipItPath,
18465
+ stagingRoot
18466
+ }),
18467
+ shipItPath,
18468
+ stagingRoot,
18469
+ jobState: input.jobState ?? probeShipItInstallJobState
18470
+ });
18471
+ }
18472
+ function defaultInstallerCommandLine(pid) {
18473
+ return readCommandOutput("/bin/ps", ["-o", "command=", "-p", String(pid)]);
18474
+ }
18475
+ function defaultProcessList() {
18476
+ return readCommandOutput("/bin/ps", ["-Ao", "command=", "-ww"]);
18477
+ }
18023
18478
  function startRelaunchWatchdog(input) {
18024
18479
  if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
18025
18480
  const bundlePath = appBundleFromExecutable(process.execPath);
@@ -18031,6 +18486,16 @@ function startRelaunchWatchdog(input) {
18031
18486
  // watchdog cannot ask a name about its own ancestor (review R1).
18032
18487
  appPid: process.pid,
18033
18488
  shipItJob: shipItJobForRunningBundle(),
18489
+ // The installer this app started itself, when there is one: without it the
18490
+ // script would wait on a launchd job that this path never submits, and settle
18491
+ // on the bound instead of on the install (management MAJOR-1).
18492
+ installerPid: input.installerPid ?? null,
18493
+ // The same two facts `installerIsAlive`/`installerElsewhere` use on this side
18494
+ // of the seam, so the script's own "is the installer gone" answer cannot
18495
+ // disagree with the app's (review R2-1): the script asks them of a process
18496
+ // listing before it relaunches the app into an install that is still running.
18497
+ shipItPath: shipItPathInBundle(bundlePath),
18498
+ stagingRoot: updateStagingRoot(electron.app.getPath("userData")),
18034
18499
  // What "the install is over" is measured against on disk (review R11):
18035
18500
  // the updater's advertised version, and nothing that is already in place
18036
18501
  // (review R15).
@@ -18053,7 +18518,7 @@ function startRelaunchWatchdog(input) {
18053
18518
  });
18054
18519
  child.unref();
18055
18520
  input.log(
18056
- `Started the update relaunch watchdog (pid ${child.pid ?? "unknown"}): it starts the app again as soon as the install's job goes or version ${plan.env.LO_UPDATE_WATCHDOG_TARGET_VERSION || "unknown"} is in place, and at the ${plan.timeoutSeconds}s bound if neither happens - or at the ${plan.hardTimeoutSeconds}s hard bound when the install's job is still loaded at the first, which is the case where starting the app would cancel the install.`
18521
+ `Started the update relaunch watchdog (pid ${child.pid ?? "unknown"}): it starts the app again as soon as ${input.installerPid != null ? "the installer this app started is gone" : "the install's job goes"} or version ${plan.env.LO_UPDATE_WATCHDOG_TARGET_VERSION || "unknown"} is in place, and at the ${plan.timeoutSeconds}s bound if neither happens - or at the ${plan.hardTimeoutSeconds}s hard bound when the install is still live at the first, which is the case where starting the app would cancel the install.`
18057
18522
  );
18058
18523
  return child.pid ?? null;
18059
18524
  } catch (error) {
@@ -18074,9 +18539,12 @@ function ensureRelaunchWatchdog(input) {
18074
18539
  );
18075
18540
  return true;
18076
18541
  }
18077
- const launched = input.startWatchdog(input.marker.targetVersion);
18542
+ const launched = input.startWatchdog(
18543
+ input.marker.targetVersion,
18544
+ input.marker.installerPid ?? null
18545
+ );
18078
18546
  input.log(
18079
- launched != null ? `Started relaunch watchdog ${launched} for the install already in flight; it will start the app when the install's job goes.` : "No relaunch watchdog is running for the install in flight and none could be started; the app will need starting by hand after it quits."
18547
+ launched != null ? `Started relaunch watchdog ${launched} for the install already in flight; it will start the app when the install goes.` : "No relaunch watchdog is running for the install in flight and none could be started; the app will need starting by hand after it quits."
18080
18548
  );
18081
18549
  return launched != null;
18082
18550
  }
@@ -18152,16 +18620,20 @@ async function showInstallHoldNotice(notice, bannerDeadlineMs = LAUNCH_HOLD_NOTI
18152
18620
  function holdLaunchForLiveInstall(options) {
18153
18621
  if (!electron.app.isPackaged) return false;
18154
18622
  const marker = readPendingInstallMarker(electron.app.getPath("userData"));
18155
- const jobState = (options.jobState ?? probeShipItInstallJobState)();
18623
+ const liveness = installLivenessFor({
18624
+ marker,
18625
+ jobState: options.jobState,
18626
+ installerCommandLine: options.installerCommandLine
18627
+ });
18156
18628
  const reading = evaluateLaunchDuringInstall({
18157
18629
  marker,
18158
- jobState,
18630
+ liveness,
18159
18631
  runningVersion: options.runningVersion ?? electron.app.getVersion(),
18160
18632
  now: options.now
18161
18633
  });
18162
18634
  if (reading.kind === "open") return false;
18163
18635
  options.log(
18164
- `Holding this launch for the install of version ${reading.marker.targetVersion} that is still running (install job ${jobState}): a window here would make this process the running instance Squirrel's final check aborts the install on, so the app tells the user and quits.`
18636
+ `Holding this launch for the install of version ${reading.marker.targetVersion} that is still running (${installLivenessText(liveness, reading.marker.installerPid)}): a window here would make this process the running instance Squirrel's final check aborts the install on, so the app tells the user and quits.`
18165
18637
  );
18166
18638
  const relaunchEnsured = ensureRelaunchWatchdog({
18167
18639
  marker: reading.marker,
@@ -18196,10 +18668,11 @@ function holdLaunchForLiveInstall(options) {
18196
18668
  });
18197
18669
  return true;
18198
18670
  }
18199
- function defaultStartRelaunchWatchdog(targetVersion) {
18671
+ function defaultStartRelaunchWatchdog(targetVersion, installerPid) {
18200
18672
  return startRelaunchWatchdog({
18201
18673
  targetVersion,
18202
18674
  runningVersion: electron.app.getVersion(),
18675
+ installerPid,
18203
18676
  log: (message) => logger.info(message, LogFileType.UPDATE_SERVICE)
18204
18677
  });
18205
18678
  }
@@ -18218,6 +18691,12 @@ class UpdateService {
18218
18691
  * available", which is how `shouldFilterUpdateError` would otherwise read it.
18219
18692
  */
18220
18693
  updateStage = "idle";
18694
+ /**
18695
+ * The install that just landed, for the one-line affirmation the next launch
18696
+ * owes the user (UX U4). Null once it has been reported.
18697
+ */
18698
+ pendingInstallSucceeded = null;
18699
+ installSucceededDelivered = false;
18221
18700
  /**
18222
18701
  * How many app-channel checks are in flight right now.
18223
18702
  *
@@ -18273,6 +18752,17 @@ class UpdateService {
18273
18752
  lastPublishedBackendVersion = null;
18274
18753
  /** Path of the artifact this run downloaded, when the updater reported one. */
18275
18754
  downloadedArtifactPath = null;
18755
+ /**
18756
+ * The artifact path the current pre-flight verified, for the staging step.
18757
+ *
18758
+ * `ensureStagedArtifactVerified` resolves it (the updater's own recorded path
18759
+ * first, then the cache's pending directory) and hashes it; staging needs the
18760
+ * same path and must not resolve it a second time, because the second answer is
18761
+ * allowed to be a different file than the one whose sha512 was checked. Set
18762
+ * only on a passing pre-flight, so a refusal leaves this null and staging
18763
+ * refuses too (`stageInstallerHandoff`).
18764
+ */
18765
+ verifiedArtifactPath = null;
18276
18766
  /**
18277
18767
  * Artifacts that failed their size/sha512/disk check, by version.
18278
18768
  *
@@ -18541,17 +19031,17 @@ class UpdateService {
18541
19031
  return;
18542
19032
  }
18543
19033
  const marker = readPendingInstallMarker(this.markerDir());
18544
- const jobState = this.shipItInstallJobState();
19034
+ const liveness = this.installLiveness(marker);
18545
19035
  if (marker) {
18546
19036
  logger.info(
18547
- `Pending install marker for version ${marker.targetVersion} on disk; its install job is ${jobState}.`,
19037
+ `Pending install marker for version ${marker.targetVersion} on disk; ${installLivenessText(liveness, marker.installerPid)}.`,
18548
19038
  LogFileType.UPDATE_SERVICE
18549
19039
  );
18550
19040
  }
18551
19041
  const outcome = evaluatePendingInstall({
18552
19042
  marker,
18553
19043
  runningVersion: electron.app.getVersion(),
18554
- // Probed once per evaluation, and only on macOS: a RUNNING job plus a
19044
+ // Probed once per evaluation, and only on macOS: a live installer plus a
18555
19045
  // current marker is what makes "still on the old version" an install in
18556
19046
  // progress rather than an install that failed. A job that is registered
18557
19047
  // but not running is the state a finished install leaves behind, and
@@ -18562,7 +19052,8 @@ class UpdateService {
18562
19052
  // bounds (review R6, R11).
18563
19053
  installInFlight: isInstallInFlight({
18564
19054
  marker,
18565
- jobState
19055
+ jobState: liveness.jobState,
19056
+ installerRunning: liveness.installerRunning
18566
19057
  })
18567
19058
  });
18568
19059
  switch (outcome.kind) {
@@ -18574,8 +19065,13 @@ class UpdateService {
18574
19065
  LogFileType.UPDATE_SERVICE
18575
19066
  );
18576
19067
  this.reapWatchdog(outcome.marker, true);
19068
+ this.reapStagedUpdateTrees();
18577
19069
  clearPendingInstallMarker(this.markerDir());
18578
19070
  this.retireSupersededInstallRecord();
19071
+ this.pendingInstallSucceeded = {
19072
+ version: outcome.marker.targetVersion
19073
+ };
19074
+ this.scheduleInstallSucceededDelivery();
18579
19075
  return;
18580
19076
  case "stale":
18581
19077
  logger.info(
@@ -18583,13 +19079,14 @@ class UpdateService {
18583
19079
  LogFileType.UPDATE_SERVICE
18584
19080
  );
18585
19081
  this.reapWatchdog(outcome.marker, true);
19082
+ this.reapStagedUpdateTrees();
18586
19083
  clearPendingInstallMarker(this.markerDir());
18587
19084
  this.retireSupersededInstallRecord();
18588
19085
  return;
18589
19086
  case "in-flight": {
18590
19087
  this.installWasInFlight = true;
18591
19088
  logger.info(
18592
- `Update marker: the install of version ${outcome.marker.targetVersion} is still running (install job ${jobState}). Leaving the marker, the install job and the relaunch watchdog in place.`,
19089
+ `Update marker: the install of version ${outcome.marker.targetVersion} is still running (${installLivenessText(liveness, outcome.marker.installerPid)}). Leaving the marker, the staged tree, the install job and the relaunch watchdog in place.`,
18593
19090
  LogFileType.UPDATE_SERVICE
18594
19091
  );
18595
19092
  this.pendingInstallInFlight = installInFlightPayload(
@@ -18634,6 +19131,7 @@ class UpdateService {
18634
19131
  this.stopInstallInFlightRecheck();
18635
19132
  this.reapWatchdog(outcome.marker, false);
18636
19133
  this.reapFailedInstallLeftovers();
19134
+ this.reapStagedUpdateTrees();
18637
19135
  this.schedulePendingInstallFailureDelivery();
18638
19136
  }
18639
19137
  }
@@ -18645,7 +19143,14 @@ class UpdateService {
18645
19143
  * before a service exists; the rule itself lives there.
18646
19144
  */
18647
19145
  bundleIdentifier() {
18648
- return runningBundleIdentifier();
19146
+ const bundlePath = this.runningBundleProbe ? this.runningBundleProbe() : appBundleFromExecutable(process.execPath);
19147
+ if (!bundlePath) return null;
19148
+ const value = readCommandOutput("/usr/bin/defaults", [
19149
+ "read",
19150
+ path$1.join(bundlePath, "Contents", "Info.plist"),
19151
+ "CFBundleIdentifier"
19152
+ ]);
19153
+ return value && BUNDLE_ID_REGEX.test(value) ? value : null;
18649
19154
  }
18650
19155
  /**
18651
19156
  * Squirrel's own stderr log for this app, when it exists.
@@ -18804,6 +19309,23 @@ class UpdateService {
18804
19309
  * it.
18805
19310
  */
18806
19311
  installJobStateProbe = null;
19312
+ /**
19313
+ * The `.app` a harness wants this service to treat as the one it runs from.
19314
+ *
19315
+ * A seam, and only a seam: production never sets it, and it exists because
19316
+ * `appBundleFromExecutable(process.execPath)` answers "not a bundle" for every
19317
+ * process that is not a packaged Electron app - which is every harness that
19318
+ * drives this module. Without it, the staging step and the installer's own
19319
+ * liveness could only be asserted by reading their source, and the shipped
19320
+ * code (the `ditto` extraction, the seal probe, the state plist, the detached
19321
+ * spawner) would be exactly the part nothing exercises.
19322
+ *
19323
+ * Substituting it does not change what the code does to the substitute: the
19324
+ * bundle that is pointed at is staged, sealed, probed and handed over as if it
19325
+ * were the running one, and a test that points it at a bundle it built in its
19326
+ * own scratch directory is driving the real path rather than a stub.
19327
+ */
19328
+ runningBundleProbe = null;
18807
19329
  /**
18808
19330
  * What this app's ShipIt install job is doing, right now.
18809
19331
  *
@@ -18819,6 +19341,19 @@ class UpdateService {
18819
19341
  if (this.installJobStateProbe) return this.installJobStateProbe();
18820
19342
  return probeShipItInstallJobState();
18821
19343
  }
19344
+ /**
19345
+ * What the pre-quit work is doing, on the update surface that is already up.
19346
+ *
19347
+ * A phase rather than a percentage: the three steps are `codesign` over the
19348
+ * installed bundle plus a hash of the artifact, the `ditto` extraction and its
19349
+ * seal probe, and the spawn - each of them one indivisible call this app does not
19350
+ * get progress out of, and inventing a bar for them would be a lie about what is
19351
+ * known (UX U5). Sent before each step starts, so the sentence on screen always
19352
+ * names the work that is actually happening.
19353
+ */
19354
+ reportInstallProgress(phase) {
19355
+ this.sendToRenderer("update-install-progress", { phase });
19356
+ }
18822
19357
  /**
18823
19358
  * Deliver the still-installing notice once the renderer can hear it.
18824
19359
  *
@@ -18835,6 +19370,39 @@ class UpdateService {
18835
19370
  }
18836
19371
  setTimeout(deliver, 5e3);
18837
19372
  }
19373
+ /**
19374
+ * Deliver the "the update went in" notice once the renderer can hear it.
19375
+ *
19376
+ * Same shape and the same reason as the in-flight and failure notices: recovery
19377
+ * runs while the window is still loading, and the component subscribes from a
19378
+ * React effect that can run after `did-finish-load`. Unlike the failure there is
19379
+ * nothing to keep on disk if the push is lost - the marker is cleared either way,
19380
+ * because it is the marker's job to be gone and this is a courtesy - so a missed
19381
+ * notice costs the sentence and nothing else.
19382
+ */
19383
+ scheduleInstallSucceededDelivery() {
19384
+ if (!this.pendingInstallSucceeded || this.installSucceededDelivered) return;
19385
+ const deliver = () => this.deliverPendingInstallSucceeded();
19386
+ const webContents = this.mainWindow?.webContents;
19387
+ if (webContents && !webContents.isDestroyed()) {
19388
+ webContents.once("did-finish-load", deliver);
19389
+ }
19390
+ setTimeout(deliver, 5e3);
19391
+ }
19392
+ deliverPendingInstallSucceeded() {
19393
+ if (!this.pendingInstallSucceeded || this.installSucceededDelivered) return;
19394
+ if (!this.sendToRenderer(
19395
+ "update-install-succeeded",
19396
+ this.pendingInstallSucceeded
19397
+ )) {
19398
+ return;
19399
+ }
19400
+ this.installSucceededDelivered = true;
19401
+ logger.info(
19402
+ "Reported a completed update install to the renderer",
19403
+ LogFileType.UPDATE_SERVICE
19404
+ );
19405
+ }
18838
19406
  deliverPendingInstallInFlight() {
18839
19407
  if (!this.pendingInstallInFlight || this.installInFlightDelivered) return;
18840
19408
  if (!this.sendToRenderer(
@@ -18887,8 +19455,12 @@ class UpdateService {
18887
19455
  marker,
18888
19456
  // The service's own method, not the module function, because a harness
18889
19457
  // substitutes this one: substituting the seam is how a test counts the
18890
- // watchdogs a quit leaves running without spawning any.
18891
- startWatchdog: (targetVersion) => this.launchWatchdog(targetVersion),
19458
+ // watchdogs a quit leaves running without spawning any. The marker's own
19459
+ // installer pid travels with the call: a watchdog ensured for an install
19460
+ // that is already in flight has to hold on the signal that install is
19461
+ // actually visible by, and for an install this app started itself that is
19462
+ // the pid rather than a job that will never appear (management MAJOR-1).
19463
+ startWatchdog: (targetVersion, installerPid) => this.launchWatchdog(targetVersion, installerPid),
18892
19464
  log: (message) => logger.info(message, LogFileType.UPDATE_SERVICE)
18893
19465
  });
18894
19466
  }
@@ -18896,16 +19468,18 @@ class UpdateService {
18896
19468
  * The pending-install marker, if the machine says an install is live now.
18897
19469
  *
18898
19470
  * The marker is read first, because the ordinary answer to "is an update
18899
- * installing" is no and a file read answers it; the launchd probe behind it is
18900
- * a process spawn and is only worth paying for when there is a marker to ask
18901
- * about.
19471
+ * installing" is no and a file read answers it; the probes behind it are process
19472
+ * spawns and are only worth paying for when there is a marker to ask about - and
19473
+ * only the one that can answer for that marker is paid for.
18902
19474
  */
18903
19475
  livePendingInstallMarker() {
18904
19476
  const marker = readPendingInstallMarker(this.markerDir());
18905
19477
  if (!marker) return null;
19478
+ const liveness = this.installLiveness(marker);
18906
19479
  return isInstallInFlight({
18907
19480
  marker,
18908
- jobState: this.shipItInstallJobState()
19481
+ jobState: liveness.jobState,
19482
+ installerRunning: liveness.installerRunning
18909
19483
  }) ? marker : null;
18910
19484
  }
18911
19485
  /**
@@ -19049,6 +19623,7 @@ class UpdateService {
19049
19623
  * against.
19050
19624
  */
19051
19625
  runningBundlePath() {
19626
+ if (this.runningBundleProbe) return this.runningBundleProbe();
19052
19627
  if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
19053
19628
  const bundlePath = appBundleFromExecutable(process.execPath);
19054
19629
  if (!bundlePath) {
@@ -19353,6 +19928,7 @@ ${heal.removed.join("\n")}`,
19353
19928
  version: info?.version ?? null
19354
19929
  });
19355
19930
  if (verdict.ok) {
19931
+ this.verifiedArtifactPath = artifactPath;
19356
19932
  const required = requiredDiskBytes({
19357
19933
  artifactSize: verdict.size,
19358
19934
  installedBundleSize: installedBundleSize ?? 0
@@ -19490,6 +20066,290 @@ ${heal.removed.join("\n")}`,
19490
20066
  if (sealBlock) return sealBlock;
19491
20067
  return this.ensureStagedArtifactVerified(info);
19492
20068
  }
20069
+ /**
20070
+ * The update bundle this app staged for itself, and everything ShipIt needs to
20071
+ * be pointed at it.
20072
+ *
20073
+ * Assembled in one place rather than passed as six arguments: the spawner, the
20074
+ * state plist and the marker all have to agree about which staging directory
20075
+ * this install got, and a path computed twice is two chances to disagree.
20076
+ */
20077
+ async stageInstallerHandoff(info) {
20078
+ const refuse = (reason) => {
20079
+ logger.info(
20080
+ `Not staging the update for the installer: ${reason}. The install will be handed to Squirrel's own path instead, which takes minutes but is the same install.`,
20081
+ LogFileType.UPDATE_SERVICE
20082
+ );
20083
+ return { kind: "fallback" };
20084
+ };
20085
+ const block = (detail, message, remedy) => {
20086
+ logger.warn(
20087
+ `Refusing to install the staged update: ${detail}`,
20088
+ LogFileType.UPDATE_SERVICE
20089
+ );
20090
+ return {
20091
+ kind: "blocked",
20092
+ block: {
20093
+ code: "download-verification-failed",
20094
+ message,
20095
+ remedy: { text: remedy },
20096
+ detail
20097
+ }
20098
+ };
20099
+ };
20100
+ if (process.platform !== "darwin") {
20101
+ return refuse("the installer this stages for is macOS's ShipIt");
20102
+ }
20103
+ if (!electron.app.isPackaged) {
20104
+ return refuse("this instance is not the bundle an install replaces");
20105
+ }
20106
+ const bundlePath = this.runningBundlePath();
20107
+ if (!bundlePath) {
20108
+ return refuse("the running executable is not inside an app bundle");
20109
+ }
20110
+ const shipItPath = shipItPathInBundle(bundlePath);
20111
+ if (!shipItPath || !isExecutableFile(shipItPath)) {
20112
+ return refuse(
20113
+ `${shipItPath ?? "no Squirrel.framework installer"} is not an executable inside the running bundle`
20114
+ );
20115
+ }
20116
+ const bundleId = this.bundleIdentifier();
20117
+ if (!bundleId) {
20118
+ return refuse("the running bundle has no readable CFBundleIdentifier");
20119
+ }
20120
+ const version = info?.version ?? null;
20121
+ if (!version) {
20122
+ return refuse("the update carries no version to install");
20123
+ }
20124
+ const zipPath = this.verifiedArtifactPath;
20125
+ if (!zipPath) {
20126
+ return refuse("the pre-flight verified no artifact path to extract from");
20127
+ }
20128
+ const userDataDir = electron.app.getPath("userData");
20129
+ const stagingRoot = updateStagingDir({
20130
+ userDataDir,
20131
+ version,
20132
+ nonce: node_crypto.randomBytes(6).toString("hex")
20133
+ });
20134
+ if (!sameVolume(deviceId(userDataDir), deviceId(bundlePath))) {
20135
+ return refuse(
20136
+ `the staging root ${stagingRoot} is on a different volume from the bundle it replaces (${bundlePath})`
20137
+ );
20138
+ }
20139
+ try {
20140
+ fs.mkdirSync(stagingRoot, { recursive: true });
20141
+ } catch (error) {
20142
+ return refuse(`the staging root could not be created: ${String(error)}`);
20143
+ }
20144
+ const started = Date.now();
20145
+ const extraction = await runExtraction(zipPath, stagingRoot);
20146
+ if (!extraction.ok) {
20147
+ return refuse(
20148
+ `the update bundle could not be extracted (${extraction.reason})`
20149
+ );
20150
+ }
20151
+ logger.info(
20152
+ `Extracted the update bundle into ${stagingRoot} in ${Date.now() - started} ms.`,
20153
+ LogFileType.UPDATE_SERVICE
20154
+ );
20155
+ const stagedAppPath = path$1.join(stagingRoot, path$1.basename(bundlePath));
20156
+ const verdict = evaluateStagedBundle({
20157
+ reading: readStagedBundle(stagedAppPath),
20158
+ expectedBundleIdentifier: bundleId,
20159
+ expectedVersion: version,
20160
+ runningArchitecture: process.arch
20161
+ });
20162
+ if (!verdict.ok) {
20163
+ return block(
20164
+ `The staged update bundle is not this application: ${verdict.detail}.`,
20165
+ `The downloaded update${versionSuffix(version)} is not this application, so it wasn't installed.`,
20166
+ "Check for updates again to re-download the release."
20167
+ );
20168
+ }
20169
+ let stagedSeal;
20170
+ try {
20171
+ ({ seal: stagedSeal } = await this.readBundleSeal(stagedAppPath));
20172
+ } catch (error) {
20173
+ return refuse(
20174
+ `the staged bundle's seal could not be probed: ${String(error)}`
20175
+ );
20176
+ }
20177
+ if (stagedSeal.kind === "unsealed") {
20178
+ return block(
20179
+ `The staged update bundle's seal reads \`unsealed\` (${stagedSeal.detail}), so Apple's own validation would have the same answer.`,
20180
+ `The downloaded update${versionSuffix(version)} is damaged, so it wasn't installed.`,
20181
+ "Check for updates again to re-download the release."
20182
+ );
20183
+ }
20184
+ if (stagedSeal.kind !== "sealed") {
20185
+ return refuse(
20186
+ `the staged bundle's seal could not be read (${stagedSeal.detail})`
20187
+ );
20188
+ }
20189
+ const statePath = stagingStatePath(stagingRoot);
20190
+ try {
20191
+ fs.writeFileSync(
20192
+ statePath,
20193
+ JSON.stringify(
20194
+ shipItStatePlist({
20195
+ bundleIdentifier: bundleId,
20196
+ targetBundlePath: bundlePath,
20197
+ updateBundlePath: stagedAppPath
20198
+ })
20199
+ )
20200
+ );
20201
+ } catch (error) {
20202
+ return refuse(
20203
+ `the installer's state plist could not be written: ${String(error)}`
20204
+ );
20205
+ }
20206
+ const label = shipItJobLabel(bundleId);
20207
+ const logPath = installerLogPath(
20208
+ shipItCacheDir(path$1.join(os.homedir(), "Library", "Caches"), bundleId)
20209
+ );
20210
+ try {
20211
+ fs.mkdirSync(path$1.dirname(logPath), { recursive: true });
20212
+ fs.closeSync(fs.openSync(logPath, "a"));
20213
+ } catch (error) {
20214
+ return refuse(
20215
+ `the installer's log ${logPath} could not be opened for appending: ${String(error)}`
20216
+ );
20217
+ }
20218
+ return {
20219
+ kind: "staged",
20220
+ handoff: {
20221
+ stagingRoot,
20222
+ stagedAppPath,
20223
+ shipItPath,
20224
+ statePath,
20225
+ label,
20226
+ // The SAME log launchd's redirection writes for a Squirrel-run
20227
+ // install: the failure panel's detail line and
20228
+ // scripts/update-window-report.mjs both read that path (see
20229
+ // installerLogPath).
20230
+ logPath
20231
+ }
20232
+ };
20233
+ }
20234
+ /**
20235
+ * Start the detached spawner that waits for this process to go, then installs.
20236
+ *
20237
+ * Detached with its stdio ignored, like the relaunch watchdog and for the same
20238
+ * reason: it must outlive this process, and a pipe this process holds is a pipe
20239
+ * that can keep it alive. Both of the installer's streams are appended to
20240
+ * Squirrel's own log inside the script rather than inherited from here, so no
20241
+ * descriptor of ours is passed at all.
20242
+ *
20243
+ * Returns the pid, which is the app's own answer to "is an install in flight"
20244
+ * from this moment on (`installerPid` in the marker, `install_live` in the
20245
+ * watchdog's script, `installLivenessNow` in recovery): the process is `exec`d
20246
+ * into ShipIt, so this one pid covers the wait and the whole install.
20247
+ */
20248
+ startInstaller(handoff) {
20249
+ const plan = buildInstallerSpawn({
20250
+ appPid: process.pid,
20251
+ shipItPath: handoff.shipItPath,
20252
+ label: handoff.label,
20253
+ statePath: handoff.statePath,
20254
+ logPath: handoff.logPath
20255
+ });
20256
+ try {
20257
+ const child = node_child_process.spawn("sh", plan.args, {
20258
+ detached: true,
20259
+ stdio: "ignore",
20260
+ env: { ...process.env, ...plan.env }
20261
+ });
20262
+ child.unref();
20263
+ if (child.pid == null) {
20264
+ logger.error(
20265
+ "The installer was spawned but reported no pid; falling back to Squirrel's own install path.",
20266
+ LogFileType.UPDATE_SERVICE
20267
+ );
20268
+ return null;
20269
+ }
20270
+ logger.info(
20271
+ `Started the installer (pid ${child.pid}) for version ${handoff.label}: it waits for this process (pid ${process.pid}) to go and then becomes ${handoff.shipItPath}, which installs ${handoff.stagedAppPath} and starts the app again.`,
20272
+ LogFileType.UPDATE_SERVICE
20273
+ );
20274
+ return child.pid;
20275
+ } catch (error) {
20276
+ logger.error(
20277
+ `Could not start the installer for ${handoff.stagedAppPath}: ${String(error)}`,
20278
+ LogFileType.UPDATE_SERVICE
20279
+ );
20280
+ return null;
20281
+ }
20282
+ }
20283
+ /**
20284
+ * Stop a spawner that has been started but whose marker could not be written.
20285
+ *
20286
+ * The one hole in "spawn, then record": a spawner with no marker would wait
20287
+ * for a quit that this process is not going to make, and a wait nothing can
20288
+ * tell apart from an install in progress is worse than no installer at all. So
20289
+ * the marker write is the only step whose failure undoes the spawn, and the
20290
+ * error the caller sees is the same one it saw before this change.
20291
+ */
20292
+ abortInstaller(pid) {
20293
+ try {
20294
+ process.kill(pid, "SIGTERM");
20295
+ logger.warn(
20296
+ `Stopped the installer (pid ${pid}) because this app could not record the install it was started for.`,
20297
+ LogFileType.UPDATE_SERVICE
20298
+ );
20299
+ } catch (error) {
20300
+ logger.warn(
20301
+ `Could not stop the installer (pid ${pid}) started for an install that was not recorded: ${String(error)}`,
20302
+ LogFileType.UPDATE_SERVICE
20303
+ );
20304
+ }
20305
+ }
20306
+ /**
20307
+ * Remove the staging trees left by installs that are over.
20308
+ *
20309
+ * The app owns this tree now where Squirrel used to own `update.<random>` under
20310
+ * its own cache: it is one install's worth of app either way, but a Squirrel
20311
+ * install's leftovers sit in Apple's cache directory and this one's sit in the
20312
+ * app's own, so nothing else was ever going to clean them up. Called only from
20313
+ * the branches that have decided an install is NOT in flight - reaping a tree a
20314
+ * live installer is reading is the one thing this must never do (the design
20315
+ * note's falsification list).
20316
+ */
20317
+ reapStagedUpdateTrees() {
20318
+ const root = updateStagingRoot(electron.app.getPath("userData"));
20319
+ if (!fs.existsSync(root)) return;
20320
+ for (const entry of fs.readdirSync(root)) {
20321
+ const tree = path$1.join(root, entry);
20322
+ try {
20323
+ reapStagedTree(tree);
20324
+ logger.info(
20325
+ `Reaped the staged update tree ${tree} left by an install that is not running.`,
20326
+ LogFileType.UPDATE_SERVICE
20327
+ );
20328
+ } catch (error) {
20329
+ logger.warn(
20330
+ `Could not reap the staged update tree ${tree}: ${String(error)}`,
20331
+ LogFileType.UPDATE_SERVICE
20332
+ );
20333
+ }
20334
+ }
20335
+ }
20336
+ /**
20337
+ * Both of the machine's liveness answers for this marker.
20338
+ *
20339
+ * The one place the machine is asked, so recovery's reading, the live re-check,
20340
+ * the quit's own guard and the launch hold cannot disagree about the same install
20341
+ * (management MAJOR-1). Which of the two decides (`installLivenessNow`) turns on
20342
+ * the marker's own `installerPid`: an install this app started has no launchd
20343
+ * job, and an install Squirrel submitted has no pid. Both probes are process
20344
+ * spawns, so only the one that can answer is asked.
20345
+ */
20346
+ installLiveness(marker) {
20347
+ return installLivenessFor({
20348
+ marker,
20349
+ bundlePath: () => this.runningBundlePath(),
20350
+ jobState: () => this.shipItInstallJobState()
20351
+ });
20352
+ }
19493
20353
  /**
19494
20354
  * Start the detached relaunch watchdog.
19495
20355
  *
@@ -19497,10 +20357,11 @@ ${heal.removed.join("\n")}`,
19497
20357
  * uses; the plan, the bounds and the log line live there. Kept as a method
19498
20358
  * because a harness substitutes it to count watchdogs without spawning one.
19499
20359
  */
19500
- launchWatchdog(targetVersion) {
20360
+ launchWatchdog(targetVersion, installerPid = null) {
19501
20361
  return startRelaunchWatchdog({
19502
20362
  targetVersion,
19503
20363
  runningVersion: electron.app.getVersion(),
20364
+ installerPid,
19504
20365
  log: (message) => logger.info(message, LogFileType.UPDATE_SERVICE)
19505
20366
  });
19506
20367
  }
@@ -19917,8 +20778,16 @@ ${heal.removed.join("\n")}`,
19917
20778
  this.installPreflightInFlight = true;
19918
20779
  this.updateStage = "installing";
19919
20780
  let block = null;
20781
+ let handoff = null;
20782
+ this.reportInstallProgress("verifying");
19920
20783
  try {
19921
20784
  block = await this.runInstallPreflight(this.lastUpdateInfo);
20785
+ if (!block) {
20786
+ this.reportInstallProgress("staging");
20787
+ const staged = await this.stageInstallerHandoff(this.lastUpdateInfo);
20788
+ if (staged.kind === "staged") handoff = staged.handoff;
20789
+ if (staged.kind === "blocked") block = staged.block;
20790
+ }
19922
20791
  if (!block) await this.backendService?.stop(false);
19923
20792
  } catch (error) {
19924
20793
  this.updateStage = "idle";
@@ -19937,18 +20806,32 @@ ${heal.removed.join("\n")}`,
19937
20806
  return false;
19938
20807
  }
19939
20808
  const targetVersion = this.lastUpdateInfo?.version ?? electron.app.getVersion();
19940
- const watchdogPid = this.launchWatchdog(targetVersion);
19941
- const marker = writePendingInstallMarker(this.markerDir(), {
19942
- targetVersion,
19943
- artifactPath: this.downloadedArtifactPath ?? this.downloadHelper()?.file ?? "unknown",
19944
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
19945
- watchdogPid
19946
- });
20809
+ if (handoff) this.reportInstallProgress("starting");
20810
+ const installerPid = handoff ? this.startInstaller(handoff) : null;
20811
+ const watchdogPid = this.launchWatchdog(targetVersion, installerPid);
20812
+ let marker;
20813
+ try {
20814
+ marker = writePendingInstallMarker(this.markerDir(), {
20815
+ targetVersion,
20816
+ artifactPath: this.downloadedArtifactPath ?? this.downloadHelper()?.file ?? "unknown",
20817
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
20818
+ watchdogPid,
20819
+ installerPid
20820
+ });
20821
+ } catch (error) {
20822
+ if (installerPid != null) this.abortInstaller(installerPid);
20823
+ this.updateStage = "idle";
20824
+ throw error;
20825
+ }
19947
20826
  logger.info(
19948
- `Pending install marker written for version ${marker.targetVersion} (watchdog pid ${watchdogPid ?? "none"}).`,
20827
+ `Pending install marker written for version ${marker.targetVersion} (watchdog pid ${watchdogPid ?? "none"}, ${installerPid != null ? `installer pid ${installerPid}` : "Squirrel's own installer"}).`,
19949
20828
  LogFileType.UPDATE_SERVICE
19950
20829
  );
19951
20830
  this.backendService?.setAutoUpdating(true);
20831
+ if (installerPid != null) {
20832
+ electron.app.quit();
20833
+ return true;
20834
+ }
19952
20835
  electronUpdater.autoUpdater.quitAndInstall(false, true);
19953
20836
  return true;
19954
20837
  });