From f7907d1456b09f1725b1ec830e6834eecaadf0d1 Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 30 Aug 2026 22:45:55 +0800 Subject: [PATCH 1/4] refactor(desktop): separate shared sessions from Runtime Host profiles Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 9 +- .../runtime-host-desktop-manager.test.ts | 41 +- .../runtime-host-guest-session-mounts.test.ts | 154 +++++++ .../runtime-host-profile-service.test.ts | 261 ++++------- apps/desktop/src/main/runtime-host-boot.ts | 36 +- .../src/main/runtime-host-desktop-manager.ts | 86 +++- .../main/runtime-host-guest-session-mounts.ts | 412 ++++++++++++++++++ .../src/main/runtime-host-profile-service.ts | 109 ++--- apps/desktop/src/preload/bridge-contract.d.ts | 10 +- apps/desktop/src/preload/preload.ts | 6 + .../composition/desktop-feature-services.tsx | 27 +- .../features/session-collaboration/index.ts | 26 ++ .../features/session-collaboration/ports.ts | 44 ++ .../services-context.tsx | 41 ++ .../ui/session-collaboration-join-dialog.tsx | 226 ++++++++++ .../locales/session-collaboration-copy.ts | 8 +- .../create-session-collaboration-services.ts | 33 ++ .../renderer/session-collaboration-dialog.tsx | 135 +----- .../runtime-host-profiles-section.tsx | 6 +- docs/astryx-surface-file-inventory.md | 6 +- docs/astryx-surface-file-inventory.paths | 2 + 21 files changed, 1256 insertions(+), 422 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts create mode 100644 apps/desktop/src/main/runtime-host-guest-session-mounts.ts create mode 100644 apps/desktop/src/renderer/features/session-collaboration/index.ts create mode 100644 apps/desktop/src/renderer/features/session-collaboration/ports.ts create mode 100644 apps/desktop/src/renderer/features/session-collaboration/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2f8f24e25e..1fe458ff98 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -2464,7 +2464,6 @@ "window.maka.sessionCollaboration.decideTurnRequest": 1, "window.maka.sessionCollaboration.getAccess": 1, "window.maka.sessionCollaboration.getTurnRequests": 1, - "window.maka.sessionCollaboration.importInvitation": 1, "window.maka.sessionCollaboration.prepareInvitation": 1, "window.maka.sessionCollaboration.revokeGrant": 1, "window.maka.sessionCollaboration.revokePrincipal": 1 @@ -2476,9 +2475,9 @@ }, "hookCalls": { "useEffect": 1, - "useState": 8, - "useToast": 2, - "useUiLocale": 2 + "useState": 6, + "useToast": 1, + "useUiLocale": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -3940,9 +3939,9 @@ "dependencyPaths": { "../../preload/bridge-contract.js": 1, "../features/runtime-host-management": 1, + "../features/session-collaboration": 1, "../locales/session-collaboration-copy.js": 1, "../locales/settings-projects-copy.js": 1, - "../session-collaboration-dialog.js": 1, "./password-input.js": 1, "./runtime-host-connection-code-dialog.js": 1, "./runtime-host-management-dialog.js": 1, 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 877b84d429..7149705b03 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 @@ -567,24 +567,61 @@ test('keeps independent shared-session credentials active for the same Host', as candidateHarness({ hostId: 'host-local' }).candidate, candidateHarness({ hostId: 'a'.repeat(64), ownership: 'external' }).candidate, candidateHarness({ hostId: 'a'.repeat(64), ownership: 'external' }).candidate, + candidateHarness({ hostId: 'a'.repeat(64), ownership: 'external' }).candidate, ]; const manager = await startRuntimeHostDesktopManager( {} as DesktopRuntimeHostCandidateStartInput, { startCandidate: async () => ready(candidates.shift()!) }, ); - await manager.enable(remoteTarget('shared-one', 'shared', 'session_guest')); - await manager.enable(remoteTarget('shared-two', 'shared', 'session_guest')); + await manager.mountGuest(remoteTarget('shared-one', 'shared', 'session_guest')); + await manager.mountGuest(remoteTarget('shared-two', 'shared', 'session_guest')); + await manager.enable(remoteTarget('owner', 'shared')); assert.deepEqual(manager.entries().map(({ target }) => target.profile.id), [ 'local', 'shared-one', 'shared-two', + 'owner', ]); assert.notEqual(manager.current('shared-one')?.epoch, manager.current('shared-two')?.epoch); await manager.close(); }); +test('aborts an in-flight Guest mount without publishing a late target', async () => { + const local = candidateHarness({ hostId: 'host-local' }).candidate; + let guestStarted!: () => void; + const started = new Promise((resolve) => { + guestStarted = resolve; + }); + const manager = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async (input) => { + if (!input.profileTarget) return ready(local); + guestStarted(); + const signal = input.signal; + assert.ok(signal); + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + }, + ); + const abort = new AbortController(); + const mounting = manager.mountGuest( + remoteTarget('shared-cancelled', 'shared', 'session_guest'), + abort.signal, + ); + await started; + abort.abort(new Error('cancelled')); + await assert.rejects(mounting, /cancelled/u); + await manager.unmountGuest('shared-cancelled'); + + assert.deepEqual(manager.entries().map(({ target }) => target.profile.id), ['local']); + await manager.close(); +}); + test('replays pairing finalization after an unknown commit and reconnect', async () => { const local = candidateHarness({ hostId: 'host-a' }); const remoteHostId = 'a'.repeat(64); 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 new file mode 100644 index 0000000000..28362a1bc7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { encodeCollaborationInvitationCode } from '@maka/runtime-host/protocol'; +import { encodeDesktopCollaborationInvitation } from '../runtime-host-collaboration-invitation.js'; +import { + createDesktopGuestSessionMountService, + type GuestSessionMount, + type GuestSessionMountStore, +} from '../runtime-host-guest-session-mounts.js'; + +const ROOT_ID = 'a'.repeat(64); + +test('retains a successful Guest mount and rehydrates the same authority after restart', async () => { + const store = memoryStore(); + const activated: string[] = []; + const first = service(store, { + mount: async (target) => { + activated.push(`${target.profile.id}:${target.credential}`); + }, + }); + + const result = await first.importInvitation(invitation('guest-one'), false); + assert.equal(result.kind, 'connected'); + if (result.kind !== 'connected') return; + await first.close(); + + let second: ReturnType; + const rehydrated = new Promise((resolve) => { + second = service(store, { + mount: async (target) => resolve(`${target.profile.id}:${target.credential}`), + }); + void second.start(); + }); + assert.equal(await rehydrated, `${result.mountId}:guest-one`); + assert.deepEqual(activated, [`${result.mountId}:guest-one`]); + await second!.close(); +}); + +test('removes failed activation desire instead of creating recoverable profile state', async () => { + const store = memoryStore(); + const unmounted: string[] = []; + const mounts = service(store, { + mount: async () => { + throw Object.assign(new Error('route missing'), { code: 'direct_path_unavailable' }); + }, + unmount: async (mountId) => { + unmounted.push(mountId); + }, + }); + + const result = await mounts.importInvitation(invitation('guest-two'), false); + assert.deepEqual(result.kind === 'error' ? result.reason : result.kind, 'peer_path_unavailable'); + assert.deepEqual(await store.read(), []); + assert.equal(unmounted.length, 1); +}); + +test('commits unmount desire before best-effort connection cleanup', async () => { + const store = memoryStore(); + const mounts = service(store, { + unmount: async () => { + assert.deepEqual(await store.read(), []); + throw new Error('connection shutdown failed'); + }, + }); + const result = await mounts.importInvitation(invitation('guest-three'), false); + assert.equal(result.kind, 'connected'); + if (result.kind !== 'connected') return; + + await mounts.remove(result.mountId); + assert.deepEqual(await store.read(), []); +}); + +test('awaits durable import rollback when closing during finalization', async () => { + const store = memoryStore(); + let started!: () => void; + const finalizing = new Promise((resolve) => { + started = resolve; + }); + const mounts = service(store, { + finalizeAccess: async (_mountId, signal) => { + started(); + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + }); + + const importing = mounts.importInvitation(invitation('guest-closing'), false); + await finalizing; + await mounts.close(); + + assert.equal((await importing).kind, 'error'); + assert.deepEqual(await store.read(), []); +}); + +function service( + store: GuestSessionMountStore, + overrides: { + readonly mount?: Parameters[0]['mount']; + readonly finalizeAccess?: Parameters[0]['finalizeAccess']; + readonly unmount?: Parameters[0]['unmount']; + } = {}, +) { + return createDesktopGuestSessionMountService({ + store, + mount: overrides.mount ?? (async () => undefined), + finalizeAccess: overrides.finalizeAccess ?? (async () => undefined), + unmount: overrides.unmount ?? (async () => undefined), + onError: () => undefined, + }); +} + +function memoryStore(): GuestSessionMountStore { + let mounts: readonly GuestSessionMount[] = []; + return { + read: async () => mounts.map((mount) => ({ ...mount })), + write: async (next) => { + mounts = next.map((mount) => ({ ...mount })); + }, + }; +} + +function invitation(credential: string): string { + return encodeDesktopCollaborationInvitation({ + invitationCode: encodeCollaborationInvitationCode({ + schemaVersion: 1, + rootId: ROOT_ID, + credential, + }), + target: { + name: 'Shared Host', + transport: { kind: 'tls', url: 'wss://runtime.example.com/' }, + }, + }); +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts index e4a8759ed1..13d63b4f52 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts @@ -35,7 +35,6 @@ import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_COMPATIBILITY_EPOCH, RUNTIME_HOST_PROTOCOL_VERSION, - encodeCollaborationInvitationCode, } from "@maka/runtime-host/protocol"; import { RuntimeHostPairingFinalizationInterruptedError, @@ -50,7 +49,6 @@ import { createDesktopRuntimeHostProfileService, resolveDesktopRuntimeHostStartup, } from "../runtime-host-profile-service.js"; -import { encodeDesktopCollaborationInvitation } from '../runtime-host-collaboration-invitation.js'; const ROOT_ID = "a".repeat(64); const PROFILE = { @@ -116,6 +114,41 @@ test("migrates the former selected Host into enabled and default preferences", a ); }); +test('removes obsolete experimental Guest profiles and pairing intents at startup', async () => { + const root = await clientRoot(); + const credentials = createClientRuntimeHostCredentialStore(root); + const catalog = createClientRuntimeHostProfileCatalog(root, credentials); + const guest = { ...PROFILE, id: 'shared-obsolete', access: 'session_guest' as const }; + await catalog.create(guest, 'guest-token'); + await writeFile( + join(root, 'runtime-host-profile-selection.json'), + `${JSON.stringify({ + schemaVersion: 2, + defaultProfileId: guest.id, + enabledRemoteProfileIds: [guest.id], + })}\n`, + ); + await writeDesktopRuntimeHostPairingIntents(credentials, [ + createDesktopRuntimeHostPairingIntent({ + target: { profile: guest, credential: 'guest-token' }, + wasEnabled: true, + }), + ]); + + const startup = await resolveDesktopRuntimeHostStartup(root, { + catalog, + credentialStore: credentials, + }); + + assert.deepEqual((await catalog.read()).profiles, []); + assert.deepEqual(startup.pairingIntents, []); + assert.deepEqual(startup.preferences, { + schemaVersion: 2, + defaultProfileId: 'local', + enabledRemoteProfileIds: [], + }); +}); + test("starts Local and preserves remote preferences when the profile catalog is unreadable", async () => { const root = await clientRoot(); const catalog = createClientRuntimeHostProfileCatalog(root); @@ -299,6 +332,46 @@ test("does not enable the same State Root twice", async () => { ); }); +test('starts an enabled Owner profile beside a retained Guest mount for the same Root', async () => { + const root = await clientRoot(); + const catalog = createClientRuntimeHostProfileCatalog(root); + await catalog.create(PROFILE, 'owner-token'); + await writeFile( + join(root, 'runtime-host-profile-selection.json'), + `${JSON.stringify({ + schemaVersion: 2, + defaultProfileId: PROFILE.id, + enabledRemoteProfileIds: [PROFILE.id], + })}\n`, + ); + const startup = await resolveDesktopRuntimeHostStartup(root, { catalog }); + const enabled: string[] = []; + const guest = { + profile: { + ...PROFILE, + id: 'shared-session', + access: 'session_guest' as const, + }, + credential: 'guest-token', + }; + const service = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup, + catalog, + states: () => [connectingLocal(), connecting(guest)], + enable: async (target) => { + enabled.push(target.profile.id); + }, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + + await service.startEnabledProfiles(); + + assert.deepEqual(enabled, [PROFILE.id]); +}); + test("preserves an enabled remote profile when that Host is unavailable", async () => { const root = await clientRoot(); const startup = await resolveDesktopRuntimeHostStartup(root); @@ -503,190 +576,6 @@ test("keeps a separate profile when the same Host is paired through another conn assert.equal((await catalog.resolve("replacement")).credential, "new-token"); }); -test('imports shared access without requiring or persisting an Owner credential', async () => { - const root = await clientRoot(); - const catalog = createClientRuntimeHostProfileCatalog(root); - const startup = await resolveDesktopRuntimeHostStartup(root, { catalog }); - const connected: ResolvedRuntimeHostProfile[] = []; - const finalized: string[] = []; - const service = createDesktopRuntimeHostProfileService({ - clientDataRoot: root, - startup, - catalog, - states: () => [connectingLocal()], - enable: async (target) => { - connected.push(target); - }, - disable: async () => undefined, - setDefault: () => undefined, - finalizePairing: async (profileId) => { - finalized.push(profileId); - }, - }); - - const result = await service.importCollaborationInvitation( - encodeDesktopCollaborationInvitation({ - invitationCode: encodeCollaborationInvitationCode({ - schemaVersion: 1, - rootId: ROOT_ID, - credential: 'guest-token', - }), - target: { - name: PROFILE.name, - transport: PROFILE.transport, - }, - }), - false, - ); - - assert.equal(result.kind, 'connected'); - if (result.kind !== 'connected') return; - assert.equal((await catalog.read()).profiles.length, 1); - const sharedProfileId = connected[0]?.profile.id; - assert.ok(sharedProfileId); - const shared = await catalog.resolve(sharedProfileId); - assert.equal(shared.profile.kind, 'remote'); - assert.equal(shared.profile.kind === 'remote' ? shared.profile.access : undefined, 'session_guest'); - assert.equal(shared.credential, 'guest-token'); - assert.deepEqual(finalized, [sharedProfileId]); -}); - -test('keeps separate Guest principals for sessions shared by the same Host', async () => { - const root = await clientRoot(); - const catalog = createClientRuntimeHostProfileCatalog(root); - const startup = await resolveDesktopRuntimeHostStartup(root, { catalog }); - const connected: ResolvedRuntimeHostProfile[] = []; - const service = createDesktopRuntimeHostProfileService({ - clientDataRoot: root, - startup, - catalog, - states: () => [connectingLocal(), ...connected.map(ready)], - enable: async (target) => { - connected.push(target); - }, - disable: async () => undefined, - setDefault: () => undefined, - finalizePairing: async () => undefined, - }); - const invitation = (credential: string) => encodeDesktopCollaborationInvitation({ - invitationCode: encodeCollaborationInvitationCode({ - schemaVersion: 1, - rootId: ROOT_ID, - credential, - }), - target: { name: PROFILE.name, transport: PROFILE.transport }, - }); - - assert.equal((await service.importCollaborationInvitation(invitation('guest-one'), false)).kind, 'connected'); - assert.equal((await service.importCollaborationInvitation(invitation('guest-two'), false)).kind, 'connected'); - - const profiles = await catalog.read(); - assert.equal(profiles.profiles.length, 2); - assert.notEqual(profiles.profiles[0]?.id, profiles.profiles[1]?.id); - assert.deepEqual( - await Promise.all(profiles.profiles.map(async ({ id }) => (await catalog.resolve(id)).credential)), - ['guest-one', 'guest-two'], - ); -}); - -test('lets the user discard an interrupted shared-session pairing', async () => { - const root = await clientRoot(); - const catalog = createClientRuntimeHostProfileCatalog(root); - const startup = await resolveDesktopRuntimeHostStartup(root, { catalog }); - const service = createDesktopRuntimeHostProfileService({ - clientDataRoot: root, - startup, - catalog, - states: () => [connectingLocal()], - enable: async () => undefined, - disable: async () => undefined, - setDefault: () => undefined, - finalizePairing: async () => { - throw new RuntimeHostPairingFinalizationInterruptedError(); - }, - }); - - const result = await service.importCollaborationInvitation( - encodeDesktopCollaborationInvitation({ - invitationCode: encodeCollaborationInvitationCode({ - schemaVersion: 1, - rootId: ROOT_ID, - credential: 'guest-token', - }), - target: { - name: PROFILE.name, - transport: PROFILE.transport, - }, - }), - false, - ); - - assert.equal(result.kind, 'pairing_pending'); - if (result.kind !== 'pairing_pending') return; - const pending = (await service.getSnapshot()).entries.find( - (entry) => entry.pairingPending, - ); - assert.ok(pending); - assert.equal(result.profileId, pending.profile.id); - assert.equal(pending.enabled, true); - - const discarded = await service.discardPairing(pending.profile.id); - assert.equal(discarded.pairingRecoveryPending, undefined); - assert.deepEqual((await catalog.read()).profiles, []); - assert.deepEqual( - (await resolveDesktopRuntimeHostStartup(root, { catalog })).pairingIntents, - [], - ); -}); - -test('requires explicit confirmation before importing plaintext shared access', async () => { - const root = await clientRoot(); - const catalog = createClientRuntimeHostProfileCatalog(root); - const startup = await resolveDesktopRuntimeHostStartup(root, { catalog }); - const connected: ResolvedRuntimeHostProfile[] = []; - const service = createDesktopRuntimeHostProfileService({ - clientDataRoot: root, - startup, - catalog, - states: () => [connectingLocal()], - enable: async (target) => { - connected.push(target); - }, - disable: async () => undefined, - setDefault: () => undefined, - finalizePairing: async () => undefined, - }); - - const code = encodeDesktopCollaborationInvitation({ - invitationCode: encodeCollaborationInvitationCode({ - schemaVersion: 1, - rootId: ROOT_ID, - credential: 'guest-token', - }), - target: { - name: 'Lab', - transport: { - kind: 'plaintext', - url: 'ws://runtime.example.com', - acknowledgement: 'plaintext-bearer-v1', - }, - }, - }); - assert.deepEqual(await service.importCollaborationInvitation(code, false), { - kind: 'error', - reason: 'insecure_confirmation_required', - }); - assert.equal(connected.length, 0); - - const result = await service.importCollaborationInvitation(code, true); - assert.equal(result.kind, 'connected'); - assert.equal(connected[0]?.profile.kind, 'remote'); - assert.equal( - connected[0]?.profile.kind === 'remote' ? connected[0].profile.transport.kind : undefined, - 'plaintext', - ); -}); - test('classifies connection-code failures without exposing transport errors to the renderer', async () => { const root = await clientRoot(); const startup = await resolveDesktopRuntimeHostStartup(root); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index a4d554b222..78edbe30d2 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -173,6 +173,11 @@ import { registerDesktopRuntimeHostProfileIpc, resolveDesktopRuntimeHostStartup, } from "./runtime-host-profile-service.js"; +import { + createDesktopGuestSessionMountService, + createGuestSessionMountStore, + registerDesktopGuestSessionMountIpc, +} from './runtime-host-guest-session-mounts.js'; import { createDesktopRuntimeHostSshTerminal, } from "./runtime-host-ssh-terminal.js"; @@ -504,6 +509,27 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({ runtimeHostManager.setDefaultProfile(profileId); }, }); +const guestSessionMountService = createDesktopGuestSessionMountService({ + store: createGuestSessionMountStore(runtimeHostCredentialStore), + mount: async (target, signal) => { + if (target.profile.kind !== 'remote' || !target.credential) { + throw new Error('A shared Session requires a remote Guest target'); + } + if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable'); + await runtimeHostManager.mountGuest( + { profile: target.profile, credential: target.credential }, + signal, + ); + }, + finalizeAccess: async (mountId, signal) => { + if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable'); + await runtimeHostManager.finalizeGuestAccess(mountId, signal); + }, + unmount: async (mountId) => { + if (!runtimeHostManager) return; + await runtimeHostManager.unmountGuest(mountId); + }, +}); const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({ ipcMain, clientInstanceId: runtimeHostClientInstanceId, @@ -967,7 +993,10 @@ runtimeHostManager = await startRuntimeHostDesktopManager( console.error("[runtime-host] Browser target retirement failed:", error), ); } - if (state.readiness === "unavailable") { + if ( + state.readiness === 'unavailable' && + state.target.profile.id === runtimeHostManager?.defaultProfileId() + ) { defaultRuntimeHostRecovery.offer({ profileId: state.target.profile.id, profileName: state.target.profile.name, @@ -1042,6 +1071,9 @@ runtimeHostManager = await startRuntimeHostDesktopManager( }); wireLifecycle(); runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfileId); +await guestSessionMountService.start().catch((error: unknown) => { + console.error('[runtime-host] shared Sessions could not be restored:', error); +}); await localRuntimeHostRemoteAccess.recover().catch((error: unknown) => { console.error('[runtime-host] interrupted Local Host setup could not be recovered:', error); }); @@ -1480,6 +1512,7 @@ function registerPersistentClientIpc(): void { }); registerMarkdownSaveIpc({ ipcMain, mainWindowController }); registerDesktopRuntimeHostProfileIpc(ipcMain, runtimeHostProfileService); + registerDesktopGuestSessionMountIpc(ipcMain, guestSessionMountService); registerClientSettingsIpc({ ipcMain, settingsStore, @@ -1720,6 +1753,7 @@ async function closeRuntimeHostDesktop(): Promise { const results = await Promise.allSettled([ Promise.resolve().then(() => runtimeHostManagement.close()), Promise.resolve().then(() => runtimeHostPeerMeshManagement.close()), + Promise.resolve().then(() => guestSessionMountService.close()), runtimeHostManager?.close(), runtimeHostPeerOwner?.close() ?? runtimeHostPeerClient?.close(), runtimeHostOnboarding.close(), diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 0db7ba5c0e..6c668af9b4 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -66,6 +66,12 @@ export interface RuntimeHostDesktopManager { enable( profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], ): Promise; + mountGuest( + profileTarget: NonNullable, + signal?: AbortSignal, + ): Promise; + finalizeGuestAccess(mountId: string, signal?: AbortSignal): Promise; + unmountGuest(mountId: string): Promise; disable(profileId: string): Promise; waitUntilReady( profileId: string, @@ -338,10 +344,14 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } finalizePairing(profileId: string): Promise { - return this.#mutateTarget(profileId, () => this.#finalizePairing(profileId)); + return this.#mutateTarget(profileId, () => this.#finalizeAccessCredential(profileId)); + } + + finalizeGuestAccess(mountId: string, signal?: AbortSignal): Promise { + return this.#mutateTarget(mountId, () => this.#finalizeAccessCredential(mountId, signal)); } - async #finalizePairing(profileId: string): Promise { + async #finalizeAccessCredential(profileId: string, externalSignal?: AbortSignal): Promise { const target = this.#requireTarget(profileId); if (target.target.profile.kind !== 'remote') { throw new Error('Only remote Runtime Host profiles can finalize pairing'); @@ -356,6 +366,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const signal = AbortSignal.any([ this.#pairingFinalizationShutdown.signal, timeout.signal, + ...(externalSignal ? [externalSignal] : []), ]); try { let candidate = await this.#waitForReadyCandidate(lifecycle, undefined, signal); @@ -367,11 +378,15 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { try { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) throw new RuntimeHostPairingFinalizationInterruptedError(); - const finalized = await candidate.client.finalizeAccessCredential(remainingMs); + const finalized = await abortableOperation( + () => candidate.client.finalizeAccessCredential(remainingMs), + signal, + ); if (finalized.reconnectRequired) { await candidate.close(); await this.#waitForReadyCandidate(lifecycle, candidate, signal); } + signal.throwIfAborted(); return; } catch (error) { if (pairingFinalizeTimedOut(error)) { @@ -473,12 +488,32 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], ): Promise { if (!profileTarget) throw new Error('A non-local Runtime Host profile is required'); - return this.#mutateTarget(profileTarget.profile.id, () => this.#enable(profileTarget)); + if (isSessionGuestProfile(profileTarget.profile)) { + throw new Error('Session Guest targets must be mounted instead of enabled as profiles'); + } + return this.#mutateTarget(profileTarget.profile.id, () => + this.#enable(profileTarget, false), + ); + } + + mountGuest( + profileTarget: NonNullable, + signal?: AbortSignal, + ): 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), + ); } async #enable( profileTarget: NonNullable, + allowSameRoot: boolean, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); if (this.#closed) throw new Error('Desktop Runtime Host manager is closed'); const profileId = profileTarget.profile.id; if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { @@ -491,10 +526,8 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { : target.hostId; if ( rootId === profileTarget.profile.rootId && - !( - isSessionGuestProfile(target.target.profile) && - isSessionGuestProfile(profileTarget.profile) - ) + !allowSameRoot && + !isSessionGuestProfile(target.target.profile) ) { throw new Error(`Runtime Host ${profileTarget.profile.rootId} is already enabled`); } @@ -514,7 +547,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { readiness: 'connecting', }); try { - target.lifecycle = await this.#startLifecycle(target, false); + target.lifecycle = await this.#startLifecycle(target, false, signal); if (this.#closed) { await target.lifecycle.close(); throw new Error('Desktop Runtime Host manager is closed'); @@ -539,6 +572,16 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return this.#mutateTarget(profileId, () => this.#disable(profileId)); } + unmountGuest(mountId: string): Promise { + return this.#mutateTarget(mountId, async () => { + const target = this.#targets.get(mountId); + if (target && !isSessionGuestProfile(target.target.profile)) { + throw new Error('Runtime Host target is not a Session Guest mount'); + } + await this.#disable(mountId); + }); + } + async waitUntilReady( profileId: string, previousHostEpoch?: string, @@ -757,6 +800,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { async #startLifecycle( target: DesktopRuntimeHostTargetGeneration, reportInitialFailure: boolean, + initialSignal?: AbortSignal, ): Promise> { let starting = true; try { @@ -764,7 +808,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { connect: (signal) => this.connect( target, - signal, + starting && initialSignal + ? AbortSignal.any([signal, initialSignal]) + : signal, starting ? target.input.profileTarget?.sshInteraction : 'batch', ), onReconnectError: (error) => { @@ -1177,6 +1223,26 @@ function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { }); } +function abortableOperation(operation: () => Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + const running = operation(); + return new Promise((resolve, reject) => { + let settled = false; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + callback(); + }; + const onAbort = () => settle(() => reject(signal.reason)); + signal.addEventListener('abort', onAbort, { once: true }); + void running.then( + (value) => settle(() => resolve(value)), + (error: unknown) => settle(() => reject(error)), + ); + }); +} + async function waitForProcessRetirement( registration: HostRegistration, signal: AbortSignal, diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts new file mode 100644 index 0000000000..ae54938d5e --- /dev/null +++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts @@ -0,0 +1,412 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { + decodeRemoteRuntimeHostProfile, + RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, + type ResolvedRuntimeHostProfile, + type RuntimeHostRemoteTransport, +} from '@maka/runtime-host/client'; +import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol'; +import type { CredentialStore } from '@maka/storage/credential-store'; +import type { DesktopSessionCollaborationImportResult } from '../preload/bridge-contract.js'; +import { decodeDesktopCollaborationInvitation } from './runtime-host-collaboration-invitation.js'; + +const STORE_SCHEMA_VERSION = 1; +const STORE_SLOT = 'desktop-guest-session-mounts'; +const MAX_MOUNTS = 128; +const STARTUP_RETRY_MAX_MS = 30_000; + +export interface GuestSessionMount { + readonly mountId: string; + readonly name: string; + readonly rootId: string; + readonly transport: RuntimeHostRemoteTransport; + readonly credential: string; +} + +export interface GuestSessionMountSummary { + readonly mountId: string; + readonly name: string; +} + +interface GuestSessionMountDocument { + readonly schemaVersion: typeof STORE_SCHEMA_VERSION; + readonly mounts: readonly GuestSessionMount[]; +} + +export interface GuestSessionMountStore { + read(): Promise; + write(mounts: readonly GuestSessionMount[]): Promise; +} + +export interface DesktopGuestSessionMountService { + start(): Promise; + list(): Promise; + importInvitation( + code: string, + allowInsecure: boolean, + ): Promise; + remove(mountId: string): Promise; + close(): Promise; +} + +export function createGuestSessionMountStore( + credentials: Pick, +): GuestSessionMountStore { + return { + async read() { + const raw = await credentials.getSecret(STORE_SLOT, 'runtime_host_access'); + if (raw === null) return []; + return decodeDocument(JSON.parse(raw) as unknown).mounts; + }, + async write(mounts) { + if (mounts.length > MAX_MOUNTS) { + throw new Error(`At most ${MAX_MOUNTS} shared Sessions can be retained`); + } + const document: GuestSessionMountDocument = { + schemaVersion: STORE_SCHEMA_VERSION, + mounts: [...mounts].sort((left, right) => left.mountId.localeCompare(right.mountId)), + }; + decodeDocument(document); + await credentials.setSecret( + STORE_SLOT, + 'runtime_host_access', + `${JSON.stringify(document)}\n`, + ); + }, + }; +} + +export function createDesktopGuestSessionMountService(input: { + readonly store: GuestSessionMountStore; + readonly mount: (target: ResolvedRuntimeHostProfile, signal: AbortSignal) => Promise; + readonly finalizeAccess: (mountId: string, signal: AbortSignal) => Promise; + readonly unmount: (mountId: string) => Promise; + readonly wait?: (delayMs: number, signal: AbortSignal) => Promise; + readonly onError?: (error: Error, mount: GuestSessionMount) => void; +}): DesktopGuestSessionMountService { + const wait = input.wait ?? waitForDelay; + const onError = input.onError ?? ((error, mount) => { + console.warn(`[runtime-host] shared Session ${mount.mountId} is unavailable:`, error); + }); + const controllers = new Map(); + const importControllers = new Set(); + const importTasks = new Set>(); + const tasks = new Map>(); + let mounts: Map | undefined; + let mutationTail = Promise.resolve(); + let closed = false; + + const mutate = (operation: () => Promise): Promise => { + const pending = mutationTail.then(operation); + mutationTail = pending.then( + () => undefined, + () => undefined, + ); + return pending; + }; + + const load = async (): Promise> => { + if (!mounts) mounts = new Map((await input.store.read()).map((mount) => [mount.mountId, mount])); + return mounts; + }; + + const persist = async (next: Map): Promise => { + await input.store.write([...next.values()]); + mounts = next; + }; + + const activate = async (mount: GuestSessionMount, signal: AbortSignal): Promise => { + await input.mount(resolveMountTarget(mount), signal); + signal.throwIfAborted(); + await input.finalizeAccess(mount.mountId, signal); + signal.throwIfAborted(); + }; + + const beginStartupReconciliation = (mount: GuestSessionMount): void => { + if (closed || tasks.has(mount.mountId)) return; + const controller = new AbortController(); + controllers.set(mount.mountId, controller); + const task = (async () => { + let delayMs = 1_000; + while (!controller.signal.aborted) { + if (!(await load()).has(mount.mountId)) return; + try { + await activate(mount, controller.signal); + return; + } catch (error) { + if (controller.signal.aborted) return; + onError(asError(error), mount); + await wait(delayMs, controller.signal); + delayMs = Math.min(delayMs * 2, STARTUP_RETRY_MAX_MS); + } + } + })().finally(() => { + if (controllers.get(mount.mountId) === controller) controllers.delete(mount.mountId); + if (tasks.get(mount.mountId) === task) tasks.delete(mount.mountId); + }); + tasks.set(mount.mountId, task); + void task.catch((error) => { + if (!controller.signal.aborted) onError(asError(error), mount); + }); + }; + + const remove = async (mountId: string): Promise => { + const removed = await mutate(async () => { + const current = await load(); + const mount = current.get(mountId); + if (!mount) return undefined; + const next = new Map(current); + next.delete(mountId); + await persist(next); + return mount; + }); + if (!removed) return; + controllers.get(mountId)?.abort(new Error('Shared Session mount was removed')); + void input.unmount(mountId).catch((error) => onError(asError(error), removed)); + }; + + const runImport = async ( + code: string, + allowInsecure: boolean, + controller: AbortController, + ): Promise => { + controller.signal.throwIfAborted(); + let bundle; + let invitation; + try { + bundle = decodeDesktopCollaborationInvitation(code); + invitation = decodeCollaborationInvitationCode(bundle.invitationCode); + } catch { + return { kind: 'error', reason: 'invalid_code' }; + } + if (bundle.target.transport.kind === 'plaintext' && !allowInsecure) { + return { kind: 'error', reason: 'insecure_confirmation_required' }; + } + const mount = decodeMount({ + mountId: `shared-${randomUUID()}`, + name: `${bundle.target.name} · Shared`, + rootId: invitation.rootId, + transport: bundle.target.transport, + credential: invitation.credential, + }); + const retained = await mutate(async () => { + controller.signal.throwIfAborted(); + const current = await load(); + if (current.size >= MAX_MOUNTS) return false; + await persist(new Map(current).set(mount.mountId, mount)); + return true; + }); + if (!retained) { + return { + kind: 'error', + reason: 'connection_failed', + message: `At most ${MAX_MOUNTS} shared Sessions can be retained`, + }; + } + controllers.set(mount.mountId, controller); + try { + await activate(mount, controller.signal); + 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 }; + } catch (error) { + await mutate(async () => { + const next = new Map(await load()); + next.delete(mount.mountId); + await persist(next); + }); + 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, + }; + } finally { + if (controllers.get(mount.mountId) === controller) controllers.delete(mount.mountId); + } + }; + + const importInvitation = ( + code: string, + allowInsecure: boolean, + ): Promise => { + if (closed) return Promise.reject(new Error('Shared Session mount service is closed')); + const controller = new AbortController(); + importControllers.add(controller); + let task!: Promise; + task = runImport(code, allowInsecure, controller).finally(() => { + importControllers.delete(controller); + importTasks.delete(task); + }); + importTasks.add(task); + return task; + }; + + return { + async start() { + if (closed) return; + const current = await mutate(load); + for (const mount of current.values()) beginStartupReconciliation(mount); + }, + + async list() { + return [...(await mutate(load)).values()] + .map(({ mountId, name }) => ({ mountId, name })) + .sort((left, right) => left.name.localeCompare(right.name)); + }, + + importInvitation, + + remove, + + async close() { + closed = true; + for (const controller of controllers.values()) { + controller.abort(new Error('Shared Session mount service is closed')); + } + for (const controller of importControllers) { + controller.abort(new Error('Shared Session mount service is closed')); + } + await Promise.allSettled([...importTasks, ...tasks.values()]); + await mutationTail; + controllers.clear(); + importControllers.clear(); + }, + }; +} + +export function registerDesktopGuestSessionMountIpc( + ipcMain: Pick, + service: DesktopGuestSessionMountService, +): () => void { + const channels = [ + 'session-collaboration:import', + '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[1], () => service.list()); + ipcMain.handle(channels[2], (_event, mountId: string) => service.remove(mountId)); + return () => { + for (const channel of channels) ipcMain.removeHandler(channel); + }; +} + +function resolveMountTarget(mount: GuestSessionMount): ResolvedRuntimeHostProfile { + return { + profile: decodeRemoteRuntimeHostProfile({ + id: mount.mountId, + name: mount.name, + kind: 'remote', + rootId: mount.rootId, + transport: mount.transport, + access: 'session_guest', + }), + credential: mount.credential, + }; +} + +function decodeDocument(value: unknown): GuestSessionMountDocument { + if (!isRecord(value) || !hasExactKeys(value, ['schemaVersion', 'mounts'])) { + throw new Error('Shared Session mount store is invalid'); + } + if (value.schemaVersion !== STORE_SCHEMA_VERSION || !Array.isArray(value.mounts)) { + throw new Error('Shared Session mount store version is unsupported'); + } + if (value.mounts.length > MAX_MOUNTS) { + throw new Error(`Shared Session mount store exceeds ${MAX_MOUNTS} entries`); + } + const mounts = value.mounts.map(decodeMount); + if (new Set(mounts.map((mount) => mount.mountId)).size !== mounts.length) { + throw new Error('Shared Session mount identities must be unique'); + } + return { schemaVersion: STORE_SCHEMA_VERSION, mounts }; +} + +function decodeMount(value: unknown): GuestSessionMount { + if ( + !isRecord(value) || + !hasExactKeys(value, ['mountId', 'name', 'rootId', 'transport', 'credential']) || + typeof value.credential !== 'string' || + !value.credential || + /\s/u.test(value.credential) || + Buffer.byteLength(value.credential, 'utf8') > RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES + ) { + throw new Error('Shared Session mount is invalid'); + } + const target = decodeRemoteRuntimeHostProfile({ + id: value.mountId, + name: value.name, + kind: 'remote', + rootId: value.rootId, + transport: value.transport, + access: 'session_guest', + }); + return { + mountId: target.id, + name: target.name, + rootId: target.rootId, + transport: target.transport, + credential: value.credential, + }; +} + +function isPeerPathUnavailable(error: unknown): boolean { + if (!isRecord(error) || typeof error.code !== 'string') return false; + return error.code === 'direct_path_unavailable' || error.code === 'transit_unavailable'; +} + +function waitForDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timer = setTimeout(done, delayMs); + function done(): void { + signal.removeEventListener('abort', aborted); + resolve(); + } + function aborted(): void { + clearTimeout(timer); + signal.removeEventListener('abort', aborted); + reject(signal.reason); + } + signal.addEventListener('abort', aborted, { once: true }); + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + const sorted = [...expected].sort(); + return keys.length === sorted.length && keys.every((key, index) => key === sorted[index]); +} + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index c6b42484c1..670283e40b 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -37,7 +37,6 @@ import { type RuntimeHostProfileCatalog, } from "@maka/runtime-host/client"; import { runtimeHostAccessCredentialFingerprint } from "@maka/runtime-host/operator"; -import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol'; import type { CredentialStore } from "@maka/storage/credential-store"; import { withFileUpdateLock } from "@maka/storage/file-update-lock"; import type { @@ -46,7 +45,6 @@ import type { DesktopRuntimeHostProfileEntry, DesktopRuntimeHostProfileSnapshot, DesktopRuntimeHostConnectionCodeImportResult, - DesktopSessionCollaborationImportResult, } from "../preload/bridge-contract.js"; import { RuntimeHostPairingFinalizationInterruptedError, @@ -68,7 +66,6 @@ import { type DesktopRuntimeHostManagedServiceBinding, type DesktopRuntimeHostManagedServiceStore, } from "./runtime-host-managed-services.js"; -import { decodeDesktopCollaborationInvitation } from './runtime-host-collaboration-invitation.js'; import type { DesktopCollaborationConnectionTarget } from './runtime-host-collaboration-invitation.js'; const PREFERENCES_SCHEMA_VERSION = 2; @@ -103,10 +100,6 @@ export interface DesktopRuntimeHostProfileService { }, ): Promise<{ readonly profileId: string }>; importConnectionCode(code: string): Promise; - importCollaborationInvitation( - code: string, - allowInsecure: boolean, - ): Promise; resolveManagedService( profileId: string, ): Promise; @@ -215,6 +208,25 @@ export async function resolveDesktopRuntimeHostStartup( unavailable, }; } + const obsoleteProfileIds = new Set( + document.profiles.flatMap((profile) => + profile.kind === 'remote' && profile.access === 'session_guest' ? [profile.id] : [], + ), + ); + if (obsoleteProfileIds.size > 0) { + await recoverAbandonedProfileLock(join(clientDataRoot, PROFILE_FILE)); + } + for (const profileId of obsoleteProfileIds) await catalog.remove(profileId); + if (obsoleteProfileIds.size > 0) document = await catalog.read(); + if (!pairingReadFailure) { + const retained = pairingIntents.filter( + (intent) => intent.target.profile.access !== 'session_guest', + ); + if (retained.length !== pairingIntents.length) { + await writeDesktopRuntimeHostPairingIntents(credentialStore, retained); + pairingIntents = retained; + } + } const profileIds = new Set(document.profiles.map((profile) => profile.id)); const defaultProfile = document.profiles.find( (profile) => profile.id === preferences.defaultProfileId, @@ -692,15 +704,18 @@ export function createDesktopRuntimeHostProfileService(input: { }, ): Promise<{ readonly profileId: string }> => { requireSaveInput(value); + if (value.profile.access === 'session_guest') { + return Promise.reject( + new Error('Session Guest access must be retained as a shared Session mount'), + ); + } return mutateProfiles(async () => { const currentDocument = await catalog.read(); - const existing = value.profile.access === 'session_guest' - ? undefined - : currentDocument.profiles.find((profile) => - profile.kind === 'remote' && - profile.rootId === value.profile.rootId && - sameRemoteRuntimeHostProfileTarget(profile, value.profile), - ); + const existing = currentDocument.profiles.find((profile) => + profile.kind === 'remote' && + profile.rootId === value.profile.rootId && + sameRemoteRuntimeHostProfileTarget(profile, value.profile), + ); const previousTarget = existing ? await catalog.resolve(existing.id) : undefined; const profile = existing ? { ...value.profile, id: existing.id } : value.profile; const target = { profile, credential: value.credential } as const; @@ -792,43 +807,6 @@ export function createDesktopRuntimeHostProfileService(input: { return { kind: 'error', reason: connectionCodeImportFailure(error) }; } }, - async importCollaborationInvitation(code, allowInsecure) { - let bundle; - let invitation; - try { - bundle = decodeDesktopCollaborationInvitation(code); - invitation = decodeCollaborationInvitationCode(bundle.invitationCode); - } catch { - return { kind: 'error', reason: 'invalid_code' }; - } - if (bundle.target.transport.kind === 'plaintext' && !allowInsecure) { - return { kind: 'error', reason: 'insecure_confirmation_required' }; - } - const profileId = `shared-${randomUUID()}`; - try { - await addAndEnableVerified({ - profile: { - id: profileId, - name: `${bundle.target.name} · Shared`, - kind: 'remote', - rootId: invitation.rootId, - transport: bundle.target.transport, - access: 'session_guest', - }, - credential: invitation.credential, - }); - return { kind: 'connected' }; - } catch (error) { - if (pairingIntents.has(profileId)) { - return { kind: 'pairing_pending', profileId }; - } - return { - kind: 'error', - reason: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed', - message: asError(error).message, - }; - } - }, rotateManagedCredential(expected, credential) { return mutateProfiles(async () => { const profileId = expected.profile.id; @@ -1214,12 +1192,6 @@ export function createDesktopRuntimeHostProfileService(input: { ) { throw new Error("Enable a Runtime Host before making it the default"); } - if (profileId !== LOCAL_RUNTIME_HOST_PROFILE.id) { - const target = await catalog.resolve(profileId); - if (target.profile.kind === 'remote' && target.profile.access === 'session_guest') { - throw new Error('A shared Session connection cannot be the default Runtime Host'); - } - } const next = { ...preferences, defaultProfileId: profileId }; await persist(next); input.setDefault(profileId); @@ -1316,6 +1288,12 @@ function assertRootIsNotEnabled( ); const duplicateState = states.find((state) => { if (state.target.profile.id === target.profile.id) return false; + if ( + state.target.profile.kind === 'remote' && + state.target.profile.access === 'session_guest' + ) { + return false; + } const stateRootId = state.target.profile.kind !== 'local' ? state.target.profile.rootId : state.readiness === "ready" @@ -1326,7 +1304,7 @@ function assertRootIsNotEnabled( return stateRootId === rootId; }); const duplicate = duplicateProfile ?? duplicateState?.target.profile; - if (duplicate && !(isSessionGuestProfile(target.profile) && isSessionGuestProfile(duplicate))) { + if (duplicate) { throw new Error( `Runtime Host profile "${duplicate.name}" is already connected to this computer; disable it before adding another connection`, ); @@ -1354,12 +1332,6 @@ async function recoverAbandonedProfileLock(profilePath: string): Promise { }); } -function isSessionGuestProfile( - profile: ResolvedRuntimeHostProfile['profile'], -): boolean { - return profile.kind === 'remote' && profile.access === 'session_guest'; -} - async function persistIfCurrentTarget( catalog: RuntimeHostProfileCatalog, profilePath: string, @@ -1400,7 +1372,6 @@ export function registerDesktopRuntimeHostProfileIpc( "runtime-host-profiles:remove", "runtime-host-profiles:resolve-pairing-recovery", "runtime-host-profiles:discard-pairing", - 'session-collaboration:import', ] as const; ipcMain.handle(channels[0], () => service.getSnapshot()); ipcMain.handle(channels[1], (_event, value: DesktopRuntimeHostProfileAddInput) => @@ -1418,9 +1389,6 @@ export function registerDesktopRuntimeHostProfileIpc( ipcMain.handle(channels[7], (_event, profileId: string) => service.discardPairing(profileId), ); - ipcMain.handle(channels[8], (_event, code: string, allowInsecure: boolean) => - service.importCollaborationInvitation(code, allowInsecure), - ); return () => { for (const channel of channels) ipcMain.removeHandler(channel); }; @@ -1461,11 +1429,6 @@ function errorCode(error: unknown): string | undefined { return typeof error.code === 'string' ? error.code : undefined; } -function isPeerPathUnavailable(error: unknown): boolean { - const code = errorCode(error); - return code === 'direct_path_unavailable' || code === 'transit_unavailable'; -} - function requireSaveInput(value: unknown): asserts value is { readonly profile: PersistedRuntimeHostProfile; readonly credential?: string; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 10fc8cf377..c4ea01fd0f 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -326,8 +326,7 @@ export interface DesktopRuntimeHostProfileSnapshot { } export type DesktopSessionCollaborationImportResult = - | { readonly kind: 'connected' } - | { readonly kind: 'pairing_pending'; readonly profileId: string } + | { readonly kind: 'connected'; readonly mountId: string } | { readonly kind: 'error'; readonly reason: @@ -338,6 +337,11 @@ export type DesktopSessionCollaborationImportResult = readonly message?: string; }; +export interface DesktopGuestSessionMountSummary { + readonly mountId: string; + readonly name: string; +} + export type DesktopSessionCollaborationPrepareResult = | { readonly kind: 'prepared'; @@ -701,6 +705,8 @@ export interface MakaBridge { readonly code: string; readonly allowInsecure?: boolean; }): Promise; + listMounts(): Promise; + removeMount(mountId: string): Promise; requestTurn( sessionId: string, input: { readonly turnId: string; readonly text: string }, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 157a758e6e..b3fc2ecd9a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1252,6 +1252,12 @@ const makaBridge = { importInvitation({ code, allowInsecure = false }) { return ipcRenderer.invoke('session-collaboration:import', code, allowInsecure); }, + listMounts() { + return ipcRenderer.invoke('session-collaboration:mount:list'); + }, + removeMount(mountId) { + return ipcRenderer.invoke('session-collaboration:mount:remove', mountId); + }, async requestTurn(sessionId, input) { const session = await runtimeHostSessionRef(sessionId); return ipcRenderer.invoke( diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index ef4ff25bd6..47c188d6e6 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -21,12 +21,14 @@ import type { ReactNode } from 'react'; import { GoalServicesProvider } from '../features/goals'; import { ModuleHubServicesProvider } from '../features/module-hub'; import { RuntimeHostManagementServicesProvider } from '../features/runtime-host-management'; +import { SessionCollaborationServicesProvider } from '../features/session-collaboration'; import { SessionNavigationServicesProvider } from '../features/session-navigation'; import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; import { createDesktopModuleHubServices } from '../platform/desktop/create-module-hub-services'; import { createDesktopRuntimeHostManagementServices } from '../platform/desktop/create-runtime-host-management-services'; +import { createDesktopSessionCollaborationServices } from '../platform/desktop/create-session-collaboration-services'; import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; @@ -36,6 +38,7 @@ export function createDesktopFeatureServices() { goal: createDesktopGoalServices(), moduleHub: createDesktopModuleHubServices(), runtimeHostManagement: createDesktopRuntimeHostManagementServices(), + sessionCollaboration: createDesktopSessionCollaborationServices(), sessionNavigation: createDesktopSessionNavigationServices(), taskEntry: createDesktopTaskEntryServices(), workbar: createDesktopWorkbarServices(), @@ -48,17 +51,19 @@ export function DesktopFeatureServicesProvider(props: { }) { return ( - - - - - - {props.children} - - - - - + + + + + + + {props.children} + + + + + + ); } diff --git a/apps/desktop/src/renderer/features/session-collaboration/index.ts b/apps/desktop/src/renderer/features/session-collaboration/index.ts new file mode 100644 index 0000000000..cd08613ef9 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-collaboration/index.ts @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SessionCollaborationServicesProvider } from './services-context'; +export { SessionCollaborationJoinDialog } from './ui/session-collaboration-join-dialog'; +export type { + SessionCollaborationImportResult, + SessionCollaborationMountSummary, + SessionCollaborationServices, +} from './ports'; diff --git a/apps/desktop/src/renderer/features/session-collaboration/ports.ts b/apps/desktop/src/renderer/features/session-collaboration/ports.ts new file mode 100644 index 0000000000..dd0d019a32 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-collaboration/ports.ts @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type SessionCollaborationImportResult = + | { readonly kind: 'connected'; readonly mountId: string } + | { + readonly kind: 'error'; + readonly reason: + | 'invalid_code' + | 'insecure_confirmation_required' + | 'peer_path_unavailable' + | 'connection_failed'; + readonly message?: string; + }; + +export interface SessionCollaborationMountSummary { + readonly mountId: string; + readonly name: string; +} + +export interface SessionCollaborationServices { + importInvitation(input: { + readonly code: string; + readonly allowInsecure: boolean; + }): Promise; + listMounts(): Promise; + removeMount(mountId: string): Promise; +} diff --git a/apps/desktop/src/renderer/features/session-collaboration/services-context.tsx b/apps/desktop/src/renderer/features/session-collaboration/services-context.tsx new file mode 100644 index 0000000000..9d6e556b5c --- /dev/null +++ b/apps/desktop/src/renderer/features/session-collaboration/services-context.tsx @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, useContext, type ReactNode } from 'react'; +import type { SessionCollaborationServices } from './ports.js'; + +const SessionCollaborationServicesContext = + createContext(null); + +export function SessionCollaborationServicesProvider(props: { + readonly services: SessionCollaborationServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useSessionCollaborationServices(): SessionCollaborationServices { + const services = useContext(SessionCollaborationServicesContext); + if (!services) throw new Error('SessionCollaborationServicesProvider is missing'); + return services; +} 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 new file mode 100644 index 0000000000..ef5aa296a7 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, 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'; +import { + Banner, + Button, + FormLayout, + TextArea, + useToast, +} from '@maka/ui'; +import { useSessionCollaborationServices } from '../services-context.js'; +import type { SessionCollaborationMountSummary } from '../ports.js'; + +export interface SessionCollaborationJoinCopy { + readonly joinTitle: string; + readonly joinDescription: string; + readonly insecureTitle: string; + readonly insecureBody: string; + readonly joinInsecure: string; + readonly close: string; + readonly connectionFailed: string; + readonly invalidCode: string; + readonly directPathUnavailable: string; + readonly code: string; + readonly join: string; + readonly joining: string; + readonly retainedTasks: string; + readonly disconnect: string; + readonly disconnectFailed: string; +} + +export function SessionCollaborationJoinDialog(props: { + readonly copy: SessionCollaborationJoinCopy; + readonly onImported: () => void; + readonly onClose: () => void; +}) { + const services = useSessionCollaborationServices(); + const toast = useToast(); + const [code, setCode] = useState(''); + const [mounts, setMounts] = useState([]); + const [removingMountId, setRemovingMountId] = useState(); + const [joinState, setJoinState] = useState< + | { readonly kind: 'idle' } + | { readonly kind: 'working' } + | { readonly kind: 'failed'; readonly message: string } + >({ kind: 'idle' }); + const working = joinState.kind === 'working'; + const failure = joinState.kind === 'failed' ? joinState.message : undefined; + + useEffect(() => { + let disposed = false; + void services.listMounts().then( + (next) => { + if (!disposed) setMounts(next); + }, + () => undefined, + ); + return () => { + disposed = true; + }; + }, [services]); + + async function join(allowInsecure = false): Promise { + setJoinState({ kind: 'working' }); + try { + const result = await services.importInvitation({ + code: code.trim(), + allowInsecure, + }); + if (result.kind === 'error' && result.reason === 'insecure_confirmation_required') { + const confirmed = await toast.confirm({ + title: props.copy.insecureTitle, + description: props.copy.insecureBody, + confirmLabel: props.copy.joinInsecure, + cancelLabel: props.copy.close, + destructive: true, + }); + if (confirmed) await join(true); + return; + } + if (result.kind === 'error') { + const message = importError(props.copy, result.reason, result.message); + setJoinState({ kind: 'failed', message }); + toast.error(props.copy.joinTitle, message); + return; + } + props.onImported(); + props.onClose(); + } catch (error) { + const message = errorMessage(error); + setJoinState({ kind: 'failed', message }); + toast.error(props.copy.joinTitle, message); + } finally { + setJoinState((current) => current.kind === 'working' ? { kind: 'idle' } : current); + } + } + + async function disconnect(mountId: string): Promise { + setRemovingMountId(mountId); + try { + await services.removeMount(mountId); + setMounts((current) => current.filter((mount) => mount.mountId !== mountId)); + } catch (error) { + toast.error(props.copy.disconnectFailed, errorMessage(error)); + } finally { + setRemovingMountId(undefined); + } + } + + return ( + !open && !working && props.onClose()} + purpose="form" + width={560} + > + !open && !working && props.onClose()} + /> + )} + content={( + + + {working ? : null} + {failure ? ( + + ) : null} +