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.
- package/electron/installer-quit-gate.mjs +183 -0
- package/electron/main.mjs +249 -102
- package/electron/runtime-window-load-owner.mjs +153 -0
- package/electron/update-manager.mjs +762 -144
- package/package.json +1 -1
- package/web/dist/app.webmanifest +1 -1
- package/web/dist/assets/LiveDeskApp-Dr77Bkmu.js +179 -0
- package/web/dist/assets/{icons-JxbtiHLQ.js → icons-tFaWqIIe.js} +1 -1
- package/web/dist/assets/{index-C4QRbhif.js → index-Dzx6WcDO.js} +4 -4
- package/web/dist/assets/{react-hDor0Pi9.js → react-BQos8uGS.js} +1 -1
- package/web/dist/assets/supabase-C7qjtLN3.js +29 -0
- package/web/dist/index.html +3 -3
- package/web/dist/livedesk-build-evidence.json +25 -25
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/LiveDeskApp-Dw86X0VP.js +0 -179
- package/web/dist/assets/supabase-C0Z0FS3K.js +0 -29
|
@@ -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
|
-
|
|
173
|
-
const
|
|
174
|
-
const
|
|
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
|
-
|
|
600
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
1028
|
-
mainWindow.
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
if (
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
if (!isQuitting) {
|
|
1080
|
-
event.preventDefault();
|
|
1081
|
-
|
|
1082
|
-
}
|
|
1083
|
-
});
|
|
1084
|
-
|
|
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
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
});
|