livedesk 0.1.607 → 0.1.609

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.
@@ -0,0 +1,183 @@
1
+ export const DEFAULT_INSTALLER_QUIT_CONFIRMATION_MS = 1_000;
2
+
3
+ export const InstallerQuitDecision = Object.freeze({
4
+ NORMAL: 'normal-quit',
5
+ HOLD: 'hold-update-quit',
6
+ BLOCK: 'block-stale-update-quit',
7
+ ALLOW: 'allow-update-quit'
8
+ });
9
+
10
+ function normalizeDelay(value) {
11
+ const milliseconds = Number(value);
12
+ return Number.isFinite(milliseconds) && milliseconds >= 0
13
+ ? Math.round(milliseconds)
14
+ : DEFAULT_INSTALLER_QUIT_CONFIRMATION_MS;
15
+ }
16
+
17
+ /**
18
+ * electron-updater reports that it intends to quit before the Windows NSIS
19
+ * spawn promise has necessarily rejected. This gate holds the first quit for
20
+ * a short error window without importing Electron, so its race is executable
21
+ * in a plain Node regression test.
22
+ */
23
+ export function createInstallerQuitGate({
24
+ requestQuit,
25
+ confirmationDelayMs = DEFAULT_INSTALLER_QUIT_CONFIRMATION_MS,
26
+ setTimer = setTimeout,
27
+ clearTimer = clearTimeout
28
+ } = {}) {
29
+ if (typeof requestQuit !== 'function') {
30
+ throw new TypeError('installer-quit-gate-request-quit-required');
31
+ }
32
+
33
+ const confirmationDelay = normalizeDelay(confirmationDelayMs);
34
+ let generation = 0;
35
+ let attempt = null;
36
+
37
+ const clearConfirmationTimer = ownedAttempt => {
38
+ if (!ownedAttempt || ownedAttempt.confirmationTimer === null) return;
39
+ clearTimer(ownedAttempt.confirmationTimer);
40
+ ownedAttempt.confirmationTimer = null;
41
+ };
42
+
43
+ const snapshot = () => {
44
+ if (!attempt) {
45
+ return {
46
+ generation,
47
+ state: 'idle',
48
+ intentObserved: false,
49
+ failed: false,
50
+ firstQuitHeld: false,
51
+ quitReissueRequested: false,
52
+ staleQuitBlocked: false,
53
+ error: ''
54
+ };
55
+ }
56
+ return {
57
+ generation: attempt.generation,
58
+ state: attempt.state,
59
+ intentObserved: attempt.intentObserved,
60
+ failed: attempt.failed,
61
+ firstQuitHeld: attempt.firstQuitHeld,
62
+ quitReissueRequested: attempt.quitReissueRequested,
63
+ staleQuitBlocked: attempt.staleQuitBlocked,
64
+ error: attempt.error
65
+ };
66
+ };
67
+
68
+ const beginInstall = () => {
69
+ clearConfirmationTimer(attempt);
70
+ attempt = {
71
+ generation: ++generation,
72
+ state: 'preparing',
73
+ intentObserved: false,
74
+ failed: false,
75
+ firstQuitHeld: false,
76
+ quitReissueRequested: false,
77
+ staleQuitBlocked: false,
78
+ error: '',
79
+ confirmationTimer: null
80
+ };
81
+ return attempt.generation;
82
+ };
83
+
84
+ const noteBeforeQuitForUpdate = () => {
85
+ if (!attempt) return false;
86
+ attempt.intentObserved = true;
87
+ if (attempt.failed) {
88
+ attempt.state = 'failed-update-quit-intent';
89
+ } else if (!attempt.firstQuitHeld) {
90
+ attempt.state = 'update-quit-intent';
91
+ }
92
+ return true;
93
+ };
94
+
95
+ const failInstall = error => {
96
+ if (!attempt) return false;
97
+ attempt.failed = true;
98
+ attempt.error = String(error?.message || error || 'desktop-update-installer-launch-failed');
99
+ clearConfirmationTimer(attempt);
100
+
101
+ if (attempt.firstQuitHeld && !attempt.quitReissueRequested) {
102
+ // The updater-owned quit was already intercepted. There is no later quit
103
+ // to consume, so a user's subsequent normal quit must remain available.
104
+ attempt.intentObserved = false;
105
+ attempt.state = 'failed-after-held-quit';
106
+ } else if (attempt.intentObserved) {
107
+ // The updater has announced a quit, or our delayed reissue is in flight.
108
+ // Consume that one stale quit before returning to normal quit handling.
109
+ attempt.state = 'failed-update-quit-pending';
110
+ } else {
111
+ // electron-updater can dispatch its spawn error before its queued
112
+ // before-quit-for-update event. Retain the failed attempt for that event.
113
+ attempt.state = 'failed-awaiting-update-quit-intent';
114
+ }
115
+ return true;
116
+ };
117
+
118
+ const scheduleConfirmedQuit = ownedAttempt => {
119
+ if (ownedAttempt.confirmationTimer !== null) return;
120
+ const fire = () => {
121
+ ownedAttempt.confirmationTimer = null;
122
+ if (
123
+ attempt !== ownedAttempt
124
+ || ownedAttempt.failed
125
+ || !ownedAttempt.intentObserved
126
+ || ownedAttempt.quitReissueRequested
127
+ ) {
128
+ return;
129
+ }
130
+ ownedAttempt.quitReissueRequested = true;
131
+ ownedAttempt.state = 'reissuing-update-quit';
132
+ try {
133
+ requestQuit();
134
+ } catch (error) {
135
+ ownedAttempt.failed = true;
136
+ ownedAttempt.intentObserved = false;
137
+ ownedAttempt.error = String(error?.message || error);
138
+ ownedAttempt.state = 'quit-reissue-failed';
139
+ }
140
+ };
141
+ ownedAttempt.confirmationTimer = setTimer(fire, confirmationDelay);
142
+ ownedAttempt.confirmationTimer?.unref?.();
143
+ };
144
+
145
+ const handleBeforeQuit = () => {
146
+ const ownedAttempt = attempt;
147
+ if (!ownedAttempt?.intentObserved) return InstallerQuitDecision.NORMAL;
148
+
149
+ if (ownedAttempt.failed) {
150
+ ownedAttempt.intentObserved = false;
151
+ ownedAttempt.staleQuitBlocked = true;
152
+ ownedAttempt.state = 'failed-update-quit-blocked';
153
+ return InstallerQuitDecision.BLOCK;
154
+ }
155
+
156
+ if (ownedAttempt.quitReissueRequested) {
157
+ ownedAttempt.intentObserved = false;
158
+ ownedAttempt.state = 'update-quit-allowed';
159
+ return InstallerQuitDecision.ALLOW;
160
+ }
161
+
162
+ if (!ownedAttempt.firstQuitHeld) {
163
+ ownedAttempt.firstQuitHeld = true;
164
+ ownedAttempt.state = 'holding-update-quit';
165
+ scheduleConfirmedQuit(ownedAttempt);
166
+ }
167
+ return InstallerQuitDecision.HOLD;
168
+ };
169
+
170
+ const dispose = () => {
171
+ clearConfirmationTimer(attempt);
172
+ attempt = null;
173
+ };
174
+
175
+ return {
176
+ beginInstall,
177
+ noteBeforeQuitForUpdate,
178
+ failInstall,
179
+ handleBeforeQuit,
180
+ getState: snapshot,
181
+ dispose
182
+ };
183
+ }
package/electron/main.mjs CHANGED
@@ -1,11 +1,12 @@
1
- import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, powerMonitor, safeStorage, shell, Tray } from 'electron';
1
+ import { app, autoUpdater as electronAutoUpdater, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, powerMonitor, safeStorage, shell, Tray } from 'electron';
2
2
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { createRequire } from 'node:module';
7
- import { createProductUpdateManager } from './update-manager.mjs';
8
- import { createRecurringProductUpdateOwner } from './recurring-update-owner.mjs';
7
+ import { createProductUpdateManager } from './update-manager.mjs';
8
+ import { createRecurringProductUpdateOwner } from './recurring-update-owner.mjs';
9
+ import { createInstallerQuitGate, InstallerQuitDecision } from './installer-quit-gate.mjs';
9
10
  import { AUTH_REQUEST_TIMEOUT_MS, createPkceAuthorizationUrl, exchangePkceCode, fetchSupabaseUser, parsePkceCallbackUrl, refreshSupabaseSession } from './oauth-pkce.mjs';
