livedesk 0.1.608 → 0.1.610

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
@@ -41,11 +41,12 @@ import {
41
41
  waitForOwnedRuntimeTreeExit,
42
42
  waitForPromiseBeforeDeadline
43
43
  } from './runtime-process-owner.mjs';
44
- import {
45
- desktopHubTakeoverPublicationConfirmed,
46
- resolveDesktopHubTakeoverPending
47
- } from './runtime-role-transition.mjs';
48
- 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';
49
50
 
50
51
  // Chromium otherwise leaves an out-of-process Audio Service (~80-90 MiB on
51
52
  // Windows) alive after the exact Remote Audio owner, AudioContext, worklet,
@@ -168,14 +169,34 @@ let desktopAuthRefreshTimer = null;
168
169
  let desktopSessionRestorePromise = null;
169
170
  let lastDesktopResumeRecoveryAt = 0;
170
171
  let desktopSessionEpoch = 0;
171
- let lastKnownDesktopSession = null;
172
- let desktopClipboardOwner = null;
172
+ let lastKnownDesktopSession = null;
173
+ let desktopClipboardOwner = null;
174
+ let runtimeWindowLoadOwner = null;
173
175
  const desktopAuthInvalidationGuard = createDesktopAuthInvalidationGuard();
174
176
  const installerQuitGate = createInstallerQuitGate({
175
177
  requestQuit: () => app.quit()
176
178
  });
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>')}`;
178
- const desktopLogger = createBoundedDesktopLogger({ logPath });
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 });
179
200
 
180
201
  function mask(value) {
181
202
  return String(value ?? '')
@@ -600,9 +621,11 @@ async function waitForRuntime(options = {}) {
600
621
  await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(200, remainingMs)));
601
622
  }
602
623
  }
603
- log(`runtime identity check timed out; expectedRole=${expectedRole || 'any'}`);
604
- return null;
605
- }
624
+ if (config.logTimeout !== false) {
625
+ log(`runtime identity check timed out; expectedRole=${expectedRole || 'any'}`);
626
+ }
627
+ return null;
628
+ }
606
629
 
607
630
  async function stopRuntime() {
608
631
  if (runtimeRestartTimer) {
@@ -1056,25 +1079,28 @@ function updateTray(status = 'Starting') {
1056
1079
  tray.setToolTip(`${APP_NAME} — ${status}`);
1057
1080
  }
1058
1081
 
1059
- function showWindow() {
1060
- if (!mainWindow) return;
1061
- if (mainWindow.isMinimized()) mainWindow.restore();
1062
- mainWindow.show();
1082
+ function showWindow() {
1083
+ if (!mainWindow) return;
1084
+ runtimeWindowLoadOwner?.start('window-show');
1085
+ if (mainWindow.isMinimized()) mainWindow.restore();
1086
+ mainWindow.show();
1063
1087
  mainWindow.focus();
1064
1088
  if (!mainWindow.webContents.isDestroyed()) mainWindow.webContents.invalidate();
1065
1089
  requestDesktopResumeAuthRecovery('window-show');
1066
1090
  }
1067
1091
 
1068
- function createMainWindow() {
1069
- const iconPath = appResource('electron', 'icon.png');
1070
- Menu.setApplicationMenu(null);
1071
- mainWindow = new BrowserWindow({
1072
- width: 1440,
1073
- height: 960,
1074
- minWidth: 1080,
1075
- minHeight: 720,
1076
- show: false,
1077
- 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,
1078
1104
  title: appWindowTitle(),
1079
1105
  icon: existsSync(iconPath) ? iconPath : nativeImage.createFromDataURL(FALLBACK_ICON_DATA_URL),
1080
1106
  webPreferences: {
@@ -1082,41 +1108,89 @@ function createMainWindow() {
1082
1108
  contextIsolation: true,
1083
1109
  nodeIntegration: false,
1084
1110
  sandbox: true,
1085
- spellcheck: false
1086
- }
1087
- });
1088
- mainWindow.on('page-title-updated', event => {
1089
- event.preventDefault();
1090
- mainWindow?.setTitle(appWindowTitle());
1091
- });
1092
- mainWindow.on('focus', () => requestDesktopResumeAuthRecovery('window-focus'));
1093
- mainWindow.webContents.on('will-navigate', (event, url) => {
1094
- if (isAllowedRuntimeUrl(url)) return;
1095
- event.preventDefault();
1096
- void shell.openExternal(url);
1097
- });
1098
- mainWindow.webContents.setWindowOpenHandler(({ url }) => {
1099
- if (isAllowedRuntimeUrl(url)) return { action: 'allow' };
1100
- void shell.openExternal(url);
1101
- return { action: 'deny' };
1102
- });
1103
- mainWindow.loadURL(LOCAL_RUNTIME_URL);
1104
- mainWindow.webContents.on('did-finish-load', () => {
1105
- void restoreEncryptedSession({ syncRuntime: true }).then(session => {
1106
- 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 });
1107
1177
  }).catch(error => {
1108
- log(`secure auth session window restore failed: ${error?.message || error}`);
1109
- });
1110
- });
1111
- mainWindow.once('ready-to-show', () => { updateTray('Running'); if (process.env.LIVEDESK_E2E_HEADLESS !== '1') showWindow(); });
1112
- mainWindow.on('close', event => {
1113
- if (!isQuitting) {
1114
- event.preventDefault();
1115
- mainWindow.hide();
1116
- }
1117
- });
1118
- mainWindow.on('closed', () => { mainWindow = null; });
1119
- }
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
+ }
1120
1194
 
1121
1195
  function registerIpc() {
1122
1196
  const handle = (channel, handler) => ipcMain.handle(channel, async (event, ...args) => {
@@ -0,0 +1,153 @@
1
+ const DEFAULT_RETRY_DELAY_MS = 1_000;
2
+
3
+ function normalizeDelay(value, fallback = 0) {
4
+ const milliseconds = Number(value);
5
+ return Number.isFinite(milliseconds) && milliseconds >= 0
6
+ ? Math.round(milliseconds)
7
+ : fallback;
8
+ }
9
+
10
+ /**
11
+ * Owns the one retry timer and one in-flight load attempt for a BrowserWindow.
12
+ * A successfully mounted runtime document is never reloaded by start(). Only a
13
+ * real main-frame failure or renderer termination opens a new generation.
14
+ */
15
+ export function createRuntimeWindowLoadOwner({
16
+ probeRuntime,
17
+ showRecovery = async () => undefined,
18
+ loadRuntime,
19
+ onLoaded = () => undefined,
20
+ onAttemptError = () => undefined,
21
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
22
+ setTimer = setTimeout,
23
+ clearTimer = clearTimeout
24
+ } = {}) {
25
+ if (typeof probeRuntime !== 'function') throw new Error('runtime-window-probe-required');
26
+ if (typeof loadRuntime !== 'function') throw new Error('runtime-window-loader-required');
27
+
28
+ const retryDelay = normalizeDelay(retryDelayMs, DEFAULT_RETRY_DELAY_MS);
29
+ let generation = 1;
30
+ let stopped = false;
31
+ let loaded = false;
32
+ let recoveryVisible = false;
33
+ let timer = null;
34
+ let operation = null;
35
+ let attemptCount = 0;
36
+ let pendingReason = 'created';
37
+ let pendingRetryDelay = retryDelay;
38
+
39
+ const snapshot = () => ({
40
+ generation,
41
+ stopped,
42
+ loaded,
43
+ recoveryVisible,
44
+ retryScheduled: timer !== null,
45
+ attemptPending: operation !== null,
46
+ attemptCount,
47
+ pendingReason
48
+ });
49
+
50
+ const reportAttemptError = (error, context) => {
51
+ try {
52
+ onAttemptError(error, context);
53
+ } catch {
54
+ // Diagnostics cannot take ownership from the load recovery path.
55
+ }
56
+ };
57
+
58
+ const ensureRecoveryVisible = async context => {
59
+ if (stopped || loaded || recoveryVisible || context.generation !== generation) return;
60
+ try {
61
+ await showRecovery(context);
62
+ if (!stopped && !loaded && context.generation === generation) recoveryVisible = true;
63
+ } catch (error) {
64
+ reportAttemptError(error, { ...context, phase: 'recovery-page' });
65
+ }
66
+ };
67
+
68
+ const schedule = (reason = 'retry', delayMs = 0) => {
69
+ if (stopped || loaded || timer !== null || operation !== null) return false;
70
+ pendingReason = String(reason || 'retry');
71
+ const ownerGeneration = generation;
72
+ timer = setTimer(() => {
73
+ timer = null;
74
+ if (stopped || loaded || ownerGeneration !== generation) return;
75
+ const context = {
76
+ generation: ownerGeneration,
77
+ attempt: ++attemptCount,
78
+ reason: pendingReason
79
+ };
80
+ const currentOperation = (async () => {
81
+ const ready = await probeRuntime(context);
82
+ if (stopped || loaded || ownerGeneration !== generation) return;
83
+ if (!ready) {
84
+ await ensureRecoveryVisible({ ...context, phase: 'runtime-wait' });
85
+ return;
86
+ }
87
+ await loadRuntime(context);
88
+ if (stopped || loaded || ownerGeneration !== generation) return;
89
+ loaded = true;
90
+ recoveryVisible = false;
91
+ try {
92
+ onLoaded(context);
93
+ } catch (error) {
94
+ reportAttemptError(error, { ...context, phase: 'loaded-callback' });
95
+ }
96
+ })()
97
+ .catch(async error => {
98
+ if (stopped || loaded || ownerGeneration !== generation) return;
99
+ reportAttemptError(error, { ...context, phase: 'runtime-load' });
100
+ await ensureRecoveryVisible({ ...context, phase: 'runtime-load' });
101
+ })
102
+ .finally(() => {
103
+ if (operation !== currentOperation) return;
104
+ operation = null;
105
+ if (!stopped && !loaded) {
106
+ const delay = pendingRetryDelay;
107
+ pendingRetryDelay = retryDelay;
108
+ schedule(pendingReason || 'retry', delay);
109
+ }
110
+ });
111
+ operation = currentOperation;
112
+ }, normalizeDelay(delayMs));
113
+ timer?.unref?.();
114
+ return true;
115
+ };
116
+
117
+ const beginNewGeneration = reason => {
118
+ if (stopped) return false;
119
+ generation += 1;
120
+ loaded = false;
121
+ recoveryVisible = false;
122
+ pendingReason = String(reason || 'reload');
123
+ pendingRetryDelay = 0;
124
+ if (timer !== null) {
125
+ clearTimer(timer);
126
+ timer = null;
127
+ }
128
+ schedule(pendingReason, 0);
129
+ return true;
130
+ };
131
+
132
+ return {
133
+ getStatus: snapshot,
134
+ start(reason = 'start') {
135
+ return schedule(reason, 0);
136
+ },
137
+ noteMainFrameFailure(reason = 'main-frame-failed') {
138
+ return beginNewGeneration(reason);
139
+ },
140
+ noteRendererGone(reason = 'renderer-gone') {
141
+ return beginNewGeneration(reason);
142
+ },
143
+ stop() {
144
+ if (stopped) return;
145
+ stopped = true;
146
+ generation += 1;
147
+ if (timer !== null) {
148
+ clearTimer(timer);
149
+ timer = null;
150
+ }
151
+ }
152
+ };
153
+ }
@@ -398,7 +398,16 @@ export function createProductUpdateManager({
398
398
  if (!candidate || connectedUpdaters.has(candidate)) return;
399
399
  candidate.autoDownload = false;
400
400
  candidate.autoInstallOnAppQuit = true;
401
- if (feed.url) candidate.setFeedURL({ provider: 'generic', url: feed.url });
401
+ if (feed.url) {
402
+ // R2 exposes one byte range per read. Keep current clients on simple
403
+ // single-range differential requests; the Worker retains bounded
404
+ // multipart compatibility for already-installed older versions.
405
+ candidate.setFeedURL({
406
+ provider: 'generic',
407
+ url: feed.url,
408
+ useMultipleRangeRequest: false
409
+ });
410
+ }
402
411
  for (const eventName of forwardedUpdaterEvents) {
403
412
  candidate.on(eventName, (...args) => {
404
413
  // A force-retired updater keeps this harmless listener so a late
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.608",
3
+ "version": "0.1.610",
4
4
  "livedeskClientVersion": "0.1.262",
5
5
  "buildFlavor": "production",
6
6
  "description": "VuvoDesk Hub and client launcher",