livedesk 0.1.607 → 0.1.608

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';
@@ -155,10 +156,10 @@ let runtimeRestartWindowStartedAt = 0;
155
156
  let runtimeRestartTimer = null;
156
157
  let runtimeRestartPromise = null;
157
158
  let runtimeLaunchStartedAt = 0;
158
- let runtimeRoleTakeoverPending = process.env.LIVEDESK_ROLE_TAKEOVER === '1';
159
- let quitSequenceStarted = false;
160
- let updateInstallSequenceStarted = false;
161
- let productUpdates = null;
159
+ let runtimeRoleTakeoverPending = process.env.LIVEDESK_ROLE_TAKEOVER === '1';
160
+ let quitSequenceStarted = false;
161
+ let updateInstallSequenceStarted = false;
162
+ let productUpdates = null;
162
163
  let recurringProductUpdates = null;
163
164
  let pendingOAuth = null;
164
165
  let pendingProtocolUrl = '';
@@ -169,7 +170,10 @@ let lastDesktopResumeRecoveryAt = 0;
169
170
  let desktopSessionEpoch = 0;
170
171
  let lastKnownDesktopSession = null;
171
172
  let desktopClipboardOwner = null;
172
- const desktopAuthInvalidationGuard = createDesktopAuthInvalidationGuard();
173
+ const desktopAuthInvalidationGuard = createDesktopAuthInvalidationGuard();
174
+ const installerQuitGate = createInstallerQuitGate({
175
+ requestQuit: () => app.quit()
176
+ });
173
177
  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
178
  const desktopLogger = createBoundedDesktopLogger({ logPath });
175
179
 
@@ -615,12 +619,18 @@ async function stopRuntime() {
615
619
  child.livedeskRuntimeStopDeadline = deadline;
616
620
  const operation = (async () => {
617
621
  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);
622
+ const gracefulDeadline = Math.max(
623
+ Date.now(),
624
+ deadline - DEFAULT_RUNTIME_FORCED_CLEANUP_RESERVE_MS
625
+ );
626
+ try {
627
+ await waitForOwnedRuntimeTreeExit(child, { deadline: gracefulDeadline });
628
+ } catch (error) {
629
+ // Preserve the forced-cleanup reserve. A stale ChildProcess terminal
630
+ // notification must not prevent the exact registered tree drain below.
631
+ log(`runtime graceful exit wait expired; forcing exact drain: ${error?.message || error}`);
632
+ }
633
+ await drainRuntimeTreeUntilStopped(child, deadline);
624
634
  if (runtimeChild === child) runtimeChild = null;
625
635
  return true;
626
636
  })();
@@ -669,32 +679,56 @@ function requestRuntimeRestart(source = 'manual') {
669
679
  async function prepareRuntimeForUpdateInstall() {
670
680
  if (updateInstallSequenceStarted) {
671
681
  throw new Error('desktop-update-install-already-started');
672
- }
673
- updateInstallSequenceStarted = true;
682
+ }
683
+ updateInstallSequenceStarted = true;
684
+ installerQuitGate.beginInstall();
674
685
  isQuitting = true;
675
686
  recurringProductUpdates?.stop();
676
687
  try {
677
688
  await quiesceDesktopAuthOwner('desktop-update-install');
678
689
  await stopRuntime();
679
690
  } catch (error) {
691
+ installerQuitGate.failInstall(error);
680
692
  updateInstallSequenceStarted = false;
681
693
  isQuitting = false;
682
694
  scheduleDesktopAuthRefresh(readEncryptedSession());
695
+ let recoveryError = null;
696
+ try {
697
+ const ready = await restartRuntimeProcess();
698
+ if (!ready) throw new Error('desktop-update-runtime-recovery-not-ready');
699
+ } catch (cause) {
700
+ recoveryError = cause;
701
+ log(`desktop update preparation recovery failed: ${cause?.message || cause}`);
702
+ }
703
+ if (!recoveryError) {
704
+ recurringProductUpdates?.start();
705
+ }
706
+ if (recoveryError) {
707
+ throw new Error(
708
+ `desktop-update-preinstall-recovery-failed:${recoveryError?.message || recoveryError}`,
709
+ { cause: error }
710
+ );
711
+ }
683
712
  throw error;
684
713
  }
685
- }
686
-
687
- async function recoverRuntimeAfterUpdateInstallError(error) {
688
- log(`desktop update install launch failed after runtime drain: ${error?.message || error}`);
714
+ }
715
+
716
+ async function recoverRuntimeAfterUpdateInstallError(error) {
717
+ // electron-updater can report a Windows installer spawn failure just before
718
+ // its queued app.quit(). Seal that exact update quit before restarting.
719
+ installerQuitGate.failInstall(error);
720
+ log(`desktop update install launch failed after runtime drain: ${error?.message || error}`);
689
721
  updateInstallSequenceStarted = false;
690
722
  isQuitting = false;
691
723
  scheduleDesktopAuthRefresh(readEncryptedSession());
692
- if (!runtimeChild) {
693
- startRuntime();
694
- const ready = await waitForRuntime();
695
- if (ready) mainWindow?.loadURL(LOCAL_RUNTIME_URL);
696
- }
697
- }
724
+ if (!runtimeChild) startRuntime();
725
+ const ready = await waitForRuntime();
726
+ if (!ready) {
727
+ throw new Error('desktop-update-runtime-recovery-not-ready', { cause: error });
728
+ }
729
+ mainWindow?.loadURL(LOCAL_RUNTIME_URL);
730
+ recurringProductUpdates?.start();
731
+ }
698
732
 
