@livedesk/client 0.1.225 → 0.1.227

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.
@@ -90,6 +90,7 @@ let agentRestartRequest = null;
90
90
  let linuxVideoAccelerationStatus = null;
91
91
  let discoveryWakeController = new AbortController();
92
92
  let networkChangeMonitor = null;
93
+ let deviceRoleMutationTail = Promise.resolve();
93
94
  const sessionRefreshesInFlight = new WeakMap();
94
95
  const disposeAgentTerminationHandlers = installAgentTerminationHandlers({
95
96
  getAgentProcess: () => activeAgentProcess
@@ -177,6 +178,17 @@ export function requestLocalDiscoveryWake(reason = 'local-trigger') {
177
178
  previous.abort(String(reason || 'local-trigger'));
178
179
  }
179
180
 
181
+ export function enqueueDeviceRoleMutation(operation) {
182
+ if (typeof operation !== 'function') {
183
+ return Promise.reject(new TypeError('device-role-mutation-operation-required'));
184
+ }
185
+ const pending = deviceRoleMutationTail
186
+ .catch(() => undefined)
187
+ .then(operation);
188
+ deviceRoleMutationTail = pending.catch(() => undefined);
189
+ return pending;
190
+ }
191
+
180
192
  function networkInterfaceSignature() {
181
193
  return Object.entries(os.networkInterfaces())
182
194
  .flatMap(([name, entries]) => (entries || [])
@@ -1204,12 +1216,18 @@ function isTransientNetworkError(error) {
1204
1216
  return /fetch failed|network|dns|socket|connection|timeout|getaddrinfo/i.test(getNestedErrorMessage(error));
1205
1217
  }
1206
1218
 
1207
- function formatDiscoveryError(error) {
1219
+ export function formatDiscoveryError(error) {
1208
1220
  if (isTransientNetworkError(error)) {
1209
1221
  const code = getNestedErrorCode(error);
1210
1222
  return `Network is not ready yet${code ? ` (${code})` : ''}. Waiting for DNS/Wi-Fi after sleep.`;
1211
1223
  }
1212
- return error instanceof Error ? error.message : String(error);
1224
+ const message = getNestedErrorMessage(error);
1225
+ if (message) return message;
1226
+ for (const key of ['error_description', 'reason', 'details', 'hint', 'error', 'code']) {
1227
+ const value = error?.[key];
1228
+ if (typeof value === 'string' && value.trim()) return value.trim();
1229
+ }
1230
+ return 'LiveDesk could not read the current Hub registration yet.';
1213
1231
  }
1214
1232
 
1215
1233
  function createHubDiscoveryError(code, message, cause = null) {
@@ -1308,10 +1326,13 @@ async function refreshSessionIfNeededCore(supabase) {
1308
1326
  return null;
1309
1327
  }
1310
1328
  const session = { ...candidate, ...normalized.session };
1311
- const expiresAt = Number(session.expires_at || 0);
1312
- if (expiresAt <= 0 || expiresAt - Math.floor(Date.now() / 1000) > SESSION_REFRESH_SKEW_SECONDS) {
1313
- writeSavedSessionToFile(session);
1314
- return session;
1329
+ const expiresAt = Number(session.expires_at || 0);
1330
+ if (expiresAt <= 0 || expiresAt - Math.floor(Date.now() / 1000) > SESSION_REFRESH_SKEW_SECONDS) {
1331
+ // A persisted session is not automatically installed into this
1332
+ // memory-only Supabase client after a process restart. Returning the
1333
+ // file value without setSession makes the UI say "Signed in" while
1334
+ // PostgREST still sends the publishable-key request as anon.
1335
+ return activateSupabaseSession(supabase, session);
1315
1336
  }
1316
1337
  const { data, error } = await supabase.auth.refreshSession(session);
1317
1338
  if (error) {
@@ -1387,6 +1408,27 @@ async function activateSupabaseSession(supabase, session) {
1387
1408
  }
1388
1409
  return activeSession;
1389
1410
  }
1411
+
1412
+ export async function activateRoleChangeSession(supabase, currentSession = null) {
1413
+ if (!supabase?.auth) {
1414
+ throw new Error('Google sign-in is temporarily unavailable. Try again in a moment.');
1415
+ }
1416
+ const current = normalizeRuntimeAuthSession(currentSession, { requireRefreshToken: true });
1417
+ let session = current.ok ? current.session : await refreshSessionIfNeeded(supabase);
1418
+ if (!session?.access_token) {
1419
+ throw new Error('Sign in again before switching this computer to Hub.');
1420
+ }
1421
+ try {
1422
+ return await activateSupabaseSession(supabase, session);
1423
+ } catch (error) {
1424
+ if (!isRefreshTokenAlreadyUsedError(error)) throw error;
1425
+ session = await refreshSessionIfNeeded(supabase);
1426
+ if (!session?.access_token) {
1427
+ throw new Error('Sign in again before switching this computer to Hub.');
1428
+ }
1429
+ return activateSupabaseSession(supabase, session);
1430
+ }
1431
+ }
1390
1432
 
1391
1433
  function escapeHtml(value) {
1392
1434
  return String(value ?? '')
@@ -3105,14 +3147,17 @@ async function startConnectionChoiceServer(supabase, options = {}) {
3105
3147
  res.end(JSON.stringify(hubClientPortPreflightError(portPreflight)));
3106
3148
  return;
3107
3149
  }
3108
- const session = await refreshSessionIfNeeded(supabase);
3109
- const expectedRoleVersion = Number(dashboardState.roleVersion || 0);
3110
- const { data, error } = await supabase.rpc('set_livedesk_device_role', {
3111
- p_device_id: deviceId,
3112
- p_role: 'hub',
3113
- p_assigned_hub_id: null,
3114
- p_expected_role_version: expectedRoleVersion > 0 ? expectedRoleVersion : null
3115
- });
3150
+ const session = await activateRoleChangeSession(
3151
+ supabase,
3152
+ dashboardState.choice?.session || savedSession
3153
+ );
3154
+ const expectedRoleVersion = Number(dashboardState.roleVersion || 0);
3155
+ const { data, error } = await enqueueDeviceRoleMutation(() => supabase.rpc('set_livedesk_device_role', {
3156
+ p_device_id: deviceId,
3157
+ p_role: 'hub',
3158
+ p_assigned_hub_id: null,
3159
+ p_expected_role_version: expectedRoleVersion > 0 ? expectedRoleVersion : null
3160
+ }));
3116
3161
  const result = Array.isArray(data) ? data[0] : data;
3117
3162
  if (error || result?.ok === false || !session?.access_token) {
3118
3163
  const reason = error?.message || result?.reason || 'role-change-rejected';
@@ -3415,35 +3460,48 @@ async function chooseClientConnection(supabase, options = {}) {
3415
3460
  relayEndpoint: options.relayEndpoint
3416
3461
  });
3417
3462
  },
3418
- changeRole: async (role, snapshot) => {
3463
+ changeRole: async (role, snapshot, currentSession) => {
3464
+ console.log('[LiveDesk Client] Hub role transition requested. Validating the signed-in session and local Hub port.');
3419
3465
  const activeSupabase = await getSupabase();
3420
3466
  if (!activeSupabase) {
3421
- return { ok: false, error: 'supabase-session-required' };
3467
+ console.warn('[LiveDesk Client] Hub role transition rejected: Google sign-in is temporarily unavailable.');
3468
+ return { ok: false, error: 'Google sign-in is temporarily unavailable. Try again in a moment.' };
3422
3469
  }
3423
3470
  const portPreflight = await preflightHubClientPort();
3424
3471
  if (!portPreflight.ok) {
3472
+ console.warn(`[LiveDesk Client] Hub role transition rejected: ${hubClientPortPreflightError(portPreflight).error}`);
3425
3473
  return hubClientPortPreflightError(portPreflight);
3426
3474
  }
3427
- const session = await refreshSessionIfNeeded(activeSupabase);
3428
- if (!session?.access_token) {
3429
- return { ok: false, error: 'supabase-session-required' };
3430
- }
3431
- const expectedRoleVersion = Number(snapshot?.roleVersion || 0);
3432
- const { data, error } = await activeSupabase.rpc('set_livedesk_device_role', {
3433
- p_device_id: options.deviceId,
3434
- p_role: role,
3435
- p_assigned_hub_id: null,
3436
- p_expected_role_version: expectedRoleVersion > 0 ? expectedRoleVersion : null
3437
- });
3438
- const result = Array.isArray(data) ? data[0] : data;
3439
- if (error || result?.ok === false) {
3440
- return { ok: false, error: error?.message || result?.reason || 'role-change-rejected' };
3441
- }
3475
+ let session;
3476
+ try {
3477
+ session = await activateRoleChangeSession(activeSupabase, currentSession);
3478
+ } catch (error) {
3479
+ console.warn(`[LiveDesk Client] Hub role transition rejected: ${formatDiscoveryError(error)}`);
3480
+ return { ok: false, error: formatDiscoveryError(error) };
3481
+ }
3482
+ if (!session?.access_token) {
3483
+ console.warn('[LiveDesk Client] Hub role transition rejected: a fresh Google sign-in is required.');
3484
+ return { ok: false, error: 'Sign in again before switching this computer to Hub.' };
3485
+ }
3486
+ const expectedRoleVersion = Number(snapshot?.roleVersion || 0);
3487
+ const { data, error } = await enqueueDeviceRoleMutation(() => activeSupabase.rpc('set_livedesk_device_role', {
3488
+ p_device_id: options.deviceId,
3489
+ p_role: role,
3490
+ p_assigned_hub_id: null,
3491
+ p_expected_role_version: expectedRoleVersion > 0 ? expectedRoleVersion : null
3492
+ }));
3493
+ const result = Array.isArray(data) ? data[0] : data;
3494
+ if (error || result?.ok === false) {
3495
+ const reason = error?.message || result?.reason || 'role-change-rejected';
3496
+ console.warn(`[LiveDesk Client] Hub role transition rejected: ${reason}`);
3497
+ return { ok: false, error: reason };
3498
+ }
3442
3499
  const roleVersion = Number.isInteger(result?.role_version) ? result.role_version : expectedRoleVersion + 1;
3443
3500
  writeLocalRoleCache(role, options.deviceId, roleVersion);
3444
3501
  roleRestartRequest = { role, requestedAt: new Date().toISOString() };
3445
3502
  requestLocalDiscoveryWake('role-change');
3446
3503
  try { requestActiveAgentStop(); } catch { /* the lifecycle loop observes the role request */ }
3504
+ console.log(`[LiveDesk Client] Hub role transition accepted at role revision ${roleVersion}. Starting the Hub runtime.`);
3447
3505
  return { ok: true, restarting: true, role, roleVersion };
3448
3506
  },
3449
3507
  videoAcceleration: linuxVideoAccelerationStatus,
@@ -3853,7 +3911,9 @@ export async function runFreshHubRegistryLookup(resolveFreshTarget, options = {}
3853
3911
  }
3854
3912
  const timeoutMs = normalizeFreshRegistryTimeoutMs(options.timeoutMs);
3855
3913
  const controller = new AbortController();
3914
+ const externalSignal = options.signal;
3856
3915
  let timeoutHandle = null;
3916
+ let removeExternalAbort = () => undefined;
3857
3917
  const lookupOutcome = Promise.resolve()
3858
3918
  .then(() => resolveFreshTarget(controller.signal))
3859
3919
  .then(
@@ -3867,6 +3927,17 @@ export async function runFreshHubRegistryLookup(resolveFreshTarget, options = {}
3867
3927
  );
3868
3928
  });
3869
3929
  const outcomes = [lookupOutcome, timeoutOutcome];
3930
+ if (externalSignal) {
3931
+ outcomes.push(new Promise(resolveAbort => {
3932
+ const onAbort = () => resolveAbort({ type: 'local-trigger' });
3933
+ if (externalSignal.aborted) {
3934
+ onAbort();
3935
+ return;
3936
+ }
3937
+ externalSignal.addEventListener('abort', onAbort, { once: true });
3938
+ removeExternalAbort = () => externalSignal.removeEventListener('abort', onAbort);
3939
+ }));
3940
+ }
3870
3941
  if (options.wakePromise && typeof options.wakePromise.then === 'function') {
3871
3942
  outcomes.push(Promise.resolve(options.wakePromise).then(
3872
3943
  event => ({ type: 'hub-online', event }),
@@ -3875,7 +3946,12 @@ export async function runFreshHubRegistryLookup(resolveFreshTarget, options = {}
3875
3946
  }
3876
3947
  const outcome = await Promise.race(outcomes).finally(() => {
3877
3948
  clearTimeout(timeoutHandle);
3949
+ removeExternalAbort();
3878
3950
  });
3951
+ if (outcome.type === 'local-trigger') {
3952
+ controller.abort('local-trigger');
3953
+ return outcome;
3954
+ }
3879
3955
  if (outcome.type === 'hub-online') {
3880
3956
  controller.abort('hub-online');
3881
3957
  return outcome;
@@ -4050,22 +4126,33 @@ export async function resolveManagerFromSupabase(supabase, options = {}) {
4050
4126
  };
4051
4127
  }
4052
4128
 
4053
- async function registerClientDeviceWithSupabase(supabase, options = {}) {
4054
- if (!options.deviceId || !options.session?.access_token) {
4055
- return null;
4056
- }
4057
- try {
4058
- const { data, error } = await supabase.rpc('register_livedesk_device', {
4059
- p_device_id: options.deviceId,
4060
- p_device_name: options.deviceName || os.hostname(),
4061
- p_role: 'client',
4062
- p_assigned_hub_id: options.assignedHubId || null,
4063
- p_platform: os.platform(),
4064
- p_os_version: os.release(),
4065
- p_app_version: readPackageVersion()
4066
- });
4067
- const result = Array.isArray(data) ? data[0] : data;
4068
- if (error || result?.ok === false) {
4129
+ async function registerClientDeviceWithSupabase(supabase, options = {}) {
4130
+ if (!options.deviceId || !options.session?.access_token) {
4131
+ return null;
4132
+ }
4133
+ if (roleRestartRequest?.role) {
4134
+ return { ok: false, skipped: true, reason: 'role-transition-pending' };
4135
+ }
4136
+ try {
4137
+ const { data, error } = await enqueueDeviceRoleMutation(() => {
4138
+ if (roleRestartRequest?.role) {
4139
+ return { data: { ok: false, skipped: true, reason: 'role-transition-pending' }, error: null };
4140
+ }
4141
+ return supabase.rpc('register_livedesk_device', {
4142
+ p_device_id: options.deviceId,
4143
+ p_device_name: options.deviceName || os.hostname(),
4144
+ p_role: 'client',
4145
+ p_assigned_hub_id: options.assignedHubId || null,
4146
+ p_platform: os.platform(),
4147
+ p_os_version: os.release(),
4148
+ p_app_version: readPackageVersion()
4149
+ });
4150
+ });
4151
+ const result = Array.isArray(data) ? data[0] : data;
4152
+ if (result?.skipped === true && result?.reason === 'role-transition-pending') {
4153
+ return result;
4154
+ }
4155
+ if (error || result?.ok === false) {
4069
4156
  console.warn(`[LiveDesk Client] Device role registration unavailable: ${error?.message || result?.reason || 'unknown-error'}`);
4070
4157
  }
4071
4158
  return error ? null : result;
@@ -4117,9 +4204,14 @@ export async function waitForManagerFromSupabase(supabase, options = {}) {
4117
4204
  }),
4118
4205
  {
4119
4206
  timeoutMs: options.freshTimeoutMs,
4120
- wakePromise: wakeListener?.promise
4207
+ wakePromise: wakeListener?.promise,
4208
+ signal: discoveryWakeController.signal
4121
4209
  }
4122
4210
  );
4211
+ if (outcome.type === 'local-trigger') {
4212
+ if (shouldStop()) return null;
4213
+ continue;
4214
+ }
4123
4215
  if (outcome.type === 'hub-online') {
4124
4216
  wakeListener?.close();
4125
4217
  wakeListener = null;
@@ -4285,6 +4377,18 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null, get
4285
4377
  } catch (error) {
4286
4378
  initialDiscoveryError = error;
4287
4379
  }
4380
+ if (roleRestartRequest?.role) {
4381
+ return {
4382
+ ...parsed,
4383
+ manager,
4384
+ pair,
4385
+ slot: normalizeSlotNumber(parsed.slot),
4386
+ connectionPage,
4387
+ forwarded,
4388
+ rediscoverOnDisconnect: shouldLogin,
4389
+ rediscoverOnInvalidPair: shouldLogin
4390
+ };
4391
+ }
4288
4392
  discoverySource = resolved?.discoverySource || 'supabase';
4289
4393
  if (resolved?.discoverySource === 'cache') {
4290
4394
  writeSavedSessionToFile(session);
@@ -4303,7 +4407,7 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null, get
4303
4407
  });
4304
4408
  discoverySource = 'supabase';
4305
4409
  }
4306
- if (!resolved && roleRestartRequest?.role) {
4410
+ if (roleRestartRequest?.role) {
4307
4411
  return {
4308
4412
  ...parsed,
4309
4413
  manager,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.225",
3
+ "version": "0.1.227",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1856,7 +1856,11 @@ export function createClientRuntimeServer(options = {}) {
1856
1856
  return;
1857
1857
  }
1858
1858
  try {
1859
- const result = await options.changeRole?.('hub', runtime.getSnapshot());
1859
+ const result = await options.changeRole?.(
1860
+ 'hub',
1861
+ runtime.getSnapshot(),
1862
+ lastChoice?.session || savedSession || null
1863
+ );
1860
1864
  respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1861
1865
  } catch (error) {
1862
1866
  respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
@@ -1870,7 +1874,11 @@ export function createClientRuntimeServer(options = {}) {
1870
1874
  return;
1871
1875
  }
1872
1876
  try {
1873
- const result = await options.changeRole?.('hub', runtime.getSnapshot());
1877
+ const result = await options.changeRole?.(
1878
+ 'hub',
1879
+ runtime.getSnapshot(),
1880
+ lastChoice?.session || savedSession || null
1881
+ );
1874
1882
  respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1875
1883
  } catch (error) {
1876
1884
  respondJson(409, { ok: false, error: normalizeString(error?.message || error) });