local-operator-ui 0.34.11 → 0.34.12
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.
- package/out/main/index.js +363 -6
- package/package.json +1 -1
package/out/main/index.js
CHANGED
|
@@ -4484,6 +4484,9 @@ const SHIPIT_RELATIVE_PATH = path$1.join(
|
|
|
4484
4484
|
const INSTALLER_TOKEN = "local-operator-update-installer";
|
|
4485
4485
|
const UPDATE_STAGING_DIR = "update-staging";
|
|
4486
4486
|
const INSTALLER_POLL_SECONDS = "0.2";
|
|
4487
|
+
const INSTALLER_FIRST_GRACE_SECONDS = 60;
|
|
4488
|
+
const INSTALLER_SECOND_GRACE_SECONDS = 20;
|
|
4489
|
+
const INSTALLER_KILL_SETTLE_SECONDS = "0.5";
|
|
4487
4490
|
const EXTRACTION_TIMEOUT_MS = 10 * 6e4;
|
|
4488
4491
|
function shipItPathInBundle(bundlePath) {
|
|
4489
4492
|
if (!bundlePath) return null;
|
|
@@ -4563,6 +4566,8 @@ function shipItStatePlist(input) {
|
|
|
4563
4566
|
};
|
|
4564
4567
|
}
|
|
4565
4568
|
function buildInstallerSpawn(input) {
|
|
4569
|
+
const firstGrace = input.graceSeconds?.first ?? INSTALLER_FIRST_GRACE_SECONDS;
|
|
4570
|
+
const secondGrace = input.graceSeconds?.second ?? INSTALLER_SECOND_GRACE_SECONDS;
|
|
4566
4571
|
const script = `#!/bin/sh
|
|
4567
4572
|
# ${INSTALLER_TOKEN}
|
|
4568
4573
|
#
|
|
@@ -4573,10 +4578,74 @@ function buildInstallerSpawn(input) {
|
|
|
4573
4578
|
# (SQRLInstallerErrorDomain Code=-9, the 2026-09-18 09:37 install). So this waits
|
|
4574
4579
|
# for the app's pid to go, and then replaces itself with the installer: one
|
|
4575
4580
|
# process, one pid for the app to record, and no shell left in the way.
|
|
4576
|
-
|
|
4581
|
+
#
|
|
4582
|
+
# THE WAIT IS BOUNDED AND LOGGED (2026-10-11): the unbounded shape waited 26 m
|
|
4583
|
+
# 49 s while the app sat alive with no window, and logged nothing anywhere. Each
|
|
4584
|
+
# stage below is bounded and writes one timestamped line to the installer's log;
|
|
4585
|
+
# the last stage starts the installer regardless, because a pid that survives
|
|
4586
|
+
# SIGKILL is a zombie and nothing more exists to wait for.
|
|
4587
|
+
app_pid=$1
|
|
4588
|
+
started=$(date +%s)
|
|
4589
|
+
log_stage() {
|
|
4590
|
+
printf '%s ${INSTALLER_TOKEN}: %s\\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$1" >>"$LO_UPDATE_INSTALLER_LOG"
|
|
4591
|
+
}
|
|
4592
|
+
elapsed() {
|
|
4593
|
+
printf '%d' "$(( $(date +%s) - started ))"
|
|
4594
|
+
}
|
|
4595
|
+
# A recycled pid is never signalled: before any kill, the target's command line
|
|
4596
|
+
# must still name the app's own executable ($LO_UPDATE_INSTALLER_APP_EXEC,
|
|
4597
|
+
# environment only - see the args comment at the callers).
|
|
4598
|
+
target_is_app() {
|
|
4599
|
+
[ -n "$LO_UPDATE_INSTALLER_APP_EXEC" ] || return 1
|
|
4600
|
+
_line=$(ps -o command= -p "$app_pid" 2>/dev/null) || return 1
|
|
4601
|
+
case "$_line" in
|
|
4602
|
+
*"$LO_UPDATE_INSTALLER_APP_EXEC"*) return 0 ;;
|
|
4603
|
+
esac
|
|
4604
|
+
return 1
|
|
4605
|
+
}
|
|
4606
|
+
start_installer() {
|
|
4607
|
+
log_stage "starting the installer ($(elapsed)s in)"
|
|
4608
|
+
exec "$LO_UPDATE_INSTALLER_SHIPIT" "$LO_UPDATE_INSTALLER_LABEL" "$LO_UPDATE_INSTALLER_STATE" >>"$LO_UPDATE_INSTALLER_LOG" 2>&1
|
|
4609
|
+
}
|
|
4610
|
+
log_stage "waiting up to ${firstGrace}s for the app to exit on its own"
|
|
4611
|
+
_end=$((started + ${firstGrace}))
|
|
4612
|
+
while kill -0 "$app_pid" 2>/dev/null; do
|
|
4613
|
+
if [ "$(date +%s)" -ge "$_end" ]; then break; fi
|
|
4577
4614
|
sleep ${INSTALLER_POLL_SECONDS}
|
|
4578
4615
|
done
|
|
4579
|
-
|
|
4616
|
+
if ! kill -0 "$app_pid" 2>/dev/null; then
|
|
4617
|
+
log_stage "the app exited on its own ($(elapsed)s in)"
|
|
4618
|
+
start_installer
|
|
4619
|
+
fi
|
|
4620
|
+
log_stage "the first grace expired with the app still there"
|
|
4621
|
+
if target_is_app; then
|
|
4622
|
+
log_stage "asking the app to stop (SIGTERM)"
|
|
4623
|
+
kill -TERM "$app_pid" 2>/dev/null
|
|
4624
|
+
_end=$(( $(date +%s) + ${secondGrace} ))
|
|
4625
|
+
while kill -0 "$app_pid" 2>/dev/null; do
|
|
4626
|
+
if [ "$(date +%s)" -ge "$_end" ]; then break; fi
|
|
4627
|
+
sleep ${INSTALLER_POLL_SECONDS}
|
|
4628
|
+
done
|
|
4629
|
+
if ! kill -0 "$app_pid" 2>/dev/null; then
|
|
4630
|
+
log_stage "the app exited after SIGTERM ($(elapsed)s in)"
|
|
4631
|
+
start_installer
|
|
4632
|
+
fi
|
|
4633
|
+
if target_is_app; then
|
|
4634
|
+
log_stage "the second grace expired; forcing the pid gone (SIGKILL)"
|
|
4635
|
+
kill -KILL "$app_pid" 2>/dev/null
|
|
4636
|
+
sleep ${INSTALLER_KILL_SETTLE_SECONDS}
|
|
4637
|
+
if kill -0 "$app_pid" 2>/dev/null; then
|
|
4638
|
+
log_stage "the pid survived SIGKILL (a zombie or unkillable); nothing left to wait for"
|
|
4639
|
+
else
|
|
4640
|
+
log_stage "the app is gone after SIGKILL ($(elapsed)s in)"
|
|
4641
|
+
fi
|
|
4642
|
+
else
|
|
4643
|
+
log_stage "target is not the app after SIGTERM; starting the installer"
|
|
4644
|
+
fi
|
|
4645
|
+
start_installer
|
|
4646
|
+
fi
|
|
4647
|
+
log_stage "target is not the app; starting the installer without signalling"
|
|
4648
|
+
start_installer
|
|
4580
4649
|
`;
|
|
4581
4650
|
return {
|
|
4582
4651
|
script,
|
|
@@ -4584,7 +4653,8 @@ exec "$LO_UPDATE_INSTALLER_SHIPIT" "$LO_UPDATE_INSTALLER_LABEL" "$LO_UPDATE_INST
|
|
|
4584
4653
|
LO_UPDATE_INSTALLER_SHIPIT: input.shipItPath,
|
|
4585
4654
|
LO_UPDATE_INSTALLER_LABEL: input.label,
|
|
4586
4655
|
LO_UPDATE_INSTALLER_STATE: input.statePath,
|
|
4587
|
-
LO_UPDATE_INSTALLER_LOG: input.logPath
|
|
4656
|
+
LO_UPDATE_INSTALLER_LOG: input.logPath,
|
|
4657
|
+
LO_UPDATE_INSTALLER_APP_EXEC: input.appExecPath
|
|
4588
4658
|
},
|
|
4589
4659
|
// `sh -c <script> <name> <pid>`: the name is $0 and the pid is $1, so the
|
|
4590
4660
|
// script text carries no path and no number that a process listing of it
|
|
@@ -4611,6 +4681,8 @@ const INSTALL_DISK_SLACK_BYTES = 256 * 1024 * 1024;
|
|
|
4611
4681
|
const DOWNLOAD_PAGE_URL = "https://local-operator.com/download";
|
|
4612
4682
|
const WATCHDOG_TIMEOUT_SECONDS = 600;
|
|
4613
4683
|
const WATCHDOG_HARD_TIMEOUT_SECONDS = 1800;
|
|
4684
|
+
const WATCHDOG_APP_WAIT_NOTICE_SECONDS = 15;
|
|
4685
|
+
const WATCHDOG_APP_WAIT_FALLBACK_SECONDS = 2 * (INSTALLER_FIRST_GRACE_SECONDS + INSTALLER_SECOND_GRACE_SECONDS);
|
|
4614
4686
|
const PENDING_INSTALL_RECENCY_MARGIN_SECONDS = 300;
|
|
4615
4687
|
const PENDING_INSTALL_RECENCY_SECONDS = WATCHDOG_HARD_TIMEOUT_SECONDS + PENDING_INSTALL_RECENCY_MARGIN_SECONDS;
|
|
4616
4688
|
const LAUNCH_HOLD_END_MARGIN_SECONDS = 60;
|
|
@@ -5346,13 +5418,15 @@ function watchdogSignals(platform) {
|
|
|
5346
5418
|
return { jobProbe: "launchctl", plistReader: "/usr/bin/plutil" };
|
|
5347
5419
|
}
|
|
5348
5420
|
function buildWatchdogPlan(input) {
|
|
5349
|
-
const signals = input.signals ?? watchdogSignals(input.platform);
|
|
5421
|
+
const signals = input.signals ?? watchdogSignals(input.platform ?? "darwin");
|
|
5350
5422
|
const timeoutSeconds = input.timeoutSeconds ?? WATCHDOG_TIMEOUT_SECONDS;
|
|
5351
5423
|
const hardTimeoutSeconds = input.hardTimeoutSeconds ?? WATCHDOG_HARD_TIMEOUT_SECONDS;
|
|
5352
5424
|
const intervalSeconds = input.intervalSeconds ?? 3;
|
|
5353
5425
|
const settleSeconds = input.settleSeconds ?? 5;
|
|
5354
5426
|
const appearSeconds = input.appearSeconds ?? 30;
|
|
5355
5427
|
const announceSeconds = input.announceSeconds ?? 5;
|
|
5428
|
+
const appWaitNoticeSeconds = input.appWaitNoticeSeconds ?? WATCHDOG_APP_WAIT_NOTICE_SECONDS;
|
|
5429
|
+
const appWaitFallbackSeconds = input.appWaitFallbackSeconds ?? WATCHDOG_APP_WAIT_FALLBACK_SECONDS;
|
|
5356
5430
|
const plistTimeoutSeconds = input.plistReadTimeoutSeconds ?? PLIST_READ_TIMEOUT_SECONDS;
|
|
5357
5431
|
const script = `#!/bin/sh
|
|
5358
5432
|
# ${WATCHDOG_TOKEN}
|
|
@@ -5450,6 +5524,27 @@ if [ -z "$APP_PID" ] || [ -z "$BUNDLE" ]; then
|
|
|
5450
5524
|
fi
|
|
5451
5525
|
now() { date +%s; }
|
|
5452
5526
|
app_running() { kill -0 "$APP_PID" 2>/dev/null; }
|
|
5527
|
+
# THE WAIT'S OWN CLOCK (2026-10-11). The script is spawned as the quit begins, so
|
|
5528
|
+
# its own start IS the wait's start - elapsed is measured from here rather than
|
|
5529
|
+
# from the marker's startedAt, which is written before the quit and would need a
|
|
5530
|
+
# new seam through two collaborators for a difference of seconds.
|
|
5531
|
+
started=$(now)
|
|
5532
|
+
# "waiting 15 s" / "waiting 2 min 30 s": elapsed in the units a person reads,
|
|
5533
|
+
# with the seconds term dropped at an exact minute ("waiting 30 min") - "30 min
|
|
5534
|
+
# 0 s" reads like a clock fault rather than a wait (round-1 D2).
|
|
5535
|
+
elapsed_text() {
|
|
5536
|
+
_seconds=$(( $(now) - started ))
|
|
5537
|
+
if [ "$_seconds" -lt 0 ]; then _seconds=0; fi
|
|
5538
|
+
if [ "$_seconds" -ge 60 ]; then
|
|
5539
|
+
if [ $((_seconds % 60)) -eq 0 ]; then
|
|
5540
|
+
printf '%d min' "$((_seconds / 60))"
|
|
5541
|
+
else
|
|
5542
|
+
printf '%d min %d s' "$((_seconds / 60))" "$((_seconds % 60))"
|
|
5543
|
+
fi
|
|
5544
|
+
else
|
|
5545
|
+
printf '%d s' "$_seconds"
|
|
5546
|
+
fi
|
|
5547
|
+
}
|
|
5453
5548
|
# Whether ANY process is this install's ShipIt, for the pid that stopped being it.
|
|
5454
5549
|
#
|
|
5455
5550
|
# The same two facts installerElsewhere requires of a line in its listing: this
|
|
@@ -5676,8 +5771,31 @@ decided() {
|
|
|
5676
5771
|
deadline=$(( $(now) + ${timeoutSeconds} ))
|
|
5677
5772
|
hard_deadline=$(( $(now) + ${hardTimeoutSeconds} ))
|
|
5678
5773
|
# 1. The install only starts once the old app has exited.
|
|
5774
|
+
#
|
|
5775
|
+
# AND THE WAIT SPEAKS, INSTEAD OF LEAVING THE PERSON IN SILENCE (2026-10-11).
|
|
5776
|
+
# The incident's app sat alive with no window for 26 m 49 s; this script was
|
|
5777
|
+
# the only process left that could say anything, and its first step was
|
|
5778
|
+
# silent until the soft bound - the person watched nothing happen and
|
|
5779
|
+
# resolved it by hand ('kill -TERM', which is what let ShipIt exec). So the
|
|
5780
|
+
# wait now says what it is waiting for and for how long, well before the
|
|
5781
|
+
# installer's own escalation signals the app
|
|
5782
|
+
# (WATCHDOG_APP_WAIT_NOTICE_SECONDS), and after the escalation has
|
|
5783
|
+
# provably failed it hands over the action that works
|
|
5784
|
+
# (WATCHDOG_APP_WAIT_FALLBACK_SECONDS). Each fires at most once; the
|
|
5785
|
+
# kill switch silences both like every other notice (notify's own guard).
|
|
5786
|
+
app_wait_notice=0
|
|
5787
|
+
app_wait_fallback=0
|
|
5679
5788
|
while app_running; do
|
|
5680
5789
|
if [ "$(now)" -ge "$deadline" ]; then break; fi
|
|
5790
|
+
_waited=$(( $(now) - started ))
|
|
5791
|
+
if [ "$app_wait_notice" -eq 0 ] && [ "$_waited" -ge ${appWaitNoticeSeconds} ]; then
|
|
5792
|
+
app_wait_notice=1
|
|
5793
|
+
notify "Waiting for Local Operator to close so the update can install (waiting $(elapsed_text)). No action is needed yet."
|
|
5794
|
+
fi
|
|
5795
|
+
if [ "$app_wait_fallback" -eq 0 ] && [ "$_waited" -ge ${appWaitFallbackSeconds} ]; then
|
|
5796
|
+
app_wait_fallback=1
|
|
5797
|
+
notify "The update is still waiting for Local Operator to close (waiting $(elapsed_text)). If nothing happens, quit Local Operator (Force Quit if it does not respond) and leave it closed until it opens again by itself."
|
|
5798
|
+
fi
|
|
5681
5799
|
sleep ${intervalSeconds}
|
|
5682
5800
|
done
|
|
5683
5801
|
# 1b. The app is gone and an install is at work, so the window that just
|
|
@@ -5752,7 +5870,16 @@ while :; do
|
|
|
5752
5870
|
if [ "$(now)" -ge "$deadline" ]; then
|
|
5753
5871
|
if [ "$signal_known" -eq 1 ] && install_live; then
|
|
5754
5872
|
holding=1
|
|
5755
|
-
|
|
5873
|
+
# THE SOFT BOUND BRANCHES TOO (round-1 UX U4): it fires while the app
|
|
5874
|
+
# may still be RUNNING, where "keep it closed" describes a state the
|
|
5875
|
+
# person is not in. App running -> the wait/fallback copy with a fresh
|
|
5876
|
+
# elapsed reading; app gone -> the stay-closed copy, which is true there
|
|
5877
|
+
# (an install this app spawned is live and the swap still needs quiet).
|
|
5878
|
+
if app_running; then
|
|
5879
|
+
notify "The update is still waiting for Local Operator to close (waiting $(elapsed_text)). If nothing happens, quit Local Operator (Force Quit if it does not respond) and leave it closed until it opens again by itself."
|
|
5880
|
+
else
|
|
5881
|
+
notify "The update is still installing. Keep Local Operator closed; it will open again when the install finishes."
|
|
5882
|
+
fi
|
|
5756
5883
|
fi
|
|
5757
5884
|
break
|
|
5758
5885
|
fi
|
|
@@ -5780,7 +5907,20 @@ if [ "$holding" -eq 1 ]; then
|
|
|
5780
5907
|
while :; do
|
|
5781
5908
|
if decided; then break; fi
|
|
5782
5909
|
if [ "$(now)" -ge "$hard_deadline" ]; then
|
|
5783
|
-
|
|
5910
|
+
# TRUTHFUL ON BOTH ARMS (2026-10-11). The notice that used to stand
|
|
5911
|
+
# here promised "Local Operator is opening again" unconditionally, and
|
|
5912
|
+
# the path below with the app STILL RUNNING ('if app_running; then
|
|
5913
|
+
# exit 0') is exactly where that promise is false - the watchdog
|
|
5914
|
+
# exits without launching, because launching into a running app is
|
|
5915
|
+
# what aborts a swap. So the app-still-running arm now says the true
|
|
5916
|
+
# thing and hands over the action that resolves it, the same fallback
|
|
5917
|
+
# the wait's own notices use; the app-gone arm keeps the notice the
|
|
5918
|
+
# panel's copy expects (UX U10).
|
|
5919
|
+
if app_running; then
|
|
5920
|
+
notify "The update is still waiting for Local Operator to close (waiting $(elapsed_text)). If nothing happens, quit Local Operator (Force Quit if it does not respond) and leave it closed until it opens again by itself."
|
|
5921
|
+
else
|
|
5922
|
+
notify "The update is taking longer than expected. Local Operator is opening again so you are not left without it — if it says the update is still installing, quit it and leave it closed until the install finishes."
|
|
5923
|
+
fi
|
|
5784
5924
|
break
|
|
5785
5925
|
fi
|
|
5786
5926
|
sleep ${intervalSeconds}
|
|
@@ -35442,6 +35582,32 @@ function rememberPickedDirectory(kind, filePaths, directoryPicked = false) {
|
|
|
35442
35582
|
if (!first) return;
|
|
35443
35583
|
lastPickedDirectory.set(kind, directoryPicked ? first : path$1.dirname(first));
|
|
35444
35584
|
}
|
|
35585
|
+
const QUIT_EXIT_DEADLINE_MARGIN_MS = 5e3;
|
|
35586
|
+
function quitExitDeadlineMs(input) {
|
|
35587
|
+
return SESSION_COOKIE_QUIT_BUDGET_MS + input.quitCleanupFailsafeMs + QUIT_EXIT_DEADLINE_MARGIN_MS;
|
|
35588
|
+
}
|
|
35589
|
+
function createQuitExitDeadline(input) {
|
|
35590
|
+
let timer = null;
|
|
35591
|
+
let stage = "";
|
|
35592
|
+
return {
|
|
35593
|
+
arm(next) {
|
|
35594
|
+
if (timer) return;
|
|
35595
|
+
stage = next;
|
|
35596
|
+
timer = setTimeout(() => {
|
|
35597
|
+
timer = null;
|
|
35598
|
+
input.onFire(stage);
|
|
35599
|
+
}, input.timeoutMs);
|
|
35600
|
+
timer.unref();
|
|
35601
|
+
},
|
|
35602
|
+
phase(next) {
|
|
35603
|
+
stage = next;
|
|
35604
|
+
},
|
|
35605
|
+
release() {
|
|
35606
|
+
if (timer) clearTimeout(timer);
|
|
35607
|
+
timer = null;
|
|
35608
|
+
}
|
|
35609
|
+
};
|
|
35610
|
+
}
|
|
35445
35611
|
function createQuitState() {
|
|
35446
35612
|
let quitting = false;
|
|
35447
35613
|
return {
|
|
@@ -36440,6 +36606,9 @@ const GLOBAL_UPDATE_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
|
36440
36606
|
const SOURCE_REBUILD_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
36441
36607
|
const UPDATE_RESTART_STOP_GRACE_MS = 5e3;
|
|
36442
36608
|
const BUNDLE_ID_REGEX = /^[\w.-]+$/;
|
|
36609
|
+
const APP_UPDATE_CACHE_DIR_NAME_REGEX = /^updaterCacheDirName:[ \t]*(\S+)[ \t]*$/m;
|
|
36610
|
+
const CACHE_DIR_NAME_OK_REGEX = /^[A-Za-z0-9._-]+$/;
|
|
36611
|
+
const UPDATER_CACHE_PENDING_FILE_REGEX = /\.zip$/i;
|
|
36443
36612
|
const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
|
|
36444
36613
|
const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
|
|
36445
36614
|
const APP_OWNED_PUBLISH_HEADROOM_BYTES = 450 * 1024 * 1024;
|
|
@@ -37446,6 +37615,7 @@ class UpdateService {
|
|
|
37446
37615
|
);
|
|
37447
37616
|
this.reapWatchdog(outcome.marker, true);
|
|
37448
37617
|
this.reapStagedUpdateTrees();
|
|
37618
|
+
this.reapUpdaterCache();
|
|
37449
37619
|
clearPendingInstallMarker(this.markerDir());
|
|
37450
37620
|
this.retireSupersededInstallRecord();
|
|
37451
37621
|
this.pendingInstallSucceeded = {
|
|
@@ -37460,6 +37630,7 @@ class UpdateService {
|
|
|
37460
37630
|
);
|
|
37461
37631
|
this.reapWatchdog(outcome.marker, true);
|
|
37462
37632
|
this.reapStagedUpdateTrees();
|
|
37633
|
+
this.reapUpdaterCache();
|
|
37463
37634
|
clearPendingInstallMarker(this.markerDir());
|
|
37464
37635
|
this.retireSupersededInstallRecord();
|
|
37465
37636
|
return;
|
|
@@ -37512,6 +37683,7 @@ class UpdateService {
|
|
|
37512
37683
|
this.reapWatchdog(outcome.marker, false);
|
|
37513
37684
|
this.reapFailedInstallLeftovers();
|
|
37514
37685
|
this.reapStagedUpdateTrees();
|
|
37686
|
+
this.reapUpdaterCache();
|
|
37515
37687
|
this.schedulePendingInstallFailureDelivery();
|
|
37516
37688
|
}
|
|
37517
37689
|
}
|
|
@@ -38643,6 +38815,16 @@ ${heal.removed.join("\n")}`,
|
|
|
38643
38815
|
startInstaller(handoff) {
|
|
38644
38816
|
const plan = buildInstallerSpawn({
|
|
38645
38817
|
appPid: process.pid,
|
|
38818
|
+
/*
|
|
38819
|
+
* The path the spawner's identity check matches before it signals
|
|
38820
|
+
* anything: this process's own executable. `process.execPath` is
|
|
38821
|
+
* `/Applications/Local Operator.app/Contents/MacOS/Local Operator`
|
|
38822
|
+
* in the packaged app, and the running main process's command line
|
|
38823
|
+
* starts with exactly that path - which is the question
|
|
38824
|
+
* `ps -o command= -p <pid>` answers. It travels in the environment,
|
|
38825
|
+
* never in the script text (see the callee's own note).
|
|
38826
|
+
*/
|
|
38827
|
+
appExecPath: process.execPath,
|
|
38646
38828
|
shipItPath: handoff.shipItPath,
|
|
38647
38829
|
label: handoff.label,
|
|
38648
38830
|
statePath: handoff.statePath,
|
|
@@ -38728,6 +38910,146 @@ ${heal.removed.join("\n")}`,
|
|
|
38728
38910
|
}
|
|
38729
38911
|
}
|
|
38730
38912
|
}
|
|
38913
|
+
/**
|
|
38914
|
+
* The updater's own cache directory for this app, or null when it cannot be
|
|
38915
|
+
* resolved.
|
|
38916
|
+
*
|
|
38917
|
+
* WHY THE APP NEEDS THIS PATH AT ALL (2026-10-11 cache hygiene): nothing ever
|
|
38918
|
+
* clears `~/Library/Caches/<updater cache dir>/` - measured residue after one
|
|
38919
|
+
* successful install: `update.zip` (~180 MB), `pending/<artifact>.zip` (~180
|
|
38920
|
+
* MB, the SAME bytes) and `pending/update-info.json`, all three left behind by
|
|
38921
|
+
* a download that has already been used or replaced. electron-updater clears
|
|
38922
|
+
* `pending/` on a failed DOWNLOAD and never clears `update.zip` - by design,
|
|
38923
|
+
* because `update.zip` is the differential base its next download patches
|
|
38924
|
+
* against (`MacUpdater`'s `canDifferentialDownload`), so deleting it buys disk
|
|
38925
|
+
* at the cost of a full ~180 MB re-download. `reapUpdaterCache` below is the
|
|
38926
|
+
* bounded-retention answer; this resolver is how it finds the directory.
|
|
38927
|
+
*
|
|
38928
|
+
* TWO SOURCES, in order. The download helper is authoritative for a download
|
|
38929
|
+
* THIS run performed (the same private surface `ensureStagedArtifactVerified`
|
|
38930
|
+
* already reads). It does not survive a restart, and the reap runs at
|
|
38931
|
+
* start-up, so the fallback reads the name electron-updater itself would
|
|
38932
|
+
* read: `updaterCacheDirName` from `app-update.yml` beside the packaged app,
|
|
38933
|
+
* under the base cache path its own adapter computes (`~/Library/Caches` on
|
|
38934
|
+
* macOS). ONE key on ONE line is parsed rather than adding a YAML dependency
|
|
38935
|
+
* on a package that is a transitive dependency of the updater rather than
|
|
38936
|
+
* ours.
|
|
38937
|
+
*
|
|
38938
|
+
* A DELETION ROOT IS NEVER TAKEN FROM THE MARKER or any other mutable record:
|
|
38939
|
+
* a corrupted record would then choose what gets deleted. Null means
|
|
38940
|
+
* "declined to reap", never a guess.
|
|
38941
|
+
*/
|
|
38942
|
+
updaterCacheRoot() {
|
|
38943
|
+
const helper = this.downloadHelper();
|
|
38944
|
+
if (helper?.cacheDirForPendingUpdate) {
|
|
38945
|
+
return path$1.dirname(helper.cacheDirForPendingUpdate);
|
|
38946
|
+
}
|
|
38947
|
+
try {
|
|
38948
|
+
const config = fs.readFileSync(
|
|
38949
|
+
path$1.join(process.resourcesPath, "app-update.yml"),
|
|
38950
|
+
"utf8"
|
|
38951
|
+
);
|
|
38952
|
+
const name = config.match(APP_UPDATE_CACHE_DIR_NAME_REGEX)?.[1];
|
|
38953
|
+
if (!name || !CACHE_DIR_NAME_OK_REGEX.test(name)) return null;
|
|
38954
|
+
return path$1.join(electron.app.getPath("home"), "Library", "Caches", name);
|
|
38955
|
+
} catch {
|
|
38956
|
+
return null;
|
|
38957
|
+
}
|
|
38958
|
+
}
|
|
38959
|
+
/**
|
|
38960
|
+
* Reduce the updater's cache to at most ONE artifact's worth, once an install
|
|
38961
|
+
* is settled (2026-10-11).
|
|
38962
|
+
*
|
|
38963
|
+
* THE RETENTION RULE, and its reasoning. After every settled verdict the cache
|
|
38964
|
+
* is DEDUPED, not emptied: `update.zip` and `pending/<artifact>.zip` are the
|
|
38965
|
+
* same bytes, and only one of them has future value - `update.zip`, the
|
|
38966
|
+
* differential base the next download patches against (`MacUpdater` copies
|
|
38967
|
+
* every download to it, and falls back to a FULL download without it). So:
|
|
38968
|
+
* when `update.zip` exists, the pending artifact and the `update-info.json`
|
|
38969
|
+
* that describes it go (the bytes survive in the base), and when it does not,
|
|
38970
|
+
* nothing is deleted - there is nothing to dedupe, and the pending artifact is
|
|
38971
|
+
* the only copy. The retention window is bounded by the next download, which
|
|
38972
|
+
* overwrites the base in place; there is deliberately NO day-count constant,
|
|
38973
|
+
* because a clock cannot tell a live delta base from a dead one and would
|
|
38974
|
+
* either delete the base the next update needs or count forever on an idle
|
|
38975
|
+
* machine. ROLLBACK STORY: nothing here is a rollback artifact - `update.zip`
|
|
38976
|
+
* cannot be installed by itself, and the app's reinstall and recovery paths
|
|
38977
|
+
* re-fetch from the feed; the retained copy exists for the next update's
|
|
38978
|
+
* DELTA (worth ~180 MB of transfer), not for restoring anything older.
|
|
38979
|
+
*
|
|
38980
|
+
* SAFETY RAILS, re-asked at the point of deletion rather than trusted from the
|
|
38981
|
+
* caller: the three branches that call this have already decided the install
|
|
38982
|
+
* is not in flight, but the machine is asked ONCE more - a live marker, a
|
|
38983
|
+
* running installer, or a relaunch watchdog that is still OURS all mean
|
|
38984
|
+
* something else may still be reading this cache, and the reap stands down
|
|
38985
|
+
* with a log line. A registered-but-idle ShipIt job is NOT one of those
|
|
38986
|
+
* signals (the 0.17.0 lessons: the job outlives finished installs by design),
|
|
38987
|
+
* and the in-flight branch above NEVER calls here.
|
|
38988
|
+
*/
|
|
38989
|
+
reapUpdaterCache() {
|
|
38990
|
+
const note = (message) => logger.info(message, LogFileType.UPDATE_SERVICE);
|
|
38991
|
+
const marker = readPendingInstallMarker(this.markerDir());
|
|
38992
|
+
const liveness = this.installLiveness(marker);
|
|
38993
|
+
if (liveness.installerRunning || marker !== null && isInstallInFlight({
|
|
38994
|
+
marker,
|
|
38995
|
+
jobState: liveness.jobState,
|
|
38996
|
+
installerRunning: liveness.installerRunning
|
|
38997
|
+
})) {
|
|
38998
|
+
note(
|
|
38999
|
+
"Not reaping the updater cache: the machine still reports an install in flight."
|
|
39000
|
+
);
|
|
39001
|
+
return;
|
|
39002
|
+
}
|
|
39003
|
+
const watchdogPid = marker?.watchdogPid ?? null;
|
|
39004
|
+
if (watchdogPid != null) {
|
|
39005
|
+
const commandLine = readCommandOutput("/bin/ps", [
|
|
39006
|
+
"-o",
|
|
39007
|
+
"command=",
|
|
39008
|
+
"-p",
|
|
39009
|
+
String(watchdogPid)
|
|
39010
|
+
]);
|
|
39011
|
+
if (watchdogIsOurs({
|
|
39012
|
+
alive: commandLine != null,
|
|
39013
|
+
commandLine,
|
|
39014
|
+
installSucceeded: true
|
|
39015
|
+
})) {
|
|
39016
|
+
note(
|
|
39017
|
+
`Not reaping the updater cache while relaunch watchdog ${watchdogPid} is still running.`
|
|
39018
|
+
);
|
|
39019
|
+
return;
|
|
39020
|
+
}
|
|
39021
|
+
}
|
|
39022
|
+
const root = this.updaterCacheRoot();
|
|
39023
|
+
if (!root) {
|
|
39024
|
+
note("Updater cache not reaped: its directory could not be resolved.");
|
|
39025
|
+
return;
|
|
39026
|
+
}
|
|
39027
|
+
const base = path$1.join(root, "update.zip");
|
|
39028
|
+
if (!fs.existsSync(base)) {
|
|
39029
|
+
note(
|
|
39030
|
+
"Updater cache left alone: no differential base to dedupe the pending artifact against."
|
|
39031
|
+
);
|
|
39032
|
+
return;
|
|
39033
|
+
}
|
|
39034
|
+
const pendingDir = path$1.join(root, "pending");
|
|
39035
|
+
if (!fs.existsSync(pendingDir)) return;
|
|
39036
|
+
for (const entry of fs.readdirSync(pendingDir)) {
|
|
39037
|
+
if (!UPDATER_CACHE_PENDING_FILE_REGEX.test(entry) && entry !== "update-info.json")
|
|
39038
|
+
continue;
|
|
39039
|
+
const target2 = path$1.join(pendingDir, entry);
|
|
39040
|
+
try {
|
|
39041
|
+
fs.rmSync(target2, { force: true });
|
|
39042
|
+
note(
|
|
39043
|
+
`Reaped the updater cache's duplicate of the differential base: ${target2}.`
|
|
39044
|
+
);
|
|
39045
|
+
} catch (error) {
|
|
39046
|
+
logger.warn(
|
|
39047
|
+
`Could not reap ${target2}: ${String(error)}`,
|
|
39048
|
+
LogFileType.UPDATE_SERVICE
|
|
39049
|
+
);
|
|
39050
|
+
}
|
|
39051
|
+
}
|
|
39052
|
+
}
|
|
38731
39053
|
/**
|
|
38732
39054
|
* Both of the machine's liveness answers for this marker.
|
|
38733
39055
|
*
|
|
@@ -43859,6 +44181,7 @@ const backendInstaller = new BackendInstaller({
|
|
|
43859
44181
|
// (`./relaunch-pending` owns why scheduling happens only at the terminal).
|
|
43860
44182
|
onQuitCancelled: () => {
|
|
43861
44183
|
quitState.cancel();
|
|
44184
|
+
quitExitDeadline.release();
|
|
43862
44185
|
relaunchPending.clear();
|
|
43863
44186
|
}
|
|
43864
44187
|
});
|
|
@@ -44178,6 +44501,22 @@ electron.app.whenReady().then(async () => {
|
|
|
44178
44501
|
process.on(signal, () => endHeadlessRun(`received ${signal}`));
|
|
44179
44502
|
}
|
|
44180
44503
|
}
|
|
44504
|
+
if (windowLaunch.mode !== "headless") {
|
|
44505
|
+
const onSigtermDuringQuit = () => {
|
|
44506
|
+
if (!quitState.isQuitting()) {
|
|
44507
|
+
process.removeListener("SIGTERM", onSigtermDuringQuit);
|
|
44508
|
+
process.kill(process.pid, "SIGTERM");
|
|
44509
|
+
return;
|
|
44510
|
+
}
|
|
44511
|
+
logger.info(
|
|
44512
|
+
"Received SIGTERM during a quit; exiting so the quit reaches its terminal (an update installer may be waiting on this pid).",
|
|
44513
|
+
LogFileType.BACKEND
|
|
44514
|
+
);
|
|
44515
|
+
scheduleReopenAtQuitTerminal();
|
|
44516
|
+
electron.app.exit(0);
|
|
44517
|
+
};
|
|
44518
|
+
process.on("SIGTERM", onSigtermDuringQuit);
|
|
44519
|
+
}
|
|
44181
44520
|
electron.app.on("browser-window-created", (_, window) => {
|
|
44182
44521
|
utils.optimizer.watchWindowShortcuts(window);
|
|
44183
44522
|
});
|
|
@@ -45184,7 +45523,22 @@ const scheduleReopenAtQuitTerminal = () => {
|
|
|
45184
45523
|
}
|
|
45185
45524
|
});
|
|
45186
45525
|
};
|
|
45526
|
+
const QUIT_EXIT_DEADLINE_MS = quitExitDeadlineMs({
|
|
45527
|
+
quitCleanupFailsafeMs: QUIT_CLEANUP_FAILSAFE_MS
|
|
45528
|
+
});
|
|
45529
|
+
const quitExitDeadline = createQuitExitDeadline({
|
|
45530
|
+
timeoutMs: QUIT_EXIT_DEADLINE_MS,
|
|
45531
|
+
onFire: (firedInStage) => {
|
|
45532
|
+
logger.error(
|
|
45533
|
+
`The quit did not finish within ${QUIT_EXIT_DEADLINE_MS} ms (stalled ${firedInStage}); exiting with failure so the process cannot sit alive with no window`,
|
|
45534
|
+
LogFileType.BACKEND
|
|
45535
|
+
);
|
|
45536
|
+
scheduleReopenAtQuitTerminal();
|
|
45537
|
+
electron.app.exit(1);
|
|
45538
|
+
}
|
|
45539
|
+
});
|
|
45187
45540
|
electron.app.on("will-quit", (event) => {
|
|
45541
|
+
quitExitDeadline.phase("will-quit");
|
|
45188
45542
|
viewerEndpoint?.close();
|
|
45189
45543
|
viewerRecord?.stop();
|
|
45190
45544
|
scheduleReopenAtQuitTerminal();
|
|
@@ -45224,6 +45578,7 @@ const holdQuitForSessionCookieSnapshot = createSessionCookieQuitHold({
|
|
|
45224
45578
|
});
|
|
45225
45579
|
electron.app.on("before-quit", async (event) => {
|
|
45226
45580
|
quitState.begin();
|
|
45581
|
+
quitExitDeadline.arm("before the session-cookie hold");
|
|
45227
45582
|
electron.globalShortcut.unregisterAll();
|
|
45228
45583
|
miniViewRegistrar?.dispose();
|
|
45229
45584
|
quickSendWatcher?.stop();
|
|
@@ -45233,6 +45588,7 @@ electron.app.on("before-quit", async (event) => {
|
|
|
45233
45588
|
armHeadlessExitDeadline("the app is quitting");
|
|
45234
45589
|
}
|
|
45235
45590
|
if (await holdQuitForSessionCookieSnapshot(event)) return;
|
|
45591
|
+
quitExitDeadline.phase("after the session-cookie hold");
|
|
45236
45592
|
logger.info("App is about to quit", LogFileType.BACKEND);
|
|
45237
45593
|
if (!backendService.isOwnedCleanupComplete()) {
|
|
45238
45594
|
try {
|
|
@@ -45253,6 +45609,7 @@ electron.app.on("before-quit", async (event) => {
|
|
|
45253
45609
|
}
|
|
45254
45610
|
activeUpdateService?.quitForInFlightInstall("app quit");
|
|
45255
45611
|
void stopBrowserHost();
|
|
45612
|
+
quitExitDeadline.phase("awaiting the window close");
|
|
45256
45613
|
});
|
|
45257
45614
|
process.on("exit", () => {
|
|
45258
45615
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-operator-ui",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.12",
|
|
4
4
|
"productName": "Local Operator",
|
|
5
5
|
"description": "User interface for AI agent assistants that run Python code safely on your device through an intuitive chat interface. Supports local and cloud LLM models with built-in security features.",
|
|
6
6
|
"main": "./out/main/index.js",
|