livedesk 0.1.573 → 0.1.575

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,104 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ let temporaryFileSequence = 0;
5
+
6
+ function writeAtomicPrivateFile(path, contents) {
7
+ mkdirSync(dirname(path), { recursive: true });
8
+ temporaryFileSequence += 1;
9
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.${temporaryFileSequence}.tmp`;
10
+ try {
11
+ writeFileSync(temporaryPath, contents, {
12
+ encoding: 'utf8',
13
+ mode: 0o600,
14
+ flush: true
15
+ });
16
+ renameSync(temporaryPath, path);
17
+ } finally {
18
+ try { rmSync(temporaryPath, { force: true }); } catch { /* best effort */ }
19
+ }
20
+ }
21
+
22
+ export function writeDurableAuthSessionRecord({
23
+ primaryPath,
24
+ backupPath = `${primaryPath}.backup`,
25
+ record
26
+ }) {
27
+ if (!primaryPath || !record || typeof record !== 'object') {
28
+ throw new Error('durable-auth-session-write-parameters-required');
29
+ }
30
+ const contents = JSON.stringify(record, null, 2);
31
+ let backupError = null;
32
+ try {
33
+ writeAtomicPrivateFile(backupPath, contents);
34
+ } catch (error) {
35
+ // A mirror failure is reported to the caller, but it must not prevent the
36
+ // primary from preserving the successfully refreshed encrypted login.
37
+ backupError = error;
38
+ }
39
+ writeAtomicPrivateFile(primaryPath, contents);
40
+ return { backupWritten: !backupError, backupError };
41
+ }
42
+
43
+ export function readDurableAuthSessionRecord({
44
+ primaryPath,
45
+ backupPath = `${primaryPath}.backup`,
46
+ decode
47
+ }) {
48
+ if (!primaryPath || typeof decode !== 'function') {
49
+ throw new Error('durable-auth-session-read-parameters-required');
50
+ }
51
+ const failures = [];
52
+ const candidates = [];
53
+ for (const candidatePath of [primaryPath, backupPath]) {
54
+ if (!existsSync(candidatePath)) continue;
55
+ try {
56
+ const record = JSON.parse(readFileSync(candidatePath, 'utf8'));
57
+ const value = decode(record);
58
+ if (!value) throw new Error('durable-auth-session-record-invalid');
59
+ const savedAt = Date.parse(String(record?.savedAt || ''));
60
+ candidates.push({
61
+ path: candidatePath,
62
+ record,
63
+ value,
64
+ savedAt: Number.isFinite(savedAt) ? savedAt : 0
65
+ });
66
+ } catch (error) {
67
+ failures.push({ path: candidatePath, error: String(error?.message || error) });
68
+ }
69
+ }
70
+ if (candidates.length === 0) {
71
+ return { value: null, recoveredFromBackup: false, primaryRepaired: false, failures };
72
+ }
73
+
74
+ // A refresh may finish writing the backup immediately before an interrupted
75
+ // primary replacement. Prefer the newest valid encrypted record so the app
76
+ // does not fall back to an older, already-rotated refresh token.
77
+ candidates.sort((left, right) => {
78
+ if (right.savedAt !== left.savedAt) return right.savedAt - left.savedAt;
79
+ if (left.path === primaryPath) return -1;
80
+ if (right.path === primaryPath) return 1;
81
+ return 0;
82
+ });
83
+ const selected = candidates[0];
84
+ const recoveredFromBackup = selected.path === backupPath;
85
+ let primaryRepaired = false;
86
+ if (recoveredFromBackup) {
87
+ try {
88
+ writeAtomicPrivateFile(primaryPath, JSON.stringify(selected.record, null, 2));
89
+ primaryRepaired = true;
90
+ } catch (error) {
91
+ failures.push({ path: primaryPath, error: String(error?.message || error) });
92
+ }
93
+ }
94
+ return { value: selected.value, recoveredFromBackup, primaryRepaired, failures };
95
+ }
96
+
97
+ export function clearDurableAuthSessionRecord({
98
+ primaryPath,
99
+ backupPath = `${primaryPath}.backup`
100
+ }) {
101
+ for (const path of [primaryPath, backupPath]) {
102
+ try { rmSync(path, { force: true }); } catch { /* best effort */ }
103
+ }
104
+ }
package/electron/main.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, powerMonitor, safeStorage, shell, Tray } from 'electron';
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { spawn } from 'node:child_process';
@@ -7,7 +7,12 @@ import { createProductUpdateManager } from './update-manager.mjs';
7
7
  import { createRecurringProductUpdateOwner } from './recurring-update-owner.mjs';
8
8
  import { createPkceAuthorizationUrl, exchangePkceCode, fetchSupabaseUser, parsePkceCallbackUrl, refreshSupabaseSession } from './oauth-pkce.mjs';
9
9
  import { getRuntimeAuthEnvironment, resolveDesktopAuthConfig, resolveDesktopBuildFlavor } from './auth-config.mjs';
10
- import { createBoundedDesktopLogger } from './bounded-desktop-log.mjs';
10
+ import { createBoundedDesktopLogger } from './bounded-desktop-log.mjs';
11
+ import {
12
+ clearDurableAuthSessionRecord,
13
+ readDurableAuthSessionRecord,
14
+ writeDurableAuthSessionRecord
15
+ } from './durable-auth-session-file.mjs';
11
16
  import {
12
17
  DESKTOP_AUTH_RETRY_MS,
13
18
  createDesktopAuthInvalidationGuard,
@@ -42,7 +47,8 @@ import {
42
47
  // warm-idle process baseline instead of retaining a hidden utility process.
43
48
  app.commandLine.appendSwitch('disable-features', 'AudioServiceOutOfProcess');
44
49
 
45
- const APP_NAME = 'LiveDesk Desktop';
50
+ const APP_NAME = 'LiveDesk Desktop';
51
+ const appWindowTitle = () => `${APP_NAME} · ${app.getVersion()}`;
46
52
  const LOCAL_RUNTIME_PORT = Number(process.env.LIVEDESK_LOCAL_RUNTIME_PORT || 5179);
47
53
  const LOCAL_RUNTIME_URL = `http://127.0.0.1:${Number.isInteger(LOCAL_RUNTIME_PORT) && LOCAL_RUNTIME_PORT > 0 ? LOCAL_RUNTIME_PORT : 5179}/`;
48
54
  function readDesktopPackageMetadata() {
@@ -72,7 +78,8 @@ const stateRoot = configuredStateRoot
72
78
  : join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'LiveDesk');
73
79
  const logDir = join(stateRoot, 'logs');
74
80
  const logPath = join(logDir, 'desktop.log');
75
- const authSessionPath = join(stateRoot, 'auth-session.json');
81
+ const authSessionPath = join(stateRoot, 'auth-session.json');
82
+ const authSessionBackupPath = join(stateRoot, 'auth-session.backup.json');
76
83
  const runtimeStateDir = process.env.LIVEDESK_STATE_DIR || join(homedir(), '.livedesk');
77
84
  try { app.setPath('userData', stateRoot); } catch { /* Electron may reject late path changes in embedded test hosts. */ }
78
85
  const appPath = app.getAppPath();
@@ -113,7 +120,8 @@ let runtimeLogoutPromise = null;
113
120
  let desktopAuthRefreshTimer = null;
114
121
  let desktopSessionRestorePromise = null;
115
122
  let lastDesktopResumeRecoveryAt = 0;
116
- let desktopSessionEpoch = 0;
123
+ let desktopSessionEpoch = 0;
124
+ let lastKnownDesktopSession = null;
117
125
  const desktopAuthInvalidationGuard = createDesktopAuthInvalidationGuard();
118
126
  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>')}`;
119
127
  const desktopLogger = createBoundedDesktopLogger({ logPath });
@@ -177,32 +185,55 @@ function normalizeSession(session) {
177
185
  };
178
186
  }