10
11
  import { getRuntimeAuthEnvironment, resolveDesktopAuthConfig, resolveDesktopBuildFlavor } from './auth-config.mjs';
11
12
  import { createBoundedDesktopLogger } from './bounded-desktop-log.mjs';
@@ -40,11 +41,12 @@ import {
40
41
  waitForOwnedRuntimeTreeExit,
41
42
  waitForPromiseBeforeDeadline
42
43
  } from './runtime-process-owner.mjs';
43
- import {
44
- desktopHubTakeoverPublicationConfirmed,
45
- resolveDesktopHubTakeoverPending
46
- } from './runtime-role-transition.mjs';
47
- import { createDesktopClipboardOwner } from './desktop-clipboard-owner.mjs';
44
+ import {
45
+ desktopHubTakeoverPublicationConfirmed,
46
+ resolveDesktopHubTakeoverPending
47
+ } from './runtime-role-transition.mjs';
48
+ import { createDesktopClipboardOwner } from './desktop-clipboard-owner.mjs';
49
+ import { createRuntimeWindowLoadOwner } from './runtime-window-load-owner.mjs';
48
50
 
49
51
  // Chromium otherwise leaves an out-of-process Audio Service (~80-90 MiB on
50
52
  // Windows) alive after the exact Remote Audio owner, AudioContext, worklet,