699
733
  async function syncSessionToRuntime(session) {
700
734
  const normalized = normalizeSession(session);
@@ -1221,12 +1255,18 @@ function createTray() {
1221
1255
  updateTray('Starting');
1222
1256
  }
1223
1257
 
1224
- app.on('open-url', (event, url) => {
1258
+ app.on('open-url', (event, url) => {
1225
1259
  event.preventDefault();
1226
1260
  if (!isLiveDeskAuthCallbackUrl(url)) return;
1227
1261
  if (app.isReady()) void handleProtocolUrl(url);
1228
1262
  else pendingProtocolUrl = url;
1229
- });
1263
+ });
1264
+
1265
+ electronAutoUpdater.on('before-quit-for-update', () => {
1266
+ // On Windows this is updater intent, not proof that the asynchronous NSIS
1267
+ // spawn succeeded. The first updater-owned quit is confirmed below.
1268
+ installerQuitGate.noteBeforeQuitForUpdate();
1269
+ });
1230
1270
 
1231
1271
  if (!app.requestSingleInstanceLock()) {
1232
1272
  app.quit();
@@ -1237,19 +1277,43 @@ if (!app.requestSingleInstanceLock()) {
1237
1277
  showWindow();
1238
1278
  });
1239
1279
  app.whenReady().then(async () => {
1240
- app.setAsDefaultProtocolClient('livedesk');
1241
- removeLegacyStartupEntries();
1242
- migrateLegacyPlaintextSession();
1243
- productUpdates = createProductUpdateManager({
1244
- app,
1245
- onStatus: status => {
1280
+ app.setAsDefaultProtocolClient('livedesk');
1281
+ removeLegacyStartupEntries();
1282
+ migrateLegacyPlaintextSession();
1283
+ let lastProductUpdateLogSignature = '';
1284
+ productUpdates = createProductUpdateManager({
1285
+ app,
1286
+ onStatus: status => {
1246
1287
  if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
1247
1288
  mainWindow.webContents.send('updates:status', status);
1248
1289
  }
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
- },
1290
+ if (status.state === 'available') updateTray(`Update ${status.availableVersion} available`);
1291
+ else if (status.state === 'downloading') updateTray(`Downloading update ${Math.round(status.progress || 0)}%`);
1292
+ else if (status.state === 'downloaded') updateTray(`Update ${status.availableVersion} ready`);
1293
+ const progressBucket = Math.floor(Math.max(0, Number(status.progress || 0)) / 10) * 10;
1294
+ const signature = [
1295
+ status.state,
1296
+ status.channel,
1297
+ status.currentVersion,
1298
+ status.availableVersion,
1299
+ status.downloadedVersion,
1300
+ progressBucket,
1301
+ status.cleanupPending,
1302
+ status.reopenRequired,
1303
+ status.errorCode,
1304
+ status.error,
1305
+ status.lastCheckStartedAt,
1306
+ status.lastCheckCompletedAt,
1307
+ status.startupCheck
1308
+ ].join('|');
1309
+ if (signature !== lastProductUpdateLogSignature) {
1310
+ lastProductUpdateLogSignature = signature;
1311
+ log(
1312
+ `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'}`,
1313
+ { priority: status.state === 'error' || status.state === 'installing' ? 'normal' : 'low' }
1314
+ );
1315
+ }
1316
+ },
1253
1317
  beforeInstall: prepareRuntimeForUpdateInstall,
1254
1318
  onInstallError: recoverRuntimeAfterUpdateInstallError
1255
1319
  });
@@ -1298,10 +1362,17 @@ if (!app.requestSingleInstanceLock()) {
1298
1362
  pendingProtocolUrl = '';
1299
1363
  if (initialProtocolUrl) void handleProtocolUrl(initialProtocolUrl);
1300
1364
  });
1301
- app.on('window-all-closed', () => { /* tray-owned desktop stays alive */ });
1302
- app.on('before-quit', event => {
1303
- if (quitSequenceStarted) return;
1304
- event.preventDefault();
1365
+ app.on('window-all-closed', () => { /* tray-owned desktop stays alive */ });
1366
+ app.on('before-quit', event => {
1367
+ if (quitSequenceStarted) return;
1368
+ const installerQuitDecision = installerQuitGate.handleBeforeQuit();
1369
+ if (installerQuitDecision === InstallerQuitDecision.HOLD
1370
+ || installerQuitDecision === InstallerQuitDecision.BLOCK) {
1371
+ event.preventDefault();
1372
+ return;
1373
+ }
1374
+ if (installerQuitDecision === InstallerQuitDecision.ALLOW) return;
1375
+ event.preventDefault();
1305
1376
  recurringProductUpdates?.stop();
1306
1377
  quitSequenceStarted = true;
1307
1378
  isQuitting = true;
@@ -1317,7 +1388,9 @@ if (!app.requestSingleInstanceLock()) {
1317
1388
  isQuitting = false;
1318
1389
  quitSequenceStarted = false;
1319
1390
  updateInstallSequenceStarted = false;
1391
+ installerQuitGate.failInstall(error);
1320
1392
  scheduleDesktopAuthRefresh(readEncryptedSession());
1393
+ recurringProductUpdates?.start();
1321
1394
  updateTray('Runtime cleanup blocked');
1322
1395
  log(`runtime cleanup blocked application quit: ${error?.message || error}`);
1323
1396
  });