179
187
 
180
- function saveEncryptedSession(session) {
181
- const normalized = normalizeSession(session);
182
- if (!normalized) throw new Error('oauth-session-invalid');
183
- if (!safeStorage.isEncryptionAvailable()) throw new Error('secure-storage-unavailable');
184
- mkdirSync(stateRoot, { recursive: true });
185
- const encryptedSession = safeStorage.encryptString(JSON.stringify(normalized)).toString('base64');
186
- writeFileSync(authSessionPath, JSON.stringify({ version: 1, encryptedSession }, null, 2), { encoding: 'utf8', mode: 0o600 });
187
- return normalized;
188
- }
189
-
190
- function readEncryptedSession() {
191
- if (!safeStorage.isEncryptionAvailable()) return null;
192
- try {
193
- const saved = JSON.parse(readFileSync(authSessionPath, 'utf8'));
194
- if (!saved?.encryptedSession) return null;
195
- const session = JSON.parse(safeStorage.decryptString(Buffer.from(saved.encryptedSession, 'base64')));
196
- return normalizeSession(session);
197
- } catch (error) {
198
- log(`secure auth session read failed: ${error?.message || error}`);
199
- return null;
200
- }
201
- }
202
-
203
- function clearEncryptedSession() {
204
- clearDesktopAuthRefreshTimer();
205
- try { rmSync(authSessionPath, { force: true }); } catch { /* best effort */ }
188
+ function saveEncryptedSession(session) {
189
+ const normalized = normalizeSession(session);
190
+ if (!normalized) throw new Error('oauth-session-invalid');
191
+ if (!safeStorage.isEncryptionAvailable()) throw new Error('secure-storage-unavailable');
192
+ const encryptedSession = safeStorage.encryptString(JSON.stringify(normalized)).toString('base64');
193
+ const persisted = writeDurableAuthSessionRecord({
194
+ primaryPath: authSessionPath,
195
+ backupPath: authSessionBackupPath,
196
+ record: { version: 2, savedAt: new Date().toISOString(), encryptedSession }
197
+ });
198
+ if (!persisted.backupWritten) {
199
+ log(`secure auth session backup write failed: ${persisted.backupError?.message || persisted.backupError}`);
200
+ }
201
+ lastKnownDesktopSession = normalized;
202
+ return normalized;
203
+ }
204
+
205
+ function readEncryptedSession() {
206
+ if (!safeStorage.isEncryptionAvailable()) return lastKnownDesktopSession;
207
+ const restored = readDurableAuthSessionRecord({
208
+ primaryPath: authSessionPath,
209
+ backupPath: authSessionBackupPath,
210
+ decode: saved => {
211
+ if (!saved?.encryptedSession) return null;
212
+ const session = JSON.parse(safeStorage.decryptString(Buffer.from(saved.encryptedSession, 'base64')));
213
+ return normalizeSession(session);
214
+ }
215
+ });
216
+ if (restored.failures.length > 0) {
217
+ log(`secure auth session read recovered=${restored.recoveredFromBackup} failures=${restored.failures.length}`);
218
+ }
219
+ if (restored.recoveredFromBackup) log('secure auth session primary restored from encrypted backup');
220
+ if (!restored.value && restored.failures.length > 0) return lastKnownDesktopSession;
221
+ if (restored.value && !existsSync(authSessionBackupPath)) {
222
+ try {
223
+ saveEncryptedSession(restored.value);
224
+ log('secure auth session upgraded to atomic mirrored storage');
225
+ } catch (error) {
226
+ log(`secure auth session mirror upgrade deferred: ${error?.message || error}`);
227
+ }
228
+ }
229
+ lastKnownDesktopSession = restored.value;
230
+ return restored.value;
231
+ }
232
+
233
+ function clearEncryptedSession() {
234
+ clearDesktopAuthRefreshTimer();
235
+ lastKnownDesktopSession = null;
236
+ clearDurableAuthSessionRecord({ primaryPath: authSessionPath, backupPath: authSessionBackupPath });
206
237
  }
207
238
 
208
239
  function migrateLegacyPlaintextSession() {
@@ -873,11 +904,12 @@ function updateTray(status = 'Starting') {
873
904
  tray.setToolTip(`${APP_NAME} — ${status}`);
874
905
  }
875
906
 
876
- function showWindow() {
877
- if (!mainWindow) return;
878
- mainWindow.show();
879
- mainWindow.focus();
880
- }
907
+ function showWindow() {
908
+ if (!mainWindow) return;
909
+ mainWindow.show();
910
+ mainWindow.focus();
911
+ requestDesktopResumeAuthRecovery('window-show');
912
+ }
881
913
 
882
914
  function createMainWindow() {
883
915
  const iconPath = appResource('electron', 'icon.png');
@@ -889,7 +921,7 @@ function createMainWindow() {
889
921
  minHeight: 720,
890
922
  show: false,
891
923
  autoHideMenuBar: true,
892
- title: APP_NAME,
924
+ title: appWindowTitle(),
893
925
  icon: existsSync(iconPath) ? iconPath : nativeImage.createFromDataURL(FALLBACK_ICON_DATA_URL),
894
926
  webPreferences: {
895
927
  preload: appResource('electron', 'preload.cjs'),
@@ -898,7 +930,12 @@ function createMainWindow() {
898
930
  sandbox: true,
899
931
  spellcheck: false
900
932
  }
901
- });
933
+ });
934
+ mainWindow.on('page-title-updated', event => {
935
+ event.preventDefault();
936
+ mainWindow?.setTitle(appWindowTitle());
937
+ });
938
+ mainWindow.on('focus', () => requestDesktopResumeAuthRecovery('window-focus'));
902
939
  mainWindow.webContents.on('will-navigate', (event, url) => {
903
940
  if (isAllowedRuntimeUrl(url)) return;
904
941
  event.preventDefault();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.573",
3
+ "version": "0.1.575",
4
4
  "livedeskClientVersion": "0.1.249",
5
5
  "buildFlavor": "production",
6
6
  "description": "LiveDesk Hub and client launcher",