livedesk 0.1.606 → 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.
package/electron/main.mjs CHANGED
@@ -1,12 +1,13 @@
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';
9
- import { createPkceAuthorizationUrl, exchangePkceCode, fetchSupabaseUser, parsePkceCallbackUrl, refreshSupabaseSession } from './oauth-pkce.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';
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';
12
13
  import {
@@ -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,48 +679,85 @@ 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;
674
- isQuitting = true;
675
- recurringProductUpdates?.stop();
676
- try {
677
- await stopRuntime();
678
- } catch (error) {
679
- updateInstallSequenceStarted = false;
680
- isQuitting = false;
681
- throw error;
682
- }
683
- }
684
-
685
- async function recoverRuntimeAfterUpdateInstallError(error) {
686
- log(`desktop update install launch failed after runtime drain: ${error?.message || error}`);
687
- updateInstallSequenceStarted = false;
688
- isQuitting = false;
689
- if (!runtimeChild) {
690
- startRuntime();
691
- const ready = await waitForRuntime();
692
- if (ready) mainWindow?.loadURL(LOCAL_RUNTIME_URL);
693
- }
694
- }
682
+ }
683
+ updateInstallSequenceStarted = true;
684
+ installerQuitGate.beginInstall();
685
+ isQuitting = true;
686
+ recurringProductUpdates?.stop();
687
+ try {
688
+ await quiesceDesktopAuthOwner('desktop-update-install');
689
+ await stopRuntime();
690
+ } catch (error) {
691
+ installerQuitGate.failInstall(error);
692
+ updateInstallSequenceStarted = false;
693
+ isQuitting = false;
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
+ }
712
+ throw error;
713
+ }
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}`);
721
+ updateInstallSequenceStarted = false;
722
+ isQuitting = false;
723
+ scheduleDesktopAuthRefresh(readEncryptedSession());
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
+ }
695
732
 
696
- async function syncSessionToRuntime(session) {
697
- const normalized = normalizeSession(session);
698
- if (!normalized) throw new Error('oauth-session-invalid');
699
- const csrfToken = await getRuntimeCsrfToken();
700
- const response = await fetch(`${LOCAL_RUNTIME_URL}api/auth/session`, {
701
- method: 'POST',
702
- headers: {
703
- 'Content-Type': 'application/json',
704
- ...(csrfToken ? { 'X-LiveDesk-CSRF': csrfToken } : {})
705
- },
706
- body: JSON.stringify({
707
- accessToken: normalized.access_token,
708
- refreshToken: normalized.refresh_token,
709
- expiresAt: normalized.expires_at,
710
- userId: normalized.user?.id,
711
- user: { id: normalized.user?.id, email: normalized.user?.email }
712
- })
713
- });
733
+ async function syncSessionToRuntime(session) {
734
+ const normalized = normalizeSession(session);
735
+ if (!normalized) throw new Error('oauth-session-invalid');
736
+ const deadline = Date.now() + AUTH_REQUEST_TIMEOUT_MS + 2_500;
737
+ const csrfToken = await getRuntimeCsrfToken(Math.min(deadline, Date.now() + 2_500));
738
+ const response = await fetchBeforeRuntimeDeadline(
739
+ fetch,
740
+ `${LOCAL_RUNTIME_URL}api/auth/session`,
741
+ {
742
+ method: 'POST',
743
+ headers: {
744
+ 'Content-Type': 'application/json',
745
+ ...(csrfToken ? { 'X-LiveDesk-CSRF': csrfToken } : {})
746
+ },
747
+ body: JSON.stringify({
748
+ accessToken: normalized.access_token,
749
+ refreshToken: normalized.refresh_token,
750
+ expiresAt: normalized.expires_at,
751
+ userId: normalized.user?.id,
752
+ user: { id: normalized.user?.id, email: normalized.user?.email }
753
+ })
754
+ },
755
+ {
756
+ deadline,
757
+ maxDurationMs: Math.min(AUTH_REQUEST_TIMEOUT_MS, remainingRuntimeDeadlineMs(deadline)),
758
+ label: 'runtime-auth-sync-deadline'
759
+ }
760
+ );
714
761
  if (!response.ok) {
715
762
  const payload = await response.json().catch(() => ({}));
716
763
  throw new Error(String(payload?.error || `runtime-session-sync-failed:${response.status}`));
@@ -854,18 +901,29 @@ async function restoreEncryptedSessionOwned({ syncRuntime = true } = {}) {
854
901
  return session;
855
902
  }
856
903
 
857
- async function restoreEncryptedSession(options = {}) {
858
- if (desktopSessionRestorePromise) return desktopSessionRestorePromise;
859
- const operation = restoreEncryptedSessionOwned(options);
904
+ async function restoreEncryptedSession(options = {}) {
905
+ if (desktopSessionRestorePromise) return desktopSessionRestorePromise;
906
+ if (isQuitting) return readEncryptedSession();
907
+ const operation = restoreEncryptedSessionOwned(options);
860
908
  desktopSessionRestorePromise = operation;
861
909
  try {
862
910
  return await operation;
863
911
  } finally {
864
912
  if (desktopSessionRestorePromise === operation) desktopSessionRestorePromise = null;
865
- }
866
- }
867
-
868
- async function signOutDesktopSession() {
913
+ }
914
+ }
915
+
916
+ async function quiesceDesktopAuthOwner(reason = 'application-exit') {
917
+ clearDesktopAuthRefreshTimer();
918
+ while (desktopSessionRestorePromise) {
919
+ const pending = desktopSessionRestorePromise;
920
+ log(`waiting for secure auth owner before ${reason}`);
921
+ await pending;
922
+ if (desktopSessionRestorePromise === pending) break;
923
+ }
924
+ }
925
+
926
+ async function signOutDesktopSession() {
869
927
  desktopSessionEpoch += 1;
870
928
  desktopAuthInvalidationGuard.observeSuccess();
871
929
  clearEncryptedSession();
@@ -1071,19 +1129,21 @@ function registerIpc() {
1071
1129
  if (quitSequenceStarted || updateInstallSequenceStarted) {
1072
1130
  return { ok: false, error: 'application-exit-already-started' };
1073
1131
  }
1074
- quitSequenceStarted = true;
1075
- isQuitting = true;
1076
- try {
1077
- await stopRuntime();
1132
+ quitSequenceStarted = true;
1133
+ isQuitting = true;
1134
+ try {
1135
+ await quiesceDesktopAuthOwner('application-restart');
1136
+ await stopRuntime();
1078
1137
  await desktopClipboardOwner?.close();
1079
1138
  await closeDesktopLog();
1080
1139
  app.relaunch();
1081
1140
  app.exit(0);
1082
1141
  return { ok: true };
1083
- } catch (error) {
1084
- isQuitting = false;
1085
- quitSequenceStarted = false;
1086
- updateTray('Runtime cleanup blocked');
1142
+ } catch (error) {
1143
+ isQuitting = false;
1144
+ quitSequenceStarted = false;
1145
+ scheduleDesktopAuthRefresh(readEncryptedSession());
1146
+ updateTray('Runtime cleanup blocked');
1087
1147
  log(`runtime cleanup blocked application restart: ${error?.message || error}`);
1088
1148
  if (!runtimeChild) {
1089
1149
  startRuntime();
@@ -1195,12 +1255,18 @@ function createTray() {
1195
1255
  updateTray('Starting');
1196
1256
  }
1197
1257
 
1198
- app.on('open-url', (event, url) => {
1258
+ app.on('open-url', (event, url) => {
1199
1259
  event.preventDefault();
1200
1260
  if (!isLiveDeskAuthCallbackUrl(url)) return;
1201
1261
  if (app.isReady()) void handleProtocolUrl(url);
1202
1262
  else pendingProtocolUrl = url;
1203
- });
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
+ });
1204
1270
 
1205
1271
  if (!app.requestSingleInstanceLock()) {
1206
1272
  app.quit();
@@ -1211,19 +1277,43 @@ if (!app.requestSingleInstanceLock()) {
1211
1277
  showWindow();
1212
1278
  });
1213
1279
  app.whenReady().then(async () => {
1214
- app.setAsDefaultProtocolClient('livedesk');
1215
- removeLegacyStartupEntries();
1216
- migrateLegacyPlaintextSession();
1217
- productUpdates = createProductUpdateManager({
1218
- app,
1219
- onStatus: status => {
1280
+ app.setAsDefaultProtocolClient('livedesk');
1281
+ removeLegacyStartupEntries();
1282
+ migrateLegacyPlaintextSession();
1283
+ let lastProductUpdateLogSignature = '';
1284
+ productUpdates = createProductUpdateManager({
1285
+ app,
1286
+ onStatus: status => {
1220
1287
  if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
1221
1288
  mainWindow.webContents.send('updates:status', status);
1222
1289
  }
1223
- if (status.state === 'available') updateTray(`Update ${status.availableVersion} available`);
1224
- else if (status.state === 'downloading') updateTray(`Downloading update ${Math.round(status.progress || 0)}%`);
1225
- else if (status.state === 'downloaded') updateTray(`Update ${status.availableVersion} ready`);
1226
- },
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
+ },
1227
1317
  beforeInstall: prepareRuntimeForUpdateInstall,
1228
1318
  onInstallError: recoverRuntimeAfterUpdateInstallError
1229
1319
  });
@@ -1272,24 +1362,36 @@ if (!app.requestSingleInstanceLock()) {
1272
1362
  pendingProtocolUrl = '';
1273
1363
  if (initialProtocolUrl) void handleProtocolUrl(initialProtocolUrl);
1274
1364
  });
1275
- app.on('window-all-closed', () => { /* tray-owned desktop stays alive */ });
1276
- app.on('before-quit', event => {
1277
- if (quitSequenceStarted) return;
1278
- 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();
1279
1376
  recurringProductUpdates?.stop();
1280
- quitSequenceStarted = true;
1281
- isQuitting = true;
1282
- void stopRuntime().then(() => {
1283
- return desktopClipboardOwner?.close();
1377
+ quitSequenceStarted = true;
1378
+ isQuitting = true;
1379
+ void quiesceDesktopAuthOwner('application-quit').then(() => {
1380
+ return stopRuntime();
1381
+ }).then(() => {
1382
+ return desktopClipboardOwner?.close();
1284
1383
  }).then(() => {
1285
1384
  return closeDesktopLog();
1286
1385
  }).then(() => {
1287
1386
  app.quit();
1288
1387
  }).catch(error => {
1289
- isQuitting = false;
1290
- quitSequenceStarted = false;
1291
- updateInstallSequenceStarted = false;
1292
- updateTray('Runtime cleanup blocked');
1388
+ isQuitting = false;
1389
+ quitSequenceStarted = false;
1390
+ updateInstallSequenceStarted = false;
1391
+ installerQuitGate.failInstall(error);
1392
+ scheduleDesktopAuthRefresh(readEncryptedSession());
1393
+ recurringProductUpdates?.start();
1394
+ updateTray('Runtime cleanup blocked');
1293
1395
  log(`runtime cleanup blocked application quit: ${error?.message || error}`);
1294
1396
  });
1295
1397
  });