From 2bede0b2b52f414e4a99f7b4ed1ae54e5bb366eb Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 30 Aug 2026 23:56:42 +0800 Subject: [PATCH 1/5] feat(desktop): make Peer Mesh operations responsive Generated-by: OpenAI Codex --- .../runtime-host-guest-session-mounts.test.ts | 39 +++++- apps/desktop/src/main/runtime-host-boot.ts | 3 +- .../src/main/runtime-host-desktop-manager.ts | 11 +- .../main/runtime-host-guest-session-mounts.ts | 125 ++++++++++++++++-- apps/desktop/src/preload/bridge-contract.d.ts | 15 ++- apps/desktop/src/preload/preload.ts | 24 +++- .../ui/runtime-host-peer-mesh-dialog.tsx | 115 +++++++++++----- .../features/session-collaboration/ports.ts | 16 ++- .../ui/session-collaboration-join-dialog.tsx | 101 ++++++++++++-- .../locales/session-collaboration-copy.ts | 16 ++- .../create-session-collaboration-services.ts | 5 +- 11 files changed, 402 insertions(+), 68 deletions(-) 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 3daa268749..3d8102b844 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 @@ -39,7 +39,7 @@ test('retains a successful Guest mount and rehydrates the same authority after r }, }); - const result = await first.importInvitation(invitation('guest-one'), false); + const result = await first.importInvitation(invitation('guest-one'), false, 'import-one'); assert.equal(result.kind, 'connected'); if (result.kind !== 'connected') return; await first.close(); @@ -68,7 +68,7 @@ test('removes failed activation desire instead of creating recoverable profile s }, }); - const result = await mounts.importInvitation(invitation('guest-two'), false); + const result = await mounts.importInvitation(invitation('guest-two'), false, 'import-two'); assert.deepEqual(result.kind === 'error' ? result.reason : result.kind, 'peer_path_unavailable'); assert.deepEqual(await store.read(), []); assert.equal(unmounted.length, 1); @@ -94,7 +94,7 @@ test('settles admitted finalization before committing unmount desire', async () throw new Error('connection shutdown failed'); }, }); - const importing = mounts.importInvitation(invitation('guest-three'), false); + const importing = mounts.importInvitation(invitation('guest-three'), false, 'import-three'); await finalizing; const [retained] = await store.read(); assert.ok(retained); @@ -204,8 +204,9 @@ test('settles admitted finalization before closing and retains the mount', async }, }); - const importing = mounts.importInvitation(invitation('guest-closing'), false); + const importing = mounts.importInvitation(invitation('guest-closing'), false, 'import-closing'); await finalizing; + assert.equal(mounts.cancelImport('import-closing'), 'settling'); let closed = false; const closing = mounts.close().then(() => { closed = true; @@ -235,7 +236,7 @@ test('retains and reconciles a mount when finalization outcome is unknown', asyn }, }); - const result = await mounts.importInvitation(invitation('guest-unknown'), false); + const result = await mounts.importInvitation(invitation('guest-unknown'), false, 'import-unknown'); assert.equal(result.kind, 'error'); assert.equal((await store.read()).length, 1); await reconciled; @@ -244,6 +245,34 @@ test('retains and reconciles a mount when finalization outcome is unknown', asyn await mounts.close(); }); +test('cancels an in-flight import and removes its durable mount desire', async () => { + const store = memoryStore(); + let connecting!: () => void; + const started = new Promise((resolve) => { + connecting = resolve; + }); + const mounts = service(store, { + mount: async (_target, signal) => { + connecting(); + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + }); + + const importing = mounts.importInvitation( + invitation('guest-cancelled'), + false, + 'import-cancelled', + ); + await started; + assert.equal(mounts.cancelImport('import-cancelled'), 'cancelled'); + + assert.equal((await importing).kind, 'error'); + assert.deepEqual(await store.read(), []); + await mounts.close(); +}); + function service( store: GuestSessionMountStore, overrides: { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index f9779c9c68..4f5f78ee3c 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -514,7 +514,7 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({ }); const guestSessionMountService = createDesktopGuestSessionMountService({ store: createGuestSessionMountStore(runtimeHostCredentialStore), - mount: async (target, signal) => { + mount: async (target, signal, onConnectionPhase) => { if (target.profile.kind !== 'remote' || !target.credential) { throw new Error('A shared Session requires a remote Guest target'); } @@ -522,6 +522,7 @@ const guestSessionMountService = createDesktopGuestSessionMountService({ await runtimeHostManager.mountGuest( { profile: target.profile, credential: target.credential }, signal, + onConnectionPhase, ); }, finalizeAccess: async (mountId, signal) => { diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 6a0c4e3f85..7953e0e1d8 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -32,6 +32,7 @@ import { type ResolvedRuntimeHostProfile, type RuntimeHostReconnectBackoff, type RuntimeHostReconnectLifecycle, + type RuntimeHostConnectionPhase, type RuntimeHostRetirementMode, type RuntimeHostSshInteraction, } from '@maka/runtime-host/client'; @@ -70,6 +71,7 @@ export interface RuntimeHostDesktopManager { mountGuest( profileTarget: NonNullable, signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, ): Promise; finalizeGuestAccess(mountId: string, signal?: AbortSignal): Promise; unmountGuest(mountId: string): Promise; @@ -500,12 +502,13 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { mountGuest( profileTarget: NonNullable, signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, ): Promise { if (!isSessionGuestProfile(profileTarget.profile)) { return Promise.reject(new Error('A Session Guest target is required')); } return this.#mutateTarget(profileTarget.profile.id, () => - this.#enable(profileTarget, true, signal), + this.#enable(profileTarget, true, signal, onConnectionPhase), ); } @@ -513,6 +516,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { profileTarget: NonNullable, allowSameRoot: boolean, signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, ): Promise { signal?.throwIfAborted(); if (this.#closed) throw new Error('Desktop Runtime Host manager is closed'); @@ -540,7 +544,10 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ) return; if (existing) await this.#removeTarget(existing); - const target = this.#createTarget(withRuntimeHostTarget(this.#baseInput, profileTarget)); + const target = this.#createTarget({ + ...withRuntimeHostTarget(this.#baseInput, profileTarget), + ...(onConnectionPhase ? { onConnectionPhase } : {}), + }); this.#targets.set(profileId, target); this.#publishState(target, { epoch: target.epoch, 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 c31941d5c3..bbcc8b3e8e 100644 --- a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts +++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts @@ -22,6 +22,7 @@ import { decodeRemoteRuntimeHostProfile, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, type ResolvedRuntimeHostProfile, + type RuntimeHostConnectionPhase, type RuntimeHostRemoteTransport, } from '@maka/runtime-host/client'; import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol'; @@ -30,6 +31,10 @@ import type { SessionCollaborationImportResult, SessionCollaborationMountSummary, } from '../shared/session-collaboration.js'; +import type { + DesktopSessionCollaborationCancelResult, + DesktopSessionCollaborationImportPhase, +} from '../preload/bridge-contract.js'; import { decodeDesktopCollaborationInvitation } from './runtime-host-collaboration-invitation.js'; import { RuntimeHostPairingFinalizationInterruptedError } from './runtime-host-desktop-manager.js'; @@ -51,15 +56,27 @@ interface GuestSessionMountDocument { readonly mounts: readonly GuestSessionMount[]; } -interface LiveGuestActivation { - readonly kind: 'import' | 'startup'; +interface LiveGuestActivationBase { readonly controller: AbortController; - mountId?: string; stage: 'connecting' | 'finalizing'; finalization?: Promise; task: Promise; } +interface LiveGuestImportActivation extends LiveGuestActivationBase { + readonly kind: 'import'; + readonly operationId: string; + readonly onProgress?: (phase: DesktopSessionCollaborationImportPhase) => void; + mountId?: string; +} + +interface LiveGuestStartupActivation extends LiveGuestActivationBase { + readonly kind: 'startup'; + readonly mountId: string; +} + +type LiveGuestActivation = LiveGuestImportActivation | LiveGuestStartupActivation; + export interface GuestSessionMountStore { read(): Promise; write(mounts: readonly GuestSessionMount[]): Promise; @@ -71,7 +88,10 @@ export interface DesktopGuestSessionMountService { importInvitation( code: string, allowInsecure: boolean, + operationId: string, + onProgress?: (phase: DesktopSessionCollaborationImportPhase) => void, ): Promise; + cancelImport(operationId: string): DesktopSessionCollaborationCancelResult; remove(mountId: string): Promise; close(): Promise; } @@ -105,7 +125,11 @@ export function createGuestSessionMountStore( export function createDesktopGuestSessionMountService(input: { readonly store: GuestSessionMountStore; - readonly mount: (target: ResolvedRuntimeHostProfile, signal: AbortSignal) => Promise; + readonly mount: ( + target: ResolvedRuntimeHostProfile, + signal: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, + ) => Promise; readonly finalizeAccess: (mountId: string, signal: AbortSignal) => Promise; readonly unmount: (mountId: string) => Promise; readonly wait?: (delayMs: number, signal: AbortSignal) => Promise; @@ -145,12 +169,22 @@ export function createDesktopGuestSessionMountService(input: { mount: GuestSessionMount, ): Promise => { activation.stage = 'connecting'; - await input.mount(resolveMountTarget(mount), activation.controller.signal); + await input.mount(resolveMountTarget(mount), activation.controller.signal, (phase) => { + if (activation.kind === 'import') { + reportImportProgress( + activation.onProgress, + collaborationProgressForConnectionPhase(phase), + ); + } + }); activation.controller.signal.throwIfAborted(); if (removingMounts.has(mount.mountId)) { throw new Error('Shared Session mount was removed while connecting'); } activation.stage = 'finalizing'; + if (activation.kind === 'import') { + reportImportProgress(activation.onProgress, 'finalizing_access'); + } const finalization = input.finalizeAccess(mount.mountId, activation.controller.signal); activation.finalization = finalization; try { @@ -240,8 +274,9 @@ export function createDesktopGuestSessionMountService(input: { const runImport = async ( code: string, allowInsecure: boolean, - activation: LiveGuestActivation, + activation: LiveGuestImportActivation, ): Promise => { + reportImportProgress(activation.onProgress, 'validating_invitation'); activation.controller.signal.throwIfAborted(); let bundle; let invitation; @@ -278,6 +313,7 @@ export function createDesktopGuestSessionMountService(input: { } let reconcile = false; try { + reportImportProgress(activation.onProgress, 'discovering_host'); await activate(activation, mount); activation.controller.signal.throwIfAborted(); if (!(await load()).has(mount.mountId)) { @@ -313,10 +349,21 @@ export function createDesktopGuestSessionMountService(input: { const importInvitation = ( code: string, allowInsecure: boolean, + operationId: string, + onProgress?: (phase: DesktopSessionCollaborationImportPhase) => void, ): Promise => { if (closed) return Promise.reject(new Error('Shared Session mount service is closed')); - const activation: LiveGuestActivation = { + if ( + [...activations].some( + (activation) => activation.kind === 'import' && activation.operationId === operationId, + ) + ) { + return Promise.reject(new Error('Shared Session import operation is already active')); + } + const activation: LiveGuestImportActivation = { kind: 'import', + operationId, + ...(onProgress ? { onProgress } : {}), controller: new AbortController(), stage: 'connecting', task: Promise.resolve(), @@ -344,6 +391,17 @@ export function createDesktopGuestSessionMountService(input: { importInvitation, + cancelImport(operationId) { + const operation = [...activations].find( + (activation): activation is LiveGuestImportActivation => + activation.kind === 'import' && activation.operationId === operationId, + ); + if (!operation) return 'idle'; + if (operation.stage === 'finalizing') return 'settling'; + operation.controller.abort(new Error('Shared Session import was cancelled')); + return 'cancelled'; + }, + remove, async close() { @@ -366,14 +424,25 @@ export function registerDesktopGuestSessionMountIpc( ): () => void { const channels = [ 'session-collaboration:import', + 'session-collaboration:import:cancel', 'session-collaboration:mount:list', 'session-collaboration:mount:remove', ] as const; - ipcMain.handle(channels[0], (_event, code: string, allowInsecure: boolean) => - service.importInvitation(code, allowInsecure), + ipcMain.handle( + channels[0], + (event, code: string, allowInsecure: boolean, operationIdValue: unknown) => { + const operationId = requireOperationId(operationIdValue); + return service.importInvitation(code, allowInsecure, operationId, (phase) => { + if (!event.sender.isDestroyed()) { + event.sender.send('session-collaboration:import:progress', operationId, phase); + } + }); + }, ); - ipcMain.handle(channels[1], () => service.list()); - ipcMain.handle(channels[2], (_event, mountId: string) => service.remove(mountId)); + ipcMain.handle(channels[1], (_event, operationIdValue: unknown) => + service.cancelImport(requireOperationId(operationIdValue))); + ipcMain.handle(channels[2], () => service.list()); + ipcMain.handle(channels[3], (_event, mountId: string) => service.remove(mountId)); return () => { for (const channel of channels) ipcMain.removeHandler(channel); }; @@ -443,6 +512,40 @@ function isPeerPathUnavailable(error: unknown): boolean { return error.code === 'direct_path_unavailable' || error.code === 'transit_unavailable'; } +function collaborationProgressForConnectionPhase( + phase: RuntimeHostConnectionPhase, +): DesktopSessionCollaborationImportPhase { + switch (phase) { + case 'discovering': + return 'preparing_route'; + case 'connecting': + return 'connecting'; + case 'authenticating': + return 'authenticating'; + case 'handshaking': + case 'waiting_for_ready': + return 'loading_session'; + } +} + +function reportImportProgress( + observer: ((phase: DesktopSessionCollaborationImportPhase) => void) | undefined, + phase: DesktopSessionCollaborationImportPhase, +): void { + try { + observer?.(phase); + } catch { + // Presentation progress cannot control the import lifecycle. + } +} + +function requireOperationId(value: unknown): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value)) { + throw new Error('Shared Session import operation ID is invalid'); + } + return value; +} + function waitForDelay(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 20d8dd908c..fc7a200a59 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -334,6 +334,17 @@ export type DesktopSessionCollaborationImportResult = SessionCollaborationImport export type DesktopGuestSessionMountSummary = SessionCollaborationMountSummary; +export type DesktopSessionCollaborationImportPhase = + | 'validating_invitation' + | 'discovering_host' + | 'preparing_route' + | 'connecting' + | 'authenticating' + | 'finalizing_access' + | 'loading_session'; + +export type DesktopSessionCollaborationCancelResult = 'cancelled' | 'settling' | 'idle'; + export type DesktopSessionCollaborationPrepareResult = | { readonly kind: 'prepared'; @@ -705,7 +716,9 @@ export interface MakaBridge { importInvitation(input: { readonly code: string; readonly allowInsecure?: boolean; - }): Promise; + readonly operationId: string; + }, onProgress?: (phase: DesktopSessionCollaborationImportPhase) => void): Promise; + cancelImport(operationId: string): Promise; listMounts(): Promise; removeMount(mountId: string): Promise; requestTurn( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bbe0af9c28..ab315a67e9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1282,8 +1282,28 @@ const makaBridge = { grantId, ); }, - importInvitation({ code, allowInsecure = false }) { - return ipcRenderer.invoke('session-collaboration:import', code, allowInsecure); + async importInvitation({ code, allowInsecure = false, operationId }, onProgress) { + const listener = ( + _event: Electron.IpcRendererEvent, + progressOperationId: string, + phase: Parameters>[0], + ) => { + if (progressOperationId === operationId) onProgress?.(phase); + }; + ipcRenderer.on('session-collaboration:import:progress', listener); + try { + return await ipcRenderer.invoke( + 'session-collaboration:import', + code, + allowInsecure, + operationId, + ); + } finally { + ipcRenderer.off('session-collaboration:import:progress', listener); + } + }, + cancelImport(operationId) { + return ipcRenderer.invoke('session-collaboration:import:cancel', operationId); }, listMounts() { return ipcRenderer.invoke('session-collaboration:mount:list'); diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx index fe87f68137..192434dc67 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx @@ -106,12 +106,14 @@ export function RuntimeHostPeerMeshDialog(props: { const [view, setView] = useState({ kind: 'overview' }); const [error, setError] = useState(); const [workingAction, setWorkingAction] = useState(); + const [settling, setSettling] = useState(false); const [managedHostPeerSetup, setManagedHostPeerSetup] = useState( props.target.kind === 'managed_host' ? { kind: 'loading' } : { kind: 'idle' }, ); const working = workingAction !== undefined; const activeOperationId = useRef(undefined); - const cancelledOperationId = useRef(undefined); + const cancelRequestedOperationId = useRef(undefined); + const closeRequested = useRef(false); const statusOperationIds = useRef(new Set()); const refreshSequence = useRef(0); const closed = useRef(false); @@ -166,24 +168,27 @@ export function RuntimeHostPeerMeshDialog(props: { const refresh = useCallback(async () => { if (closed.current) return; const sequence = ++refreshSequence.current; - const [result, localHost] = await Promise.all([ - executeStatus(activeTarget), - offerLocalHost - ? executeStatus(LOCAL_HOST_TARGET).then( - (value) => ({ kind: 'result' as const, value }), - () => ({ kind: 'failed' as const }), - ) - : undefined, - ]); + const result = await executeStatus(activeTarget); if (!isSnapshot(result)) throw new Error(copy.invalidResult); if (closed.current || sequence !== refreshSequence.current) return; setSnapshot(result); setError(undefined); if (offerLocalHost) { - setLocalHost( - localHost?.kind === 'result' && isSnapshot(localHost.value) && localHost.value.localPeerId - ? { kind: 'available', peerId: localHost.value.localPeerId } - : { kind: 'unavailable' }, + setLocalHost({ kind: 'loading' }); + void executeStatus(LOCAL_HOST_TARGET).then( + (localHost) => { + if (closed.current || sequence !== refreshSequence.current) return; + setLocalHost( + isSnapshot(localHost) && localHost.localPeerId + ? { kind: 'available', peerId: localHost.localPeerId } + : { kind: 'unavailable' }, + ); + }, + () => { + if (!closed.current && sequence === refreshSequence.current) { + setLocalHost({ kind: 'unavailable' }); + } + }, ); } }, [activeTarget, copy.invalidResult, executeStatus, offerLocalHost]); @@ -250,24 +255,47 @@ export function RuntimeHostPeerMeshDialog(props: { if (closed.current) return false; const operationId = services.createOperationId(); activeOperationId.current = operationId; + cancelRequestedOperationId.current = undefined; + setSettling(false); setWorkingAction(action); setError(undefined); let completed = false; - let cancelled = false; try { await operation(operationId); completed = true; } catch (failure) { - if (!closed.current && cancelledOperationId.current !== operationId) { + if (!closed.current && cancelRequestedOperationId.current !== operationId) { setError(peerMeshErrorMessage(failure, copy.unknownError)); } } finally { - cancelled = cancelledOperationId.current === operationId; + const cancelled = cancelRequestedOperationId.current === operationId; + if (cancelled && !closed.current) { + try { + await refresh(); + } catch (failure) { + if (!closed.current) setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + if (cancelRequestedOperationId.current === operationId) { + cancelRequestedOperationId.current = undefined; + } if (activeOperationId.current === operationId) activeOperationId.current = undefined; - if (cancelled) cancelledOperationId.current = undefined; - if (!closed.current) setWorkingAction(undefined); + if (!closed.current && activeOperationId.current === undefined) { + setWorkingAction(undefined); + setSettling(false); + } + if (cancelled) completed = false; + if (!closed.current && closeRequested.current) finishClose(); } - return completed && !cancelled && !closed.current; + return completed && !closed.current; + } + + function operationIsCurrent(operationId: string): boolean { + return ( + !closed.current && + activeOperationId.current === operationId && + cancelRequestedOperationId.current !== operationId + ); } async function refreshNow(): Promise { @@ -285,19 +313,29 @@ export function RuntimeHostPeerMeshDialog(props: { function cancelOperation(): void { const operationId = activeOperationId.current; - if (operationId) { - cancelledOperationId.current = operationId; - void services.cancel(operationId); - } + if (!operationId || cancelRequestedOperationId.current === operationId) return; + cancelRequestedOperationId.current = operationId; + if (!closed.current) setSettling(true); + void services.cancel(operationId); } - function requestClose(): void { + function finishClose(): void { + if (closed.current) return; closed.current = true; - if (working) cancelOperation(); cancelStatusOperations(); props.onClose(); } + function requestClose(): void { + if (workingAction && workingAction !== 'refresh') { + closeRequested.current = true; + setSettling(true); + cancelOperation(); + return; + } + finishClose(); + } + async function createMesh(): Promise { await runOperation('create', async (operationId) => { const previousMeshIds = new Set(snapshot?.meshes.map(({ meshId }) => meshId)); @@ -305,10 +343,12 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); const created = result.meshes.find(({ meshId }) => !previousMeshIds.has(meshId)); if (created && offerLocalHost) { await joinLocalHost(created.meshId, operationId); + if (!operationIsCurrent(operationId)) return; await refresh(); } }); @@ -321,6 +361,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setJoinDraft(''); setView({ kind: 'overview' }); setSnapshot(result); @@ -334,6 +375,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isInvitationResult(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setView({ kind: 'invitation', meshId, @@ -348,6 +390,7 @@ export function RuntimeHostPeerMeshDialog(props: { async function addLocalHost(meshId: string): Promise { await runOperation('add-host', async (operationId) => { await joinLocalHost(meshId, operationId); + if (!operationIsCurrent(operationId)) return; await refresh(); }); } @@ -369,7 +412,10 @@ export function RuntimeHostPeerMeshDialog(props: { } catch (failure) { if (!closed.current) setError(peerMeshErrorMessage(failure, copy.unknownError)); } finally { - if (!closed.current) setWorkingAction(undefined); + if (!closed.current) { + setWorkingAction(undefined); + if (closeRequested.current) finishClose(); + } } } @@ -379,7 +425,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isInvitationResult(prepared)) throw new Error(copy.invalidResult); - if (cancelledOperationId.current === operationId) { + if (!operationIsCurrent(operationId)) { throw new Error('Peer Mesh operation was cancelled'); } const joined = await services.execute( @@ -388,6 +434,9 @@ export function RuntimeHostPeerMeshDialog(props: { { invitation: JSON.stringify(prepared.invitation), operationId }, ); if (!isSnapshot(joined)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) { + throw new Error('Peer Mesh operation was cancelled'); + } } async function mutate( @@ -415,6 +464,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); }); } @@ -426,6 +476,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); }); } @@ -447,6 +498,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); }); if (!completed) throw new Error('Peer Mesh rename did not complete'); @@ -460,6 +512,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); }); if (!completed) throw new Error('Peer Mesh rename did not complete'); @@ -538,9 +591,9 @@ export function RuntimeHostPeerMeshDialog(props: { {workingAction ? ( ; + readonly operationId: string; + }, onProgress?: (phase: SessionCollaborationImportPhase) => void): Promise; + cancelImport(operationId: string): Promise; listMounts(): Promise; removeMount(mountId: string): Promise; + createOperationId(): string; } 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 ef5aa296a7..eae270f554 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 @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { List, ListItem } from '@astryxdesign/core/List'; @@ -29,7 +29,10 @@ import { useToast, } from '@maka/ui'; import { useSessionCollaborationServices } from '../services-context.js'; -import type { SessionCollaborationMountSummary } from '../ports.js'; +import type { + SessionCollaborationImportPhase, + SessionCollaborationMountSummary, +} from '../ports.js'; export interface SessionCollaborationJoinCopy { readonly joinTitle: string; @@ -43,7 +46,13 @@ export interface SessionCollaborationJoinCopy { readonly directPathUnavailable: string; readonly code: string; readonly join: string; - readonly joining: string; + readonly validatingInvitation: string; + readonly discoveringHost: string; + readonly preparingRoute: string; + readonly connectingHost: string; + readonly authenticatingGuest: string; + readonly finalizingAccess: string; + readonly loadingSession: string; readonly retainedTasks: string; readonly disconnect: string; readonly disconnectFailed: string; @@ -59,15 +68,18 @@ export function SessionCollaborationJoinDialog(props: { const [code, setCode] = useState(''); const [mounts, setMounts] = useState([]); const [removingMountId, setRemovingMountId] = useState(); + const activeOperationId = useRef(undefined); + const open = useRef(true); const [joinState, setJoinState] = useState< | { readonly kind: 'idle' } - | { readonly kind: 'working' } + | { readonly kind: 'working'; readonly phase: SessionCollaborationImportPhase } | { readonly kind: 'failed'; readonly message: string } >({ kind: 'idle' }); const working = joinState.kind === 'working'; const failure = joinState.kind === 'failed' ? joinState.message : undefined; useEffect(() => { + open.current = true; let disposed = false; void services.listMounts().then( (next) => { @@ -77,16 +89,27 @@ export function SessionCollaborationJoinDialog(props: { ); return () => { disposed = true; + open.current = false; + const operationId = activeOperationId.current; + if (operationId) void services.cancelImport(operationId); }; }, [services]); async function join(allowInsecure = false): Promise { - setJoinState({ kind: 'working' }); + const operationId = services.createOperationId(); + activeOperationId.current = operationId; + setJoinState({ kind: 'working', phase: 'validating_invitation' }); try { const result = await services.importInvitation({ code: code.trim(), allowInsecure, + operationId, + }, (phase) => { + if (open.current && activeOperationId.current === operationId) { + setJoinState({ kind: 'working', phase }); + } }); + if (!open.current || activeOperationId.current !== operationId) return; if (result.kind === 'error' && result.reason === 'insecure_confirmation_required') { const confirmed = await toast.confirm({ title: props.copy.insecureTitle, @@ -95,7 +118,9 @@ export function SessionCollaborationJoinDialog(props: { cancelLabel: props.copy.close, destructive: true, }); - if (confirmed) await join(true); + if (confirmed && open.current && activeOperationId.current === operationId) { + await join(true); + } return; } if (result.kind === 'error') { @@ -107,11 +132,42 @@ export function SessionCollaborationJoinDialog(props: { props.onImported(); props.onClose(); } catch (error) { + if (!open.current || activeOperationId.current !== operationId) return; const message = errorMessage(error); setJoinState({ kind: 'failed', message }); toast.error(props.copy.joinTitle, message); } finally { - setJoinState((current) => current.kind === 'working' ? { kind: 'idle' } : current); + if (activeOperationId.current === operationId) activeOperationId.current = undefined; + if (open.current) { + setJoinState((current) => current.kind === 'working' ? { kind: 'idle' } : current); + } + } + } + + function finishClose(): void { + open.current = false; + props.onClose(); + } + + async function requestClose(): Promise { + const operationId = activeOperationId.current; + if (!operationId) { + finishClose(); + return; + } + try { + const result = await services.cancelImport(operationId); + if (!open.current || activeOperationId.current !== operationId) return; + if (result === 'settling') { + setJoinState({ kind: 'working', phase: 'finalizing_access' }); + return; + } + finishClose(); + } catch (error) { + if (!open.current || activeOperationId.current !== operationId) return; + const message = errorMessage(error); + setJoinState({ kind: 'failed', message }); + toast.error(props.copy.joinTitle, message); } } @@ -130,7 +186,9 @@ export function SessionCollaborationJoinDialog(props: { return ( !open && !working && props.onClose()} + onOpenChange={(nextOpen) => { + if (!nextOpen) void requestClose(); + }} purpose="form" width={560} > @@ -139,13 +197,17 @@ export function SessionCollaborationJoinDialog(props: { !open && !working && props.onClose()} + onOpenChange={(nextOpen) => { + if (!nextOpen) void requestClose(); + }} /> )} content={( - {working ? : null} + {joinState.kind === 'working' ? ( + + ) : null} {failure ? ( void requestClose()} />