@@ -155,10 +157,10 @@ let runtimeRestartWindowStartedAt = 0;
155
157
  let runtimeRestartTimer = null;
156
158
  let runtimeRestartPromise = null;
157
159
  let runtimeLaunchStartedAt = 0;
158
- let runtimeRoleTakeoverPending = process.env.LIVEDESK_ROLE_TAKEOVER === '1';
159
- let quitSequenceStarted = false;
160
- let updateInstallSequenceStarted = false;
161
- let productUpdates = null;
160
+ let runtimeRoleTakeoverPending = process.env.LIVEDESK_ROLE_TAKEOVER === '1';
161
+ let quitSequenceStarted = false;
162
+ let updateInstallSequenceStarted = false;
163
+ let productUpdates = null;
162
164
  let recurringProductUpdates = null;
163
165
  let pendingOAuth = null;
164
166
  let pendingProtocolUrl = '';
@@ -167,11 +169,34 @@ let desktopAuthRefreshTimer = null;
167
169
  let desktopSessionRestorePromise = null;
168
170
  let lastDesktopResumeRecoveryAt = 0;
169
171
  let desktopSessionEpoch = 0;
170
- let lastKnownDesktopSession = null;
171
- let desktopClipboardOwner = null;
172
- const desktopAuthInvalidationGuard = createDesktopAuthInvalidationGuard();
173
- const FALLBACK_ICON_DATA_URL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"><rect x="1" y="1" width="30" height="30" rx="7" fill="#111827"/><g transform="translate(5 5) scale(.785714)"><rect x="2.5" y="4" width="14" height="9" rx="1.5" stroke="#F8FAFC" stroke-width="1.7" opacity=".42"/><path d="M7 16v1.5h5" stroke="#F8FAFC" stroke-width="1.7" stroke-linecap="round" opacity=".42"/><rect x="10.5" y="2.5" width="14" height="9" rx="1.5" stroke="#F8FAFC" stroke-width="1.7" opacity=".62"/><path d="M15 14.5V16h5" stroke="#F8FAFC" stroke-width="1.7" stroke-linecap="round" opacity=".62"/><rect x="6.5" y="8.5" width="15" height="10" rx="1.7" fill="#F8FAFC" fill-opacity=".12" stroke="#F8FAFC" stroke-width="1.9"/><path d="M12 21v1.5H9.5M16 21v1.5h2.5M9.5 22.5h9" stroke="#F8FAFC" stroke-width="1.9" stroke-linecap="round"/></g></svg>')}`;
174
- const desktopLogger = createBoundedDesktopLogger({ logPath });
172
+ let lastKnownDesktopSession = null;
173
+ let desktopClipboardOwner = null;
174
+ let runtimeWindowLoadOwner = null;
175
+ const desktopAuthInvalidationGuard = createDesktopAuthInvalidationGuard();
176
+ const installerQuitGate = createInstallerQuitGate({
177
+ requestQuit: () => app.quit()
178
+ });
179
+ const FALLBACK_ICON_DATA_URL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"><rect x="1" y="1" width="30" height="30" rx="7" fill="#111827"/><g transform="translate(5 5) scale(.785714)"><rect x="2.5" y="4" width="14" height="9" rx="1.5" stroke="#F8FAFC" stroke-width="1.7" opacity=".42"/><path d="M7 16v1.5h5" stroke="#F8FAFC" stroke-width="1.7" stroke-linecap="round" opacity=".42"/><rect x="10.5" y="2.5" width="14" height="9" rx="1.5" stroke="#F8FAFC" stroke-width="1.7" opacity=".62"/><path d="M15 14.5V16h5" stroke="#F8FAFC" stroke-width="1.7" stroke-linecap="round" opacity=".62"/><rect x="6.5" y="8.5" width="15" height="10" rx="1.7" fill="#F8FAFC" fill-opacity=".12" stroke="#F8FAFC" stroke-width="1.9"/><path d="M12 21v1.5H9.5M16 21v1.5h2.5M9.5 22.5h9" stroke="#F8FAFC" stroke-width="1.9" stroke-linecap="round"/></g></svg>')}`;
180
+ const DESKTOP_RUNTIME_RECOVERY_PAGE_URL = `data:text/html;charset=utf-8,${encodeURIComponent(`<!doctype html>
181
+ <html lang="en">
182
+ <head>
183
+ <meta charset="utf-8">
184
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
185
+ <meta name="viewport" content="width=device-width,initial-scale=1">
186
+ <title>VuvoDesk Desktop</title>
187
+ <style>
188
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
189
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #f8fafc; }
190
+ main { display: grid; justify-items: center; gap: 18px; padding: 32px; text-align: center; }
191
+ .mark { width: 42px; height: 42px; border: 3px solid #334155; border-top-color: #38bdf8; border-radius: 999px; animation: spin .9s linear infinite; }
192
+ h1 { margin: 0; font-size: 22px; font-weight: 650; }
193
+ p { margin: 0; max-width: 480px; color: #cbd5e1; font-size: 15px; line-height: 1.65; }
194
+ @keyframes spin { to { transform: rotate(360deg); } }
195
+ </style>
196
+ </head>
197
+ <body><main><div class="mark" aria-hidden="true"></div><h1>VuvoDesk is reconnecting</h1><p>The service on this computer is starting. This window will reconnect automatically.</p></main></body>
198
+ </html>`)}`;
199
+ const desktopLogger = createBoundedDesktopLogger({ logPath });
175
200
 
176
201
  function mask(value) {
177
202
  return String(value ?? '')
@@ -596,9 +621,11 @@ async function waitForRuntime(options = {}) {
596
621
  await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(200, remainingMs)));
597
622
  }
598
623
  }
599
- log(`runtime identity check timed out; expectedRole=${expectedRole || 'any'}`);
600
- return null;
601
- }
624
+ if (config.logTimeout !== false) {
625
+ log(`runtime identity check timed out; expectedRole=${expectedRole || 'any'}`);
626
+ }
627
+ return null;
628
+ }
602
629
 
603
630
  async function stopRuntime() {
604
631
  if (runtimeRestartTimer) {
@@ -615,12 +642,18 @@ async function stopRuntime() {
615
642
  child.livedeskRuntimeStopDeadline = deadline;
616
643
  const operation = (async () => {
617
644
  await requestRuntimeShutdown(deadline);
618
- const gracefulDeadline = Math.max(
619
- Date.now(),
620
- deadline - DEFAULT_RUNTIME_FORCED_CLEANUP_RESERVE_MS
621
- );
622
- await waitForOwnedRuntimeTreeExit(child, { deadline: gracefulDeadline });
623
- await drainRuntimeTreeUntilStopped(child, deadline);
645
+ const gracefulDeadline = Math.max(
646
+ Date.now(),
647
+ deadline - DEFAULT_RUNTIME_FORCED_CLEANUP_RESERVE_MS
648
+ );
649
+ try {
650
+ await waitForOwnedRuntimeTreeExit(child, { deadline: gracefulDeadline });
651
+ } catch (error) {
652
+ // Preserve the forced-cleanup reserve. A stale ChildProcess terminal
653
+ // notification must not prevent the exact registered tree drain below.
654
+ log(`runtime graceful exit wait expired; forcing exact drain: ${error?.message || error}`);
655
+ }
656
+ await drainRuntimeTreeUntilStopped(child, deadline);
624
657
  if (runtimeChild === child) runtimeChild = null;
625
658
  return true;
626
659
  })();
@@ -669,32 +702,56 @@ function requestRuntimeRestart(source = 'manual') {
669
702
  async function prepareRuntimeForUpdateInstall() {
670
703
  if (updateInstallSequenceStarted) {
671
704
  throw new Error('desktop-update-install-already-started');
672
- }
673
- updateInstallSequenceStarted = true;
705
+ }
706
+ updateInstallSequenceStarted = true;
707
+ installerQuitGate.beginInstall();
674
708
  isQuitting = true;
675
709
  recurringProductUpdates?.stop();
676
710
  try {
677
711
  await quiesceDesktopAuthOwner('desktop-update-install');
678
712
  await stopRuntime();
679
713
  } catch (error) {
714
+ installerQuitGate.failInstall(error);
680
715
  updateInstallSequenceStarted = false;
681
716
  isQuitting = false;
682
717
  scheduleDesktopAuthRefresh(readEncryptedSession());
718
+ let recoveryError = null;
719
+ try {
720
+ const ready = await restartRuntimeProcess();
721
+ if (!ready) throw new Error('desktop-update-runtime-recovery-not-ready');
722
+ } catch (cause) {
723
+ recoveryError = cause;
724
+ log(`desktop update preparation recovery failed: ${cause?.message || cause}`);
725
+ }
726
+ if (!recoveryError) {
727
+ recurringProductUpdates?.start();
728
+ }
729
+ if (recoveryError) {
730
+ throw new Error(
731
+ `desktop-update-preinstall-recovery-failed:${recoveryError?.message || recoveryError}`,
732
+ { cause: error }
733
+ );
734
+ }
683
735
  throw error;
684
736
  }
685
- }
686
-
687
- async function recoverRuntimeAfterUpdateInstallError(error) {
688
- log(`desktop update install launch failed after runtime drain: ${error?.message || error}`);
737
+ }
738
+
739
+ async function recoverRuntimeAfterUpdateInstallError(error) {
740
+ // electron-updater can report a Windows installer spawn failure just before
741
+ // its queued app.quit(). Seal that exact update quit before restarting.
742
+ installerQuitGate.failInstall(error);
743
+ log(`desktop update install launch failed after runtime drain: ${error?.message || error}`);
689
744
  updateInstallSequenceStarted = false;
690
745
  isQuitting = false;
691
746
  scheduleDesktopAuthRefresh(readEncryptedSession());
692
- if (!runtimeChild) {
693
- startRuntime();
694
- const ready = await waitForRuntime();
695
- if (ready) mainWindow?.loadURL(LOCAL_RUNTIME_URL);
696
- }
697
- }
747
+ if (!runtimeChild) startRuntime();
748
+ const ready = await waitForRuntime();
749
+ if (!ready) {
750
+ throw new Error('desktop-update-runtime-recovery-not-ready', { cause: error });
751
+ }
752
+ mainWindow?.loadURL(LOCAL_RUNTIME_URL);
753
+ recurringProductUpdates?.start();
754
+ }
698
755
 
699
756
  async function syncSessionToRuntime(session) {
700
757
  const normalized = normalizeSession(session);
@@ -1022,25 +1079,28 @@ function updateTray(status = 'Starting') {
1022
1079
  tray.setToolTip(`${APP_NAME} — ${status}`);
1023
1080
  }
1024
1081
 
1025
- function showWindow() {
1026
- if (!mainWindow) return;
1027
- if (mainWindow.isMinimized()) mainWindow.restore();
1028
- mainWindow.show();
1082
+ function showWindow() {
1083
+ if (!mainWindow) return;
1084
+ runtimeWindowLoadOwner?.start('window-show');
1085
+ if (mainWindow.isMinimized()) mainWindow.restore();
1086
+ mainWindow.show();
1029
1087
  mainWindow.focus();
1030
1088
  if (!mainWindow.webContents.isDestroyed()) mainWindow.webContents.invalidate();
1031
1089
  requestDesktopResumeAuthRecovery('window-show');
1032
1090
  }
1033
1091
 
1034
- function createMainWindow() {
1035
- const iconPath = appResource('electron', 'icon.png');
1036
- Menu.setApplicationMenu(null);
1037
- mainWindow = new BrowserWindow({
1038
- width: 1440,
1039
- height: 960,
1040
- minWidth: 1080,
1041
- minHeight: 720,
1042
- show: false,
1043
- autoHideMenuBar: true,
1092
+ function createMainWindow() {
1093
+ const iconPath = appResource('electron', 'icon.png');
1094
+ Menu.setApplicationMenu(null);
1095
+ runtimeWindowLoadOwner?.stop();
1096
+ const window = new BrowserWindow({
1097
+ width: 1440,
1098
+ height: 960,
1099
+ minWidth: 1080,
1100
+ minHeight: 720,
1101
+ show: false,
1102
+ backgroundColor: '#0f172a',
1103
+ autoHideMenuBar: true,
1044
1104
  title: appWindowTitle(),
1045
1105
  icon: existsSync(iconPath) ? iconPath : nativeImage.createFromDataURL(FALLBACK_ICON_DATA_URL),
1046
1106
  webPreferences: {
@@ -1048,41 +1108,89 @@ function createMainWindow() {
1048
1108
  contextIsolation: true,
1049
1109
  nodeIntegration: false,
1050
1110
  sandbox: true,
1051
- spellcheck: false
1052
- }
1053
- });
1054
- mainWindow.on('page-title-updated', event => {
1055
- event.preventDefault();
1056
- mainWindow?.setTitle(appWindowTitle());
1057
- });
1058
- mainWindow.on('focus', () => requestDesktopResumeAuthRecovery('window-focus'));
1059
- mainWindow.webContents.on('will-navigate', (event, url) => {
1060
- if (isAllowedRuntimeUrl(url)) return;
1061
- event.preventDefault();
1062
- void shell.openExternal(url);
1063
- });
1064
- mainWindow.webContents.setWindowOpenHandler(({ url }) => {
1065
- if (isAllowedRuntimeUrl(url)) return { action: 'allow' };
1066
- void shell.openExternal(url);
1067
- return { action: 'deny' };
1068
- });
1069
- mainWindow.loadURL(LOCAL_RUNTIME_URL);
1070
- mainWindow.webContents.on('did-finish-load', () => {
1071
- void restoreEncryptedSession({ syncRuntime: true }).then(session => {
1072
- if (desktopSessionIsUsable(session)) sendAuthEvent({ ok: true, session, restored: true });
1111
+ spellcheck: false
1112
+ }
1113
+ });
1114
+ mainWindow = window;
1115
+ let loadFailureCount = 0;
1116
+ const reportWindowLoadFailure = (message, context = {}) => {
1117
+ loadFailureCount += 1;
1118
+ if (loadFailureCount === 1 || loadFailureCount % 10 === 0) {
1119
+ log(`desktop renderer recovery ${message}; failures=${loadFailureCount}; generation=${context.generation || 'event'}; attempt=${context.attempt || 'event'}`);
1120
+ }
1121
+ };
1122
+ const windowLoadOwner = createRuntimeWindowLoadOwner({
1123
+ probeRuntime: () => waitForRuntime({ timeoutMs: 1_000, logTimeout: false }),
1124
+ showRecovery: async () => {
1125
+ if (window.isDestroyed() || window.webContents.isDestroyed()) return;
1126
+ if (window.webContents.getURL() !== DESKTOP_RUNTIME_RECOVERY_PAGE_URL) {
1127
+ await window.loadURL(DESKTOP_RUNTIME_RECOVERY_PAGE_URL);
1128
+ }
1129
+ if (process.env.LIVEDESK_E2E_HEADLESS !== '1') showWindow();
1130
+ },
1131
+ loadRuntime: async () => {
1132
+ if (window.isDestroyed() || window.webContents.isDestroyed()) {
1133
+ throw new Error('desktop-runtime-window-destroyed');
1134
+ }
1135
+ await window.loadURL(LOCAL_RUNTIME_URL);
1136
+ },
1137
+ onLoaded: context => {
1138
+ loadFailureCount = 0;
1139
+ updateTray('Running');
1140
+ log(`desktop renderer loaded current runtime; generation=${context.generation}; attempt=${context.attempt}`);
1141
+ if (process.env.LIVEDESK_E2E_HEADLESS !== '1') showWindow();
1142
+ },
1143
+ onAttemptError: (error, context) => {
1144
+ reportWindowLoadFailure(`${context.phase}: ${error?.message || error}`, context);
1145
+ },
1146
+ retryDelayMs: 1_000
1147
+ });
1148
+ runtimeWindowLoadOwner = windowLoadOwner;
1149
+ window.on('page-title-updated', event => {
1150
+ event.preventDefault();
1151
+ if (!window.isDestroyed()) window.setTitle(appWindowTitle());
1152
+ });
1153
+ window.on('focus', () => requestDesktopResumeAuthRecovery('window-focus'));
1154
+ window.webContents.on('will-navigate', (event, url) => {
1155
+ if (isAllowedRuntimeUrl(url) || url === DESKTOP_RUNTIME_RECOVERY_PAGE_URL) return;
1156
+ event.preventDefault();
1157
+ void shell.openExternal(url);
1158
+ });
1159
+ window.webContents.setWindowOpenHandler(({ url }) => {
1160
+ if (isAllowedRuntimeUrl(url)) return { action: 'allow' };
1161
+ void shell.openExternal(url);
1162
+ return { action: 'deny' };
1163
+ });
1164
+ window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedUrl, isMainFrame) => {
1165
+ if (!isMainFrame || errorCode === -3) return;
1166
+ reportWindowLoadFailure(`main-frame-failed code=${errorCode} url=${validatedUrl || 'unknown'} error=${errorDescription || 'unknown'}`);
1167
+ windowLoadOwner.noteMainFrameFailure(`main-frame-${errorCode}`);
1168
+ });
1169
+ window.webContents.on('render-process-gone', (_event, details) => {
1170
+ reportWindowLoadFailure(`renderer-gone reason=${details?.reason || 'unknown'} exitCode=${details?.exitCode ?? 'unknown'}`);
1171
+ windowLoadOwner.noteRendererGone(`renderer-${details?.reason || 'gone'}`);
1172
+ });
1173
+ window.webContents.on('did-finish-load', () => {
1174
+ if (!isAllowedRuntimeUrl(window.webContents.getURL())) return;
1175
+ void restoreEncryptedSession({ syncRuntime: true }).then(session => {
1176
+ if (desktopSessionIsUsable(session)) sendAuthEvent({ ok: true, session, restored: true });
1073
1177
  }).catch(error => {
1074
- log(`secure auth session window restore failed: ${error?.message || error}`);
1075
- });
1076
- });
1077
- mainWindow.once('ready-to-show', () => { updateTray('Running'); if (process.env.LIVEDESK_E2E_HEADLESS !== '1') showWindow(); });
1078
- mainWindow.on('close', event => {
1079
- if (!isQuitting) {
1080
- event.preventDefault();
1081
- mainWindow.hide();
1082
- }
1083
- });
1084
- mainWindow.on('closed', () => { mainWindow = null; });
1085
- }
1178
+ log(`secure auth session window restore failed: ${error?.message || error}`);
1179
+ });
1180
+ });
1181
+ windowLoadOwner.start('window-created');
1182
+ window.on('close', event => {
1183
+ if (!isQuitting) {
1184
+ event.preventDefault();
1185
+ window.hide();
1186
+ }
1187
+ });
1188
+ window.on('closed', () => {
1189
+ windowLoadOwner.stop();
1190
+ if (runtimeWindowLoadOwner === windowLoadOwner) runtimeWindowLoadOwner = null;
1191
+ if (mainWindow === window) mainWindow = null;
1192
+ });
1193
+ }
1086
1194
 
1087
1195
  function registerIpc() {
1088
1196
  const handle = (channel, handler) => ipcMain.handle(channel, async (event, ...args) => {
@@ -1221,12 +1329,18 @@ function createTray() {
1221
1329
  updateTray('Starting');
1222
1330
  }
1223
1331
 
1224
- app.on('open-url', (event, url) => {
1332
+ app.on('open-url', (event, url) => {
1225
1333
  event.preventDefault();
1226
1334
  if (!isLiveDeskAuthCallbackUrl(url)) return;
1227
1335
  if (app.isReady()) void handleProtocolUrl(url);
1228
1336
  else pendingProtocolUrl = url;
1229
- });
1337
+ });
1338
+
1339
+ electronAutoUpdater.on('before-quit-for-update', () => {
1340
+ // On Windows this is updater intent, not proof that the asynchronous NSIS
1341
+ // spawn succeeded. The first updater-owned quit is confirmed below.
1342
+ installerQuitGate.noteBeforeQuitForUpdate();
1343
+ });
1230
1344
 
1231
1345
  if (!app.requestSingleInstanceLock()) {
1232
1346
  app.quit();
@@ -1237,19 +1351,43 @@ if (!app.requestSingleInstanceLock()) {
1237
1351
  showWindow();
1238
1352
  });
1239
1353
  app.whenReady().then(async () => {
1240
- app.setAsDefaultProtocolClient('livedesk');
1241
- removeLegacyStartupEntries();
1242
- migrateLegacyPlaintextSession();
1243
- productUpdates = createProductUpdateManager({
1244
- app,
1245
- onStatus: status => {
1354
+ app.setAsDefaultProtocolClient('livedesk');
1355
+ removeLegacyStartupEntries();
1356
+ migrateLegacyPlaintextSession();
1357
+ let lastProductUpdateLogSignature = '';
1358
+ productUpdates = createProductUpdateManager({
1359
+ app,
1360
+ onStatus: status => {
1246
1361
  if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
1247
1362
  mainWindow.webContents.send('updates:status', status);
1248
1363
  }
1249
- if (status.state === 'available') updateTray(`Update ${status.availableVersion} available`);
1250
- else if (status.state === 'downloading') updateTray(`Downloading update ${Math.round(status.progress || 0)}%`);
1251
- else if (status.state === 'downloaded') updateTray(`Update ${status.availableVersion} ready`);
1252
- },
1364
+ if (status.state === 'available') updateTray(`Update ${status.availableVersion} available`);
1365
+ else if (status.state === 'downloading') updateTray(`Downloading update ${Math.round(status.progress || 0)}%`);
1366
+ else if (status.state === 'downloaded') updateTray(`Update ${status.availableVersion} ready`);
1367
+ const progressBucket = Math.floor(Math.max(0, Number(status.progress || 0)) / 10) * 10;
1368
+ const signature = [
1369
+ status.state,
1370
+ status.channel,
1371
+ status.currentVersion,
1372
+ status.availableVersion,
1373
+ status.downloadedVersion,
1374
+ progressBucket,
1375
+ status.cleanupPending,
1376
+ status.reopenRequired,
1377
+ status.errorCode,
1378
+ status.error,
1379
+ status.lastCheckStartedAt,
1380
+ status.lastCheckCompletedAt,
1381
+ status.startupCheck
1382
+ ].join('|');
1383
+ if (signature !== lastProductUpdateLogSignature) {
1384
+ lastProductUpdateLogSignature = signature;
1385
+ log(
1386
+ `desktop update state=${status.state || 'unknown'} channel=${status.channel || 'unknown'} current=${status.currentVersion || 'unknown'} available=${status.availableVersion || 'none'} downloaded=${status.downloadedVersion || 'none'} progress=${progressBucket} cleanupPending=${Boolean(status.cleanupPending)} reopenRequired=${Boolean(status.reopenRequired)} feed=${status.feedUrl || 'none'} checkStarted=${status.lastCheckStartedAt || 'none'} checkCompleted=${status.lastCheckCompletedAt || 'none'} errorCode=${status.errorCode || 'none'} error=${status.error || 'none'}`,
1387
+ { priority: status.state === 'error' || status.state === 'installing' ? 'normal' : 'low' }
1388
+ );
1389
+ }
1390
+ },
1253
1391
  beforeInstall: prepareRuntimeForUpdateInstall,
1254
1392
  onInstallError: recoverRuntimeAfterUpdateInstallError
1255
1393
  });
@@ -1298,10 +1436,17 @@ if (!app.requestSingleInstanceLock()) {
1298
1436
  pendingProtocolUrl = '';
1299
1437
  if (initialProtocolUrl) void handleProtocolUrl(initialProtocolUrl);
1300
1438
  });
1301
- app.on('window-all-closed', () => { /* tray-owned desktop stays alive */ });
1302
- app.on('before-quit', event => {
1303
- if (quitSequenceStarted) return;
1304
- event.preventDefault();
1439
+ app.on('window-all-closed', () => { /* tray-owned desktop stays alive */ });
1440
+ app.on('before-quit', event => {
1441
+ if (quitSequenceStarted) return;
1442
+ const installerQuitDecision = installerQuitGate.handleBeforeQuit();
1443
+ if (installerQuitDecision === InstallerQuitDecision.HOLD
1444
+ || installerQuitDecision === InstallerQuitDecision.BLOCK) {
1445
+ event.preventDefault();
1446
+ return;
1447
+ }
1448
+ if (installerQuitDecision === InstallerQuitDecision.ALLOW) return;
1449
+ event.preventDefault();
1305
1450
  recurringProductUpdates?.stop();
1306
1451
  quitSequenceStarted = true;
1307
1452
  isQuitting = true;
@@ -1317,7 +1462,9 @@ if (!app.requestSingleInstanceLock()) {
1317
1462
  isQuitting = false;
1318
1463
  quitSequenceStarted = false;
1319
1464
  updateInstallSequenceStarted = false;
1465
+ installerQuitGate.failInstall(error);
1320
1466
  scheduleDesktopAuthRefresh(readEncryptedSession());
1467
+ recurringProductUpdates?.start();
1321
1468
  updateTray('Runtime cleanup blocked');
1322
1469
  log(`runtime cleanup blocked application quit: ${error?.message || error}`);
1323
1470
  });