livedesk 0.1.541 → 0.1.543

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,40 @@
1
+ export const DESKTOP_AUTH_REFRESH_SKEW_SECONDS = 5 * 60;
2
+ export const DESKTOP_AUTH_RETRY_MS = 15_000;
3
+ export const DESKTOP_AUTH_MINIMUM_TIMER_MS = 30_000;
4
+
5
+ export function desktopSessionNeedsRefresh(
6
+ session,
7
+ nowSeconds = Math.floor(Date.now() / 1000),
8
+ skewSeconds = DESKTOP_AUTH_REFRESH_SKEW_SECONDS
9
+ ) {
10
+ const expiresAt = Number(session?.expires_at || 0);
11
+ return !Number.isFinite(expiresAt)
12
+ || expiresAt <= Number(nowSeconds || 0) + Math.max(0, Number(skewSeconds || 0));
13
+ }
14
+
15
+ export function desktopSessionIsUsable(session, nowSeconds = Math.floor(Date.now() / 1000)) {
16
+ const expiresAt = Number(session?.expires_at || 0);
17
+ return Boolean(session?.access_token && session?.refresh_token)
18
+ && Number.isFinite(expiresAt)
19
+ && expiresAt > Number(nowSeconds || 0) + 5;
20
+ }
21
+
22
+ export function desktopSessionRefreshDelayMs(
23
+ session,
24
+ nowMs = Date.now(),
25
+ skewSeconds = DESKTOP_AUTH_REFRESH_SKEW_SECONDS
26
+ ) {
27
+ const expiresAtMs = Number(session?.expires_at || 0) * 1000;
28
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) {
29
+ return DESKTOP_AUTH_RETRY_MS;
30
+ }
31
+ return Math.max(
32
+ DESKTOP_AUTH_MINIMUM_TIMER_MS,
33
+ expiresAtMs - Number(nowMs || 0) - Math.max(0, Number(skewSeconds || 0)) * 1000
34
+ );
35
+ }
36
+
37
+ export function isPermanentDesktopAuthRefreshFailure(error) {
38
+ const status = Number(error?.authStatus || 0);
39
+ return status === 400 || status === 401 || status === 403;
40
+ }
package/electron/main.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, safeStorage, shell, Tray } from 'electron';
1
+ import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, powerMonitor, safeStorage, shell, Tray } from 'electron';
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
@@ -6,7 +6,14 @@ import { spawn } from 'node:child_process';
6
6
  import { createProductUpdateManager } from './update-manager.mjs';
7
7
  import { createPkceAuthorizationUrl, exchangePkceCode, fetchSupabaseUser, parsePkceCallbackUrl, refreshSupabaseSession } from './oauth-pkce.mjs';
8
8
  import { getRuntimeAuthEnvironment, resolveDesktopAuthConfig, resolveDesktopBuildFlavor } from './auth-config.mjs';
9
- import { createBoundedDesktopLogger } from './bounded-desktop-log.mjs';
9
+ import { createBoundedDesktopLogger } from './bounded-desktop-log.mjs';
10
+ import {
11
+ DESKTOP_AUTH_RETRY_MS,
12
+ desktopSessionIsUsable,
13
+ desktopSessionNeedsRefresh,
14
+ desktopSessionRefreshDelayMs,
15
+ isPermanentDesktopAuthRefreshFailure
16
+ } from './auth-session-owner.mjs';
10
17
  import { resolveDeviceRole } from '../bootstrap/device-role.js';
11
18
  import {
12
19
  registerOwnedWindowsChild,
@@ -92,8 +99,11 @@ let quitSequenceStarted = false;
92
99
  let updateInstallSequenceStarted = false;
93
100
  let productUpdates = null;
94
101
  let pendingOAuth = null;
95
- let pendingProtocolUrl = '';
96
- let runtimeLogoutPromise = null;
102
+ let pendingProtocolUrl = '';
103
+ let runtimeLogoutPromise = null;
104
+ let desktopAuthRefreshTimer = null;
105
+ let desktopSessionRestorePromise = null;
106
+ let lastDesktopResumeRecoveryAt = 0;
97
107
  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>')}`;
98
108
  const desktopLogger = createBoundedDesktopLogger({ logPath });
99
109
 
@@ -179,9 +189,10 @@ function readEncryptedSession() {
179
189
  }
180
190
  }
181
191
 
182
- function clearEncryptedSession() {
183
- try { rmSync(authSessionPath, { force: true }); } catch { /* best effort */ }
184
- }
192
+ function clearEncryptedSession() {
193
+ clearDesktopAuthRefreshTimer();
194
+ try { rmSync(authSessionPath, { force: true }); } catch { /* best effort */ }
195
+ }
185
196
 
186
197
  function migrateLegacyPlaintextSession() {
187
198
  if (!safeStorage.isEncryptionAvailable()) return;
@@ -608,7 +619,7 @@ async function clearRuntimeSessionWithRecovery() {
608
619
  }
609
620
  }
610
621
 
611
- async function validateSessionWithSupabase(session) {
622
+ async function validateSessionWithSupabase(session) {
612
623
  const normalized = normalizeSession(session);
613
624
  if (!normalized) throw new Error('oauth-session-invalid');
614
625
  const user = await fetchSupabaseUser({
@@ -619,35 +630,97 @@ async function validateSessionWithSupabase(session) {
619
630
  if (normalized.user?.id && String(normalized.user.id) !== String(user.id)) {
620
631
  throw new Error('oauth-session-user-mismatch');
621
632
  }
622
- return { ...normalized, user };
623
- }
624
-
625
- async function restoreEncryptedSession({ syncRuntime = true } = {}) {
626
- let session = readEncryptedSession();
627
- if (!session) return null;
628
- if (session.expires_at && session.expires_at <= Math.floor(Date.now() / 1000) + 60) {
629
- try {
630
- session = await refreshSupabaseSession({
631
- supabaseUrl: SUPABASE_URL,
632
- publishableKey: SUPABASE_PUBLISHABLE_KEY,
633
- refreshToken: session.refresh_token
634
- });
635
- saveEncryptedSession(session);
636
- } catch (error) {
637
- log(`secure auth session refresh failed: ${error?.message || error}`);
638
- clearEncryptedSession();
639
- return null;
640
- }
641
- }
642
- if (!syncRuntime) return session;
643
- try {
644
- await syncSessionToRuntime(session);
645
- return session;
646
- } catch (error) {
647
- log(`secure auth session runtime sync failed: ${error?.message || error}`);
648
- return null;
649
- }
650
- }
633
+ return { ...normalized, user };
634
+ }
635
+
636
+ function clearDesktopAuthRefreshTimer() {
637
+ if (!desktopAuthRefreshTimer) return;
638
+ clearTimeout(desktopAuthRefreshTimer);
639
+ desktopAuthRefreshTimer = null;
640
+ }
641
+
642
+ function scheduleDesktopAuthRefresh(session, delayMs = desktopSessionRefreshDelayMs(session)) {
643
+ clearDesktopAuthRefreshTimer();
644
+ if (!session || isQuitting) return;
645
+ desktopAuthRefreshTimer = setTimeout(() => {
646
+ desktopAuthRefreshTimer = null;
647
+ void restoreEncryptedSession({ syncRuntime: true }).then(restored => {
648
+ if (desktopSessionIsUsable(restored)) {
649
+ sendAuthEvent({ ok: true, session: restored, restored: true });
650
+ }
651
+ }).catch(error => {
652
+ log(`scheduled secure auth session recovery failed: ${error?.message || error}`);
653
+ });
654
+ }, Math.max(1_000, Number(delayMs) || DESKTOP_AUTH_RETRY_MS));
655
+ desktopAuthRefreshTimer.unref?.();
656
+ }
657
+
658
+ async function restoreEncryptedSessionOwned({ syncRuntime = true } = {}) {
659
+ let session = readEncryptedSession();
660
+ if (!session) return null;
661
+ if (desktopSessionNeedsRefresh(session)) {
662
+ try {
663
+ const refreshed = await refreshSupabaseSession({
664
+ supabaseUrl: SUPABASE_URL,
665
+ publishableKey: SUPABASE_PUBLISHABLE_KEY,
666
+ refreshToken: session.refresh_token
667
+ });
668
+ session = {
669
+ ...session,
670
+ ...refreshed,
671
+ user: refreshed?.user && typeof refreshed.user === 'object' ? refreshed.user : session.user
672
+ };
673
+ saveEncryptedSession(session);
674
+ } catch (error) {
675
+ log(`secure auth session refresh failed: ${error?.message || error}`);
676
+ if (isPermanentDesktopAuthRefreshFailure(error)) {
677
+ clearEncryptedSession();
678
+ sendAuthEvent({ ok: true, signedOut: true, reason: 'refresh-rejected' });
679
+ return null;
680
+ }
681
+ // A sleeping computer commonly resumes before DNS and Wi-Fi. Keep the
682
+ // encrypted refresh token and retry instead of turning an outage into a
683
+ // user-visible sign-out.
684
+ scheduleDesktopAuthRefresh(session, DESKTOP_AUTH_RETRY_MS);
685
+ return session;
686
+ }
687
+ }
688
+ if (syncRuntime) {
689
+ try {
690
+ await syncSessionToRuntime(session);
691
+ } catch (error) {
692
+ log(`secure auth session runtime sync failed: ${error?.message || error}`);
693
+ scheduleDesktopAuthRefresh(session, DESKTOP_AUTH_RETRY_MS);
694
+ return session;
695
+ }
696
+ }
697
+ scheduleDesktopAuthRefresh(session);
698
+ return session;
699
+ }
700
+
701
+ async function restoreEncryptedSession(options = {}) {
702
+ if (desktopSessionRestorePromise) return desktopSessionRestorePromise;
703
+ const operation = restoreEncryptedSessionOwned(options);
704
+ desktopSessionRestorePromise = operation;
705
+ try {
706
+ return await operation;
707
+ } finally {
708
+ if (desktopSessionRestorePromise === operation) desktopSessionRestorePromise = null;
709
+ }
710
+ }
711
+
712
+ function requestDesktopResumeAuthRecovery(source) {
713
+ const now = Date.now();
714
+ if (now - lastDesktopResumeRecoveryAt < 1_500) return;
715
+ lastDesktopResumeRecoveryAt = now;
716
+ void restoreEncryptedSession({ syncRuntime: true }).then(session => {
717
+ if (!desktopSessionIsUsable(session)) return;
718
+ log(`secure auth session recovered after ${source}`);
719
+ sendAuthEvent({ ok: true, session, restored: true, resumed: true });
720
+ }).catch(error => {
721
+ log(`secure auth session ${source} recovery failed: ${error?.message || error}`);
722
+ });
723
+ }
651
724
 
652
725
  async function resolveStartupRole(session) {
653
726
  try {
@@ -739,9 +812,10 @@ async function handleProtocolUrl(rawUrl) {
739
812
  code,
740
813
  codeVerifier: request.codeVerifier
741
814
  }));
742
- pendingOAuth = null;
743
- await syncSessionToRuntime(session);
744
- sendAuthEvent({ ok: true, session });
815
+ pendingOAuth = null;
816
+ await syncSessionToRuntime(session);
817
+ scheduleDesktopAuthRefresh(session);
818
+ sendAuthEvent({ ok: true, session });
745
819
  showWindow();
746
820
  } catch (error) {
747
821
  const message = String(error?.message || error);
@@ -792,11 +866,14 @@ function createMainWindow() {
792
866
  void shell.openExternal(url);
793
867
  return { action: 'deny' };
794
868
  });
795
- mainWindow.loadURL(LOCAL_RUNTIME_URL);
796
- mainWindow.webContents.on('did-finish-load', () => {
797
- const session = readEncryptedSession();
798
- if (session) sendAuthEvent({ ok: true, session, restored: true });
799
- });
869
+ mainWindow.loadURL(LOCAL_RUNTIME_URL);
870
+ mainWindow.webContents.on('did-finish-load', () => {
871
+ void restoreEncryptedSession({ syncRuntime: true }).then(session => {
872
+ if (desktopSessionIsUsable(session)) sendAuthEvent({ ok: true, session, restored: true });
873
+ }).catch(error => {
874
+ log(`secure auth session window restore failed: ${error?.message || error}`);
875
+ });
876
+ });
800
877
  mainWindow.once('ready-to-show', () => { updateTray('Running'); if (process.env.LIVEDESK_E2E_HEADLESS !== '1') showWindow(); });
801
878
  mainWindow.on('close', event => {
802
879
  if (!isQuitting) {
@@ -841,12 +918,13 @@ function registerIpc() {
841
918
  }
842
919
  });
843
920
  handle('auth:sign-in-google', () => beginGoogleAuth());
844
- handle('auth:get-session', () => readEncryptedSession());
845
- handle('auth:persist-session', async session => {
846
- if (session) {
847
- try {
848
- saveEncryptedSession(await validateSessionWithSupabase(session));
849
- return { ok: true };
921
+ handle('auth:get-session', () => restoreEncryptedSession({ syncRuntime: true }));
922
+ handle('auth:persist-session', async session => {
923
+ if (session) {
924
+ try {
925
+ const validated = saveEncryptedSession(await validateSessionWithSupabase(session));
926
+ scheduleDesktopAuthRefresh(validated);
927
+ return { ok: true };
850
928
  } catch (error) {
851
929
  const message = String(error?.message || error);
852
930
  log(`renderer auth session validation failed: ${message}`);
@@ -934,16 +1012,18 @@ if (!app.requestSingleInstanceLock()) {
934
1012
  app.setAsDefaultProtocolClient('livedesk');
935
1013
  removeLegacyStartupEntries();
936
1014
  migrateLegacyPlaintextSession();
937
- productUpdates = createProductUpdateManager({
1015
+ productUpdates = createProductUpdateManager({
938
1016
  app,
939
1017
  onStatus: status => {
940
1018
  if (status.state === 'available') updateTray(`Update ${status.availableVersion} available`);
941
1019
  },
942
1020
  beforeInstall: prepareRuntimeForUpdateInstall,
943
1021
  onInstallError: recoverRuntimeAfterUpdateInstallError
944
- });
945
- registerIpc();
946
- const startupSession = await restoreEncryptedSession({ syncRuntime: false });
1022
+ });
1023
+ registerIpc();
1024
+ powerMonitor.on('resume', () => requestDesktopResumeAuthRecovery('system-resume'));
1025
+ powerMonitor.on('unlock-screen', () => requestDesktopResumeAuthRecovery('screen-unlock'));
1026
+ const startupSession = await restoreEncryptedSession({ syncRuntime: false });
947
1027
  const forceBootstrapForE2e = DESKTOP_BUILD_FLAVOR === 'e2e' && process.env.LIVEDESK_E2E_ROLE_BOOTSTRAP === '1';
948
1028
  const startupRole = forceBootstrapForE2e ? null : await resolveStartupRole(startupSession);
949
1029
  startRuntime(startupRole || {});
@@ -115,7 +115,7 @@ export async function exchangePkceCode({
115
115
  return payload;
116
116
  }
117
117
 
118
- export async function refreshSupabaseSession({
118
+ export async function refreshSupabaseSession({
119
119
  supabaseUrl,
120
120
  publishableKey,
121
121
  refreshToken,
@@ -135,9 +135,12 @@ export async function refreshSupabaseSession({
135
135
  },
136
136
  body: JSON.stringify({ refresh_token: refreshToken })
137
137
  }, timeoutMs);
138
- const payload = await response.json().catch(() => ({}));
139
- if (!response.ok || !payload?.access_token || !payload?.refresh_token) {
140
- throw new Error(String(payload?.msg || payload?.error_description || payload?.error || `oauth-refresh-failed:${response.status}`));
141
- }
142
- return payload;
143
- }
138
+ const payload = await response.json().catch(() => ({}));
139
+ if (!response.ok || !payload?.access_token || !payload?.refresh_token) {
140
+ const error = new Error(String(payload?.msg || payload?.error_description || payload?.error || `oauth-refresh-failed:${response.status}`));
141
+ error.authStatus = response.status;
142
+ error.authCode = String(payload?.error_code || payload?.code || payload?.error || '');
143
+ throw error;
144
+ }
145
+ return payload;
146
+ }
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
package/hub/src/server.js CHANGED
@@ -513,11 +513,19 @@ async function getRuntimeAccessToken() {
513
513
  const accessToken = String(runtimeAccessToken || '').trim();
514
514
  const expiresSoon = runtimeAccessTokenExpiresAt > 0
515
515
  && runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
516
- if (accessToken && !expiresSoon) {
517
- return accessToken;
518
- }
519
-
520
- const refreshToken = String(runtimeRefreshToken || '').trim();
516
+ if (accessToken && !expiresSoon) {
517
+ return accessToken;
518
+ }
519
+
520
+ // The packaged Electron main process owns the encrypted refresh token and
521
+ // proactively sends each rotation to this child runtime. A second refresh
522
+ // owner here could consume the one-time token first and make the desktop
523
+ // shell appear signed out after sleep.
524
+ if (desktopMainAuthConfig) {
525
+ return accessToken;
526
+ }
527
+
528
+ const refreshToken = String(runtimeRefreshToken || '').trim();
521
529
  if (!refreshToken) {
522
530
  return accessToken;
523
531
  }
@@ -621,9 +629,9 @@ async function queryAuthoritativeRuntimeRole() {
621
629
  },
622
630
  SUPABASE_AUTH_TIMEOUT_MS
623
631
  );
624
- if (response.status === 401 || response.status === 403) {
625
- clearRuntimeSession();
626
- throw new Error(`hub-role-query-not-authenticated:${response.status}`);
632
+ if (response.status === 401 || response.status === 403) {
633
+ if (!desktopMainAuthConfig) clearRuntimeSession();
634
+ throw new Error(`hub-role-query-not-authenticated:${response.status}`);
627
635
  }
628
636
  if (!response.ok) {
629
637
  throw new Error(`hub-role-query-failed:${response.status}`);
@@ -4601,9 +4609,9 @@ async function callHubSupabaseRpc(name, payload, accessToken) {
4601
4609
  );
4602
4610
  const data = await response.json().catch(() => null);
4603
4611
  const result = Array.isArray(data) ? data[0] : data;
4604
- if (response.status === 401 || response.status === 403) {
4605
- clearRuntimeSession();
4606
- throw new Error(`${name}-not-authenticated:${response.status}`);
4612
+ if (response.status === 401 || response.status === 403) {
4613
+ if (!desktopMainAuthConfig) clearRuntimeSession();
4614
+ throw new Error(`${name}-not-authenticated:${response.status}`);
4607
4615
  }
4608
4616
  if (!response.ok) {
4609
4617
  throw new Error(`${name}-failed:${response.status}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.541",
3
+ "version": "0.1.543",
4
4
  "livedeskClientVersion": "0.1.246",
5
5
  "buildFlavor": "production",
6
6
  "description": "LiveDesk Hub and client launcher",
@@ -4,7 +4,7 @@
4
4
  "short_name": "LiveDesk",
5
5
  "description": "Monitor and control your LiveDesk computers from your phone.",
6
6
  "lang": "en",
7
- "start_url": "/?pwa=0.1.541",
7
+ "start_url": "/?pwa=0.1.543",
8
8
  "scope": "/",
9
9
  "display": "standalone",
10
10
  "orientation": "any",