diff --git a/apps/desktop/electron/handoff-result.test.ts b/apps/desktop/electron/handoff-result.test.ts index 4d6f7b1cfe..1bc08f0ea8 100644 --- a/apps/desktop/electron/handoff-result.test.ts +++ b/apps/desktop/electron/handoff-result.test.ts @@ -67,3 +67,24 @@ test('malformed JSON is consumed silently', () => { test('absent file returns null', () => { assert.equal(readAndConsumeHandoffResult(tempHome()), null) }) + +test('manual flag survives the round trip and defaults false', () => { + const home = tempHome() + write(home, { + ok: true, + exit_code: 0, + manual: true, + message: 'Update complete. Reopen Hermes to finish (it could not restart itself).', + branch: 'main', + finished_at: Math.floor(Date.now() / 1000) + }) + + const result = readAndConsumeHandoffResult(home) + + assert.ok(result) + assert.equal(result.ok, true) + assert.equal(result.manual, true) + + write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) }) + assert.equal(readAndConsumeHandoffResult(home)?.manual, false, 'older writers without the field parse as manual:false') +}) diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts index 1cfbfac55c..8e473ea086 100644 --- a/apps/desktop/electron/handoff-result.ts +++ b/apps/desktop/electron/handoff-result.ts @@ -19,6 +19,11 @@ export const HANDOFF_RESULT_MAX_AGE_MS = 30 * 60 * 1000 export interface HandoffResult { ok: boolean exitCode: number + /** Update succeeded but the user must act (reopen the app, reinstall the + * GUI package, fix the sandbox helper). The consumer must SURFACE these — + * an ok:true manual result that only gets logged never reaches the user + * on exactly the machines where no shim/notifier could show it live. */ + manual: boolean message: string branch: string } @@ -65,6 +70,7 @@ export function readAndConsumeHandoffResult( return { ok: Boolean(parsed?.ok), exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1, + manual: Boolean(parsed?.manual), message: typeof parsed?.message === 'string' ? parsed.message : '', branch: typeof parsed?.branch === 'string' ? parsed.branch : '' } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5fa1fac0cf..e0a5cc25e3 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1815,7 +1815,18 @@ async function waitForUpdateToFinish() { try { const result = readAndConsumeHandoffResult(HERMES_HOME) - if (result && result.ok) { + if (result && result.ok && result.manual) { + // Update landed but the user must act (reopen/reinstall/sandbox). On + // machines with no shim browser and no notifier this dialog is the + // FIRST time the message is visible — it must not be a log line. + rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`) + dialog.showMessageBox({ + type: 'warning', + title: 'Hermes update', + message: 'The update finished, but needs one more step', + detail: result.message + }) + } else if (result && result.ok) { rememberLog(`[updates] detached update finished OK (branch ${result.branch})`) } else if (result) { rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`) diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index 18d22c123e..80d2715dd8 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -80,11 +80,13 @@ json_escape() { # minimal JSON string escape: \ " and control whitespace } notify_fallback() { # status message — renderer-free recovery surface. - # Fires only when there is no shim window: a gated/failed outcome must - # never be a silent disappearance (gille rounds 2-3). Each rung FALLS - # THROUGH on execution failure (notify-send existing but unable to reach - # D-Bus must not eat the message), ending at osascript on mac — present - # on every macOS — and at a logged last resort everywhere. + # Fires only when there is no shim window. BEST-EFFORT immediate channel: + # each rung requires EXECUTION acceptance, not existence — notify-send's + # exit code is its acceptance (fire-and-forget), zenity/kdialog must + # survive their first second (a dialog that dies instantly had no display + # and must not eat the message). The GUARANTEED channel is the result + # file: a manual/error outcome is durably marked and the next Desktop + # boot surfaces it in a dialog (handoff-result.ts + main.ts). case "$1" in manual|error) ;; *) return 0 ;; esac if [ "$(uname)" = "Darwin" ]; then /usr/bin/osascript -e "display notification \"$(printf '%s' "$2" | sed 's/"/\\"/g')\" with title \"Hermes update\"" 2>/dev/null && return 0 @@ -92,16 +94,23 @@ notify_fallback() { # status message — renderer-free recovery surface. if command -v notify-send >/dev/null 2>&1; then notify-send -u critical "Hermes update" "$2" 2>/dev/null && return 0 fi + local p if command -v zenity >/dev/null 2>&1; then - (zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &) && return 0 + zenity --warning --title="Hermes update" --text="$2" 2>/dev/null & + p=$!; sleep 1 + kill -0 "$p" 2>/dev/null && return 0 + wait "$p" 2>/dev/null fi if command -v kdialog >/dev/null 2>&1; then - (kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &) && return 0 + kdialog --title "Hermes update" --sorry "$2" 2>/dev/null & + p=$!; sleep 1 + kill -0 "$p" 2>/dev/null && return 0 + wait "$p" 2>/dev/null fi fi - # Explicit contract for the no-surface case: the result file already - # tells the next boot the truth; log that this was the only channel. - log "NOTICE: no notification surface available; outcome reaches the user via the result file on next launch: $2" + # No immediate surface landed. The durable channel takes over: the result + # is marked manual/failed and the next boot shows it in a real dialog. + log "NOTICE: no notification surface accepted; outcome reaches the user via the result dialog on next launch: $2" } publish() { # status message -- atomic replace; the server reads per poll @@ -274,9 +283,12 @@ launch_app() { # attempted BEFORE the terminal event (launch acceptance is fi } +MANUAL=0 # 1 = update landed but the user must act (result protocol field) + write_result() { - printf '{"ok":%s,"exit_code":%s,"message":"%s","branch":"%s","finished_at":%s}' \ + printf '{"ok":%s,"exit_code":%s,"manual":%s,"message":"%s","branch":"%s","finished_at":%s}' \ "$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" \ + "$([ "$MANUAL" -eq 1 ] && echo true || echo false)" \ "$(json_escape "$FINAL_MSG")" "$(json_escape "$BRANCH")" "$(date +%s)" \ > "$RESULT.tmp" 2>/dev/null && mv -f "$RESULT.tmp" "$RESULT" 2>/dev/null || true } @@ -293,7 +305,7 @@ finish() { # A rejected launch rewrites the result (nothing consumed it — the app # never started) so the next boot tells the truth too. deliver_outcome - [ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && FINAL_MSG="$DONE_NOTE" + [ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && { FINAL_MSG="$DONE_NOTE"; MANUAL=1; } write_result if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then @@ -312,15 +324,21 @@ finish() { # mac DONE_NOTE = swap failed but the PREVIOUS bundle was kept/rolled # back — bring it back up; the note still tells the user to re-run. # A gated linux outcome (skew/manual) skips the launch BY DESIGN. - launch_app || true + if ! launch_app; then + # Even the kept bundle didn't come back: the durable message must + # carry BOTH facts (update ok, previous app not reopened). + FINAL_MSG="$DONE_NOTE Hermes also could not reopen itself - open it manually." + write_result + fi fi - publish "manual" "$DONE_NOTE"; stop_ui leave-window + publish "manual" "$FINAL_MSG"; stop_ui leave-window elif launch_app; then publish "done" ""; stop_ui else # Launch was due and did not land. Downgrade: truthful result for the # next boot, manual state held on screen now. FINAL_MSG="Update complete. Reopen Hermes to finish (it could not restart itself)." + MANUAL=1 write_result publish "manual" "$FINAL_MSG"; stop_ui leave-window fi diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh index db7ff43b23..f2d39a3bf8 100755 --- a/scripts/desktop-update/repro.sh +++ b/scripts/desktop-update/repro.sh @@ -159,13 +159,13 @@ case "$MODE" in if [ "$(uname)" != "Darwin" ]; then bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \ --relaunch-target "$UNPACKED/hermes" >/dev/null 2>&1 || true - expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']" + expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']" else # mac: a SUPPLIED target that is missing is a REJECTED launch and # must downgrade to manual — never a clean "Update complete." bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \ --relaunch-target "$L/NoSuch.app" >/dev/null 2>&1 || true - expect_msg "missing bundle downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']" + expect_msg "missing bundle downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']" fi # 2. gated skew: success result carries the skew message (the manual @@ -174,7 +174,7 @@ case "$MODE" in bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \ --relaunch-target /opt/Hermes/hermes >/dev/null 2>&1 || true if [ "$(uname)" != "Darwin" ]; then - expect_msg "skew outcome surfaces in result message" "d['ok']==True and 'was not changed' in d['message']" + expect_msg "skew outcome surfaces in result message" "d['ok']==True and d['manual']==True and 'was not changed' in d['message']" fi rm -rf "$L" diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index 682ca50fb0..1c81bdf114 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -409,13 +409,16 @@ function Close-ProgressWindow { } } -function Write-Result([bool]$Ok, [int]$Code, [string]$Message) { +function Write-Result([bool]$Ok, [int]$Code, [string]$Message, [bool]$ManualAction = $false) { # Consumed (read + deleted) by the relaunched Desktop on boot so the - # user actually SEES how a detached update ended. + # user actually SEES how a detached update ended. $ManualAction marks an + # ok result the user still must act on -- the Desktop surfaces those in + # a dialog, not just the log (same protocol as posix.sh). try { $obj = @{ ok = $Ok exit_code = $Code + manual = $ManualAction message = $Message branch = $Branch finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) @@ -733,7 +736,7 @@ try { # Launch was due and did not verifiably land: truthful result # for the next boot, manual state held on screen now. $finalMsg = "Update complete. Reopen Hermes to finish (it could not restart itself)." - Write-Result $true 0 $finalMsg + Write-Result $true 0 $finalMsg $true Show-ManualFinale $finalMsg } Close-ProgressWindow