diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index f8573f5b22..7320f3e868 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -711,7 +711,7 @@ test('reconnects after a pairing candidate becomes bound to this Client', async await manager.close(); }); -test('activates Guest access on a fresh stream without replaying route progress', async () => { +test('completes Guest import at credential activation while reconnect continues', async () => { const local = candidateHarness({ hostId: 'host-a' }); const remoteHostId = 'a'.repeat(64); const pending = candidateHarness({ @@ -719,19 +719,27 @@ test('activates Guest access on a fresh stream without replaying route progress' finalizeReconnectRequired: true, }); const active = candidateHarness({ hostId: remoteHostId }); - const queue = [local.candidate, pending.candidate, active.candidate]; + let releaseActive!: () => void; + const activeReleased = new Promise((resolve) => { + releaseActive = resolve; + }); + let starts = 0; const phases: string[] = []; const routeRefreshes: Array = []; const manager = await startRuntimeHostDesktopManager( {} as DesktopRuntimeHostCandidateStartInput, { startCandidate: async (input) => { + starts += 1; if (input.profileTarget) { routeRefreshes.push(input.refreshPeerRoutes); input.onConnectionPhase?.('discovering'); input.onConnectionPhase?.('connecting'); } - return ready(queue.shift()!); + if (starts === 1) return ready(local.candidate); + if (starts === 2) return ready(pending.candidate); + await activeReleased; + return ready(active.candidate); }, reconnectBackoff: { minMs: 0, maxMs: 0 }, }, @@ -743,14 +751,18 @@ test('activates Guest access on a fresh stream without replaying route progress' ); let activations = 0; - await manager.finalizeGuestAccess('shared-session', undefined, () => { + const result = await manager.finalizeGuestAccess('shared-session', undefined, () => { activations += 1; }); + assert.equal(result, 'reconnecting'); + assert.equal(manager.current('shared-session')?.readiness, 'reconnecting'); assert.deepEqual(phases, ['discovering', 'connecting']); - assert.deepEqual(routeRefreshes, [undefined, false]); assert.equal(activations, 1); assert.equal(pending.closeCalls, 1); + releaseActive(); + await manager.waitUntilReady('shared-session'); + assert.deepEqual(routeRefreshes, [undefined, false]); assert.equal(manager.current('shared-session')?.candidate, active.candidate); await manager.close(); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts index 39a7948302..08a02fec32 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts @@ -64,7 +64,7 @@ test('retains a successful Guest mount and rehydrates the same authority after r await second!.close(); }); -test('presents Guest import as one connection followed by access activation', async () => { +test('reports activated Guest access as recovering while reauthentication continues', async () => { const progress: string[] = []; const mounts = service(memoryStore(), { mount: async (_target, _signal, onConnectionPhase) => { @@ -76,6 +76,7 @@ test('presents Guest import as one connection followed by access activation', as }, finalizeAccess: async (_mountId, _signal, onAccessActivated) => { onAccessActivated?.(); + return 'reconnecting'; }, }); @@ -86,7 +87,7 @@ test('presents Guest import as one connection followed by access activation', as (phase) => progress.push(phase), ); - assert.equal(result.kind, 'connected'); + assert.equal(result.kind, 'recovering'); const visibleProgress = progress.filter((phase, index) => phase !== progress[index - 1]); assert.deepEqual(visibleProgress, [ 'validating_invitation', @@ -202,6 +203,7 @@ test('settles admitted finalization before committing unmount desire', async () finalizeAccess: async () => { started(); await finalized; + return 'ready'; }, unmount: async () => { assert.deepEqual(await store.read(), []); @@ -257,6 +259,7 @@ test('removal fences a connecting startup mount before credential finalization', }, finalizeAccess: async () => { finalizations += 1; + return 'ready'; }, }); @@ -288,6 +291,7 @@ test('removal settles one admitted startup finalization without waiting through finalizeAccess: async () => { markFinalizing(); await finalization; + return 'ready'; }, }); @@ -315,6 +319,7 @@ test('settles admitted finalization before closing and retains the mount', async finalizeAccess: async () => { started(); await finalized; + return 'ready'; }, }); @@ -347,11 +352,12 @@ test('retains and reconciles a mount when finalization outcome is unknown', asyn attempts += 1; if (attempts === 1) throw new RuntimeHostPairingFinalizationInterruptedError(); resolveReconciled(); + return 'ready'; }, }); const result = await mounts.importInvitation(invitation('guest-unknown'), false, 'import-unknown'); - assert.equal(result.kind, 'error'); + assert.equal(result.kind, 'recovering'); assert.equal((await store.read()).length, 1); await reconciled; assert.equal(attempts, 2); @@ -446,7 +452,7 @@ function service( return createDesktopGuestSessionMountService({ store, mount: overrides.mount ?? (async () => undefined), - finalizeAccess: overrides.finalizeAccess ?? (async () => undefined), + finalizeAccess: overrides.finalizeAccess ?? (async () => 'ready'), unmount: overrides.unmount ?? (async () => undefined), ...(overrides.wait ? { wait: overrides.wait } : {}), onError: overrides.onError ?? (() => undefined), diff --git a/apps/desktop/src/main/__tests__/session-collaboration-join-dialog.test.ts b/apps/desktop/src/main/__tests__/session-collaboration-join-dialog.test.ts index 90f28e17fa..6027f80f36 100644 --- a/apps/desktop/src/main/__tests__/session-collaboration-join-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/session-collaboration-join-dialog.test.ts @@ -41,6 +41,7 @@ const originalGlobals = { Event: globalThis.Event, Node: globalThis.Node, CSS: globalThis.CSS, + getComputedStyle: globalThis.getComputedStyle, matchMedia: globalThis.matchMedia, requestAnimationFrame: globalThis.requestAnimationFrame, cancelAnimationFrame: globalThis.cancelAnimationFrame, @@ -109,6 +110,57 @@ test('keeps loading progress visible while an irreversible import settles', asyn assert.doesNotMatch(document.body.textContent, /finalizingAccess/u); }); +test('closes as a retained background recovery instead of reporting a failed join', async () => { + let imported = 0; + let closed = 0; + const services: SessionCollaborationServices = { + importInvitation: async () => ({ kind: 'recovering', mountId: 'shared-1' }), + cancelImport: async () => 'settling', + readInvitationClipboard: async () => '', + listMounts: async () => [], + removeMount: async () => undefined, + getPendingTurnRequests: async () => [], + decideTurnRequest: async () => { + throw new Error('unused'); + }, + createOperationId: () => 'operation-1', + }; + const { document } = installDom(); + const container = document.querySelector('#root'); + assert.ok(container); + mountedRoot = createRoot(container); + await act(async () => { + mountedRoot?.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(SessionCollaborationServicesProvider, { + services, + children: createElement(SessionCollaborationJoinDialog, { + copy: testCopy(), + onImported: () => { + imported += 1; + }, + onClose: () => { + closed += 1; + }, + }), + }), + }), + }), + }), + ); + await Promise.resolve(); + }); + await setTextArea(document, 'invitation'); + await clickButton(document, 'join'); + + assert.equal(imported, 1); + assert.equal(closed, 1); + assert.doesNotMatch(document.body.textContent, /connectionFailed/u); +}); + function installDom(): { document: Document } { const parsed = parseHTML('
'); const { document, window } = parsed; @@ -122,7 +174,10 @@ function installDom(): { document: Document } { removeEventListener() {}, dispatchEvent: () => false, }); - Object.assign(window, { matchMedia, scrollTo() {} }); + const getComputedStyle = () => ({ + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} }); Object.assign(window.HTMLElement.prototype, { showModal(this: HTMLElement) { this.setAttribute('open', ''); @@ -140,6 +195,7 @@ function installDom(): { document: Document } { Event: window.Event, Node: window.Node, CSS: { escape: (value: string) => value }, + getComputedStyle, requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), cancelAnimationFrame: (handle: number) => clearTimeout(handle), IS_REACT_ACT_ENVIRONMENT: true, diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index a326187f48..74e68af3ee 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -619,7 +619,7 @@ const guestSessionMountService = createDesktopGuestSessionMountService({ }, finalizeAccess: async (mountId, signal, onAccessActivated) => { if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable'); - await runtimeHostManager.finalizeGuestAccess(mountId, signal, onAccessActivated); + return runtimeHostManager.finalizeGuestAccess(mountId, signal, onAccessActivated); }, unmount: async (mountId) => { if (!runtimeHostManager) return; diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index b531d0b3a7..150e760454 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -83,7 +83,7 @@ export interface RuntimeHostDesktopManager { mountId: string, signal?: AbortSignal, onAccessActivated?: () => void, - ): Promise; + ): Promise; unmountGuest(mountId: string): Promise; wakePeerRecovery(): void; disable(profileId: string): Promise; @@ -175,11 +175,13 @@ export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconn export class RuntimeHostPairingFinalizationInterruptedError extends Error { constructor(options?: ErrorOptions) { - super('Runtime Host pairing finalization was deferred until the next startup', options); + super('Runtime Host pairing finalization is continuing in the background', options); this.name = 'RuntimeHostPairingFinalizationInterruptedError'; } } +export type RuntimeHostGuestAccessFinalization = 'ready' | 'reconnecting'; + const DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS = 30_000; export type RuntimeHostRestartableConflict = Extract< @@ -359,24 +361,27 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } finalizePairing(profileId: string): Promise { - return this.#mutateTarget(profileId, () => this.#finalizeAccessCredential(profileId)); + return this.#mutateTarget(profileId, async () => { + await this.#finalizeAccessCredential(profileId, 'ready'); + }); } finalizeGuestAccess( mountId: string, signal?: AbortSignal, onAccessActivated?: () => void, - ): Promise { + ): Promise { return this.#mutateTarget(mountId, () => - this.#finalizeAccessCredential(mountId, signal, onAccessActivated), + this.#finalizeAccessCredential(mountId, 'activation', signal, onAccessActivated), ); } async #finalizeAccessCredential( profileId: string, + completion: 'activation' | 'ready', externalSignal?: AbortSignal, onAccessActivated?: () => void, - ): Promise { + ): Promise { const target = this.#requireTarget(profileId); if (target.target.profile.kind !== 'remote') { throw new Error('Only remote Runtime Host profiles can finalize pairing'); @@ -415,11 +420,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ) { target.skipPeerRouteRefreshOnce = true; } + if (completion === 'activation') { + try { + await candidate.close(); + } catch (error) { + // The Host already committed the credential. Candidate cleanup + // cannot turn that durable success into a failed Guest import; + // its closed signal still drives the reconnect lifecycle. + this.#baseInput.onError?.(error); + } + return 'reconnecting'; + } await candidate.close(); await this.#waitForReadyCandidate(lifecycle, candidate, signal); } - signal.throwIfAborted(); - return; + if (completion === 'ready') signal.throwIfAborted(); + return 'ready'; } catch (error) { if (pairingFinalizeTimedOut(error)) { throw new RuntimeHostPairingFinalizationInterruptedError({ cause: error }); diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts index 44d0a94e5d..39b231d080 100644 --- a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts +++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts @@ -41,7 +41,10 @@ import { decodeDesktopCollaborationInvitation, DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES, } from './runtime-host-collaboration-invitation.js'; -import { RuntimeHostPairingFinalizationInterruptedError } from './runtime-host-desktop-manager.js'; +import { + RuntimeHostPairingFinalizationInterruptedError, + type RuntimeHostGuestAccessFinalization, +} from './runtime-host-desktop-manager.js'; const STORE_SCHEMA_VERSION = 1; const STORE_SLOT = 'desktop-guest-session-mounts'; @@ -64,7 +67,7 @@ interface GuestSessionMountDocument { interface LiveGuestActivationBase { readonly controller: AbortController; stage: 'connecting' | 'finalizing'; - finalization?: Promise; + finalization?: Promise; task: Promise; } @@ -140,7 +143,7 @@ export function createDesktopGuestSessionMountService(input: { mountId: string, signal: AbortSignal, onAccessActivated?: () => void, - ) => Promise; + ) => Promise; readonly unmount: (mountId: string) => Promise; readonly wait?: (delayMs: number, signal: AbortSignal) => Promise; readonly onError?: (error: Error, mount: GuestSessionMount) => void; @@ -203,7 +206,7 @@ export function createDesktopGuestSessionMountService(input: { const activate = async ( activation: LiveGuestActivation, mount: GuestSessionMount, - ): Promise => { + ): Promise => { activation.stage = 'connecting'; await input.mount(resolveMountTarget(mount), activation.controller.signal, (phase) => { if (activation.kind === 'import') { @@ -233,8 +236,9 @@ export function createDesktopGuestSessionMountService(input: { ); activation.finalization = finalization; try { - await finalization; + const result = await finalization; activation.controller.signal.throwIfAborted(); + return result; } finally { if (activation.finalization === finalization) activation.finalization = undefined; } @@ -361,12 +365,15 @@ export function createDesktopGuestSessionMountService(input: { let reconcile = false; try { reportImportProgress(activation.onProgress, 'discovering_host'); - await activate(activation, mount); + const finalization = await activate(activation, mount); activation.controller.signal.throwIfAborted(); if (!(await load()).has(mount.mountId)) { throw new Error('Shared Session mount was removed while connecting'); } - return { kind: 'connected', mountId: mount.mountId }; + return { + kind: finalization === 'ready' ? 'connected' : 'recovering', + mountId: mount.mountId, + }; } catch (error) { if ( activation.stage === 'finalizing' && @@ -382,11 +389,13 @@ export function createDesktopGuestSessionMountService(input: { activation.controller.abort(new Error('Shared Session mount activation failed')); await input.unmount(mount.mountId).catch(() => undefined); } - return { - kind: 'error', - reason: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed', - message: asError(error).message, - }; + return reconcile + ? { kind: 'recovering', mountId: mount.mountId } + : { + kind: 'error', + reason: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed', + message: asError(error).message, + }; } finally { activations.delete(activation); if (reconcile) beginStartupReconciliation(mount); diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx index d4f1ab7450..d4c0e27a05 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx +++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx @@ -56,6 +56,8 @@ export interface SessionCollaborationJoinCopy { readonly authenticatingGuest: string; readonly finalizingAccess: string; readonly loadingSession: string; + readonly recoveryStarted: string; + readonly recoveryStartedBody: string; readonly retainedTasks: string; readonly disconnect: string; readonly disconnectFailed: string; @@ -144,6 +146,9 @@ export function SessionCollaborationJoinDialog(props: { return; } props.onImported(); + if (result.kind === 'recovering') { + toast.info(props.copy.recoveryStarted, props.copy.recoveryStartedBody); + } finishClose(); } catch (error) { if (!open.current || activeOperationId.current !== operationId) return; diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts index 21b53b6441..915cb39042 100644 --- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts +++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts @@ -68,6 +68,8 @@ const ZH = { authenticatingGuest: '正在验证访客凭据…', finalizingAccess: '正在确认访客访问权限…', loadingSession: '正在加载共享任务…', + recoveryStarted: '共享任务正在后台连接', + recoveryStartedBody: '任务已保存在此设备;连接恢复后会自动出现在任务列表中。', invalidCode: '邀请码无效', connectionFailed: '无法加入共享任务', directPathUnavailable: '未能连接到任务所在的 Runtime Host。请确认当前 Desktop Client 和该 Host 在同一个 Peer Mesh 中,并且存在可用的直连或成员转发路径;可在 Peer Mesh 设置中同步路径并检查成员转发。', @@ -160,6 +162,8 @@ const EN = { authenticatingGuest: 'Authenticating the Guest credential…', finalizingAccess: 'Confirming Guest access…', loadingSession: 'Loading the shared task…', + recoveryStarted: 'Shared task is connecting in the background', + recoveryStartedBody: 'The task is saved on this device and will appear in the task list when the connection recovers.', invalidCode: 'The invitation code is invalid', connectionFailed: 'Could not join the shared task', directPathUnavailable: 'Could not reach the Runtime Host for this task. Make sure this Desktop Client and the Host are in the same Peer Mesh and have a usable direct or member-transit path. Sync routes and check member transit in Peer Mesh settings.', diff --git a/apps/desktop/src/shared/session-collaboration.d.ts b/apps/desktop/src/shared/session-collaboration.d.ts index bc942de184..c617854ec0 100644 --- a/apps/desktop/src/shared/session-collaboration.d.ts +++ b/apps/desktop/src/shared/session-collaboration.d.ts @@ -19,6 +19,7 @@ export type SessionCollaborationImportResult = | { readonly kind: 'connected'; readonly mountId: string } + | { readonly kind: 'recovering'; readonly mountId: string } | { readonly kind: 'error'; readonly reason: