diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2f8f24e25e..64d7fe30f4 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -586,7 +586,7 @@ "react": 1 }, "importSpecifiers": 39, - "nonTriviaTokens": 3877 + "nonTriviaTokens": 3836 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 14, @@ -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__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index 0a0f5e3a70..8506d8a503 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -30,6 +30,7 @@ import { projectDesktopTurnRecord, projectDesktopUsageStats, } from '../../shared/desktop-session-projection.js'; +import { runtimeHostChangeRetiresSession } from '../../shared/runtime-host-identity.js'; test('keeps equal raw Session ids distinct across Runtime Hosts', () => { const raw = summary('same-session'); @@ -57,6 +58,41 @@ test('keeps equal raw Session ids distinct across Runtime Hosts', () => { assert.equal(remote.profileName, 'Office'); }); +test('retires an active Session only after it leaves the refreshed Host catalog', () => { + const owner = projectDesktopSessionSummary( + { + hostId: 'shared-root', + profileId: 'owner', + profileName: 'Owner', + profileKind: 'remote', + }, + summary('shared-session'), + ); + const guest = projectDesktopSessionSummary( + { + hostId: 'shared-root', + profileId: 'guest', + profileName: 'Guest', + profileKind: 'remote', + }, + summary('shared-session'), + ); + const removedGuest = { + epoch: 'guest-epoch', + profileId: 'guest', + profileName: 'Guest', + profileKind: 'remote', + profileAccess: 'session_guest', + readiness: 'unavailable', + hostId: 'shared-root', + isDefault: false, + removed: true, + } as const; + + assert.equal(runtimeHostChangeRetiresSession(removedGuest, guest.id, [owner]), false); + assert.equal(runtimeHostChangeRetiresSession(removedGuest, guest.id, []), true); +}); + test('projects typed linked Session ids without rewriting opaque tool data', () => { const host = { hostId: 'remote-root' }; const linkedSessionId = JSON.stringify(['remote-root', 'child-session']); diff --git a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts index 4bf36c15f3..3a7b8712d2 100644 --- a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts @@ -194,6 +194,7 @@ describe('createDesktopModuleHubServices', () => { profileId: 'remote-a', profileName: 'Remote', profileKind: 'remote', + profileAccess: 'owner', readiness: 'ready', hostId: 'host-a', isDefault: true, 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..3daa268749 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts @@ -0,0 +1,296 @@ +/* + * 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'; +import { RuntimeHostPairingFinalizationInterruptedError } from '../runtime-host-desktop-manager.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('settles admitted finalization before committing unmount desire', async () => { + const store = memoryStore(); + let started!: () => void; + let finish!: () => void; + const finalizing = new Promise((resolve) => { + started = resolve; + }); + const finalized = new Promise((resolve) => { + finish = resolve; + }); + const mounts = service(store, { + finalizeAccess: async () => { + started(); + await finalized; + }, + unmount: async () => { + assert.deepEqual(await store.read(), []); + throw new Error('connection shutdown failed'); + }, + }); + const importing = mounts.importInvitation(invitation('guest-three'), false); + await finalizing; + const [retained] = await store.read(); + assert.ok(retained); + const removing = mounts.remove(retained.mountId); + await Promise.resolve(); + assert.equal((await store.read()).length, 1); + finish(); + const result = await importing; + assert.equal(result.kind, 'connected'); + await removing; + assert.deepEqual(await store.read(), []); +}); + +test('removal fences a connecting startup mount before credential finalization', async () => { + const retained = retainedMount('shared-connecting'); + let stored: readonly GuestSessionMount[] = [retained]; + let releaseWrite!: () => void; + let releaseMount!: () => void; + let markConnecting!: () => void; + let markDeleting!: () => void; + const writeReleased = new Promise((resolve) => { + releaseWrite = resolve; + }); + const mountReleased = new Promise((resolve) => { + releaseMount = resolve; + }); + const connecting = new Promise((resolve) => { + markConnecting = resolve; + }); + const deleting = new Promise((resolve) => { + markDeleting = resolve; + }); + const store: GuestSessionMountStore = { + read: async () => stored, + write: async (next) => { + markDeleting(); + await writeReleased; + stored = next; + }, + }; + let finalizations = 0; + const mounts = service(store, { + mount: async () => { + markConnecting(); + await mountReleased; + }, + finalizeAccess: async () => { + finalizations += 1; + }, + }); + + await mounts.start(); + await connecting; + const removing = mounts.remove(retained.mountId); + await deleting; + releaseMount(); + releaseWrite(); + await removing; + assert.equal(finalizations, 0); + assert.deepEqual(await store.read(), []); + await mounts.close(); +}); + +test('removal settles one admitted startup finalization without waiting through retries', async () => { + const retained = retainedMount('shared-finalizing'); + const store = memoryStore(); + await store.write([retained]); + let markFinalizing!: () => void; + let failFinalization!: (error: unknown) => void; + const finalizing = new Promise((resolve) => { + markFinalizing = resolve; + }); + const finalization = new Promise((_resolve, reject) => { + failFinalization = reject; + }); + const mounts = service(store, { + finalizeAccess: async () => { + markFinalizing(); + await finalization; + }, + }); + + await mounts.start(); + await finalizing; + const removing = mounts.remove(retained.mountId); + failFinalization(new RuntimeHostPairingFinalizationInterruptedError()); + await removing; + + assert.deepEqual(await store.read(), []); + await mounts.close(); +}); + +test('settles admitted finalization before closing and retains the mount', async () => { + const store = memoryStore(); + let started!: () => void; + let finish!: () => void; + const finalizing = new Promise((resolve) => { + started = resolve; + }); + const finalized = new Promise((resolve) => { + finish = resolve; + }); + const mounts = service(store, { + finalizeAccess: async () => { + started(); + await finalized; + }, + }); + + const importing = mounts.importInvitation(invitation('guest-closing'), false); + await finalizing; + let closed = false; + const closing = mounts.close().then(() => { + closed = true; + }); + await Promise.resolve(); + assert.equal(closed, false); + assert.deepEqual(await store.read().then((retained) => retained.length), 1); + finish(); + await closing; + + assert.equal((await importing).kind, 'connected'); + assert.equal((await store.read()).length, 1); +}); + +test('retains and reconciles a mount when finalization outcome is unknown', async () => { + const store = memoryStore(); + let attempts = 0; + let resolveReconciled!: () => void; + const reconciled = new Promise((resolve) => { + resolveReconciled = resolve; + }); + const mounts = service(store, { + finalizeAccess: async () => { + attempts += 1; + if (attempts === 1) throw new RuntimeHostPairingFinalizationInterruptedError(); + resolveReconciled(); + }, + }); + + const result = await mounts.importInvitation(invitation('guest-unknown'), false); + assert.equal(result.kind, 'error'); + assert.equal((await store.read()).length, 1); + await reconciled; + assert.equal(attempts, 2); + assert.equal((await store.read()).length, 1); + await mounts.close(); +}); + +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/' }, + }, + }); +} + +function retainedMount(mountId: string): GuestSessionMount { + return { + mountId, + name: 'Shared Host', + rootId: ROOT_ID, + transport: { kind: 'tls', url: 'wss://runtime.example.com/' }, + credential: 'guest-startup', + }; +} 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..7b63ce2568 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 @@ -25,6 +25,7 @@ import { afterEach, test } from "node:test"; import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, + createRuntimeHostProfileCredentialStore, encodeRuntimeHostOwnerConnectionCode, LOCAL_RUNTIME_HOST_PROFILE, RuntimeHostPermanentReconnectError, @@ -35,7 +36,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 +50,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 +115,45 @@ 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 createRuntimeHostProfileCredentialStore(credentials).set(guest, 'guest-token'); + await writeFile( + join(root, 'runtime-host-profiles.json'), + `${JSON.stringify({ schemaVersion: 3, profiles: [guest] })}\n`, + ); + 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 +337,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 +581,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/__tests__/runtime-host-session-catalog-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts index 99441165b4..e44732fbe7 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts @@ -41,14 +41,30 @@ test('keeps healthy Host catalogs when another Host rejects', async () => { test('reports exactly which Host catalogs are complete', async () => { const catalog = await collectRuntimeHostSessionCatalogsWithCoverage([ - { hostId: 'local', sessions: Promise.resolve([session('local-session', 1)]) }, - { hostId: 'remote', sessions: Promise.reject(new Error('remote unavailable')) }, + { hostId: 'local', access: 'owner', sessions: Promise.resolve([session('local-session', 1)]) }, + { + hostId: 'remote', + access: 'owner', + sessions: Promise.reject(new Error('remote unavailable')), + }, ]); assert.deepEqual(catalog.sessions.map(({ id }) => id), ['local-session']); assert.deepEqual(catalog.completeHostIds, ['local']); }); +test('collapses overlapping Guest catalogs in favor of the Owner authority', async () => { + const owner = session('shared-session', 2); + const guest = { ...owner, shared: true as const }; + + const sessions = await collectRuntimeHostSessionCatalogs([ + Promise.resolve([guest]), + Promise.resolve([owner]), + ]); + + assert.deepEqual(sessions, [owner]); +}); + test('fails when every Host catalog rejects', async () => { await assert.rejects( collectRuntimeHostSessionCatalogs([ diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index a4d554b222..f0c3d82bb3 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -53,6 +53,8 @@ import { LOCAL_RUNTIME_HOST_PROFILE, loadOrCreateRuntimeHostClientInstanceId, listRuntimeHostWslDistributions, + runtimeHostProfileAccess, + type ResolvedRuntimeHostProfile, } from "@maka/runtime-host/client"; import { openRuntimeHostPeerMeshOwner } from '@maka/runtime-host/peer-mesh'; import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; @@ -101,6 +103,7 @@ import { type ReconnectableReadIpcMain, } from "./ipc-reconnect-policy.js"; import { createMainWindowController } from "./main-window.js"; +import type { DesktopRuntimeHostIdentity } from "../preload/bridge-contract.js"; import { captureDesktopDiagnosticEnvironment, copyDesktopDiagnosticReport, @@ -173,6 +176,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 +512,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, @@ -953,6 +982,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( profileId: state.target.profile.id, profileName: state.target.profile.name, profileKind: state.target.profile.kind, + profileAccess: runtimeHostProfileAccess(state.target.profile), ...(hostId ? { hostId } : {}), readiness: state.readiness, isDefault: @@ -967,7 +997,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, @@ -989,6 +1022,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( profileId: state.target.profile.id, profileName: state.target.profile.name, profileKind: state.target.profile.kind, + profileAccess: runtimeHostProfileAccess(state.target.profile), ...(hostId ? { hostId } : {}), readiness: "unavailable", isDefault: @@ -1012,6 +1046,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( profileId, profileName: state?.target.profile.name ?? profileId, profileKind: state?.target.profile.kind ?? "remote", + profileAccess: state ? runtimeHostProfileAccess(state.target.profile) : "owner", ...(state?.readiness === "ready" ? { hostId: state.candidate.client.hostId } : state?.readiness !== "unavailable" && state && "hostId" in state && state.hostId @@ -1042,6 +1077,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 +1518,7 @@ function registerPersistentClientIpc(): void { }); registerMarkdownSaveIpc({ ipcMain, mainWindowController }); registerDesktopRuntimeHostProfileIpc(ipcMain, runtimeHostProfileService); + registerDesktopGuestSessionMountIpc(ipcMain, guestSessionMountService); registerClientSettingsIpc({ ipcMain, settingsStore, @@ -1537,36 +1576,40 @@ function registerPersistentClientIpc(): void { ); }, ); + const projectRuntimeHostIdentity = ( + epoch: string, + target: ResolvedRuntimeHostProfile, + readiness: 'ready' | 'reconnecting', + hostId: string, + ): DesktopRuntimeHostIdentity => ({ + hostId, + targetEpoch: epoch, + profileId: target.profile.id, + profileName: target.profile.name, + profileKind: target.profile.kind, + profileAccess: runtimeHostProfileAccess(target.profile), + readiness, + }); ipcMain.handle("runtime-host:activeIdentity", () => { const current = runtimeHostManager?.current(); if (!current?.hostId) { throw new Error("Desktop Runtime Host identity is unavailable"); } - return { - hostId: current.hostId, - targetEpoch: current.epoch, - profileId: current.target.profile.id, - profileName: current.target.profile.name, - profileKind: current.target.profile.kind, - readiness: current.readiness, - }; + return projectRuntimeHostIdentity( + current.epoch, + current.target, + current.readiness, + current.hostId, + ); }); ipcMain.handle("runtime-host:identities", () => (runtimeHostManager?.entries() ?? []).flatMap((state) => { - const hostId = state.readiness === "ready" - ? state.candidate.client.hostId - : state.readiness === "reconnecting" - ? state.hostId - : undefined; + if (state.readiness !== "ready" && state.readiness !== "reconnecting") return []; + const hostId = state.readiness === "ready" ? state.candidate.client.hostId : state.hostId; if (!hostId) return []; - return [{ - hostId, - targetEpoch: state.epoch, - profileId: state.target.profile.id, - profileName: state.target.profile.name, - profileKind: state.target.profile.kind, - readiness: state.readiness, - }]; + return [ + projectRuntimeHostIdentity(state.epoch, state.target, state.readiness, hostId), + ]; }), ); registerDesktopDiagnosticsIpc({ ipcMain, ...desktopDiagnostics }); @@ -1717,11 +1760,19 @@ async function closeRuntimeHostDesktop(): Promise { updateService.dispose(); settingsBotsIpc?.dispose(); permissionOverlay.dismiss(); + const guestMountShutdown = Promise.resolve().then(() => guestSessionMountService.close()); + const runtimeHostManagerShutdown = guestMountShutdown + .catch(() => undefined) + .then(() => runtimeHostManager?.close()); + const runtimeHostPeerShutdown = runtimeHostManagerShutdown + .catch(() => undefined) + .then(() => runtimeHostPeerOwner?.close() ?? runtimeHostPeerClient?.close()); const results = await Promise.allSettled([ Promise.resolve().then(() => runtimeHostManagement.close()), Promise.resolve().then(() => runtimeHostPeerMeshManagement.close()), - runtimeHostManager?.close(), - runtimeHostPeerOwner?.close() ?? runtimeHostPeerClient?.close(), + guestMountShutdown, + runtimeHostManagerShutdown, + runtimeHostPeerShutdown, runtimeHostOnboarding.close(), localRuntimeHostRemoteAccess.close(), runtimeHostSetupPackage.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..8602849434 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -20,6 +20,7 @@ import { randomUUID } from 'node:crypto'; import type { BotIncomingMessage } from '@maka/runtime/bots'; import { + abortable, RuntimeHostOperationError, RuntimeHostPermanentReconnectError, RuntimeHostRequestInterruptedError, @@ -66,6 +67,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 +345,14 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } finalizePairing(profileId: string): Promise { - return this.#mutateTarget(profileId, () => this.#finalizePairing(profileId)); + return this.#mutateTarget(profileId, () => this.#finalizeAccessCredential(profileId)); } - async #finalizePairing(profileId: string): Promise { + finalizeGuestAccess(mountId: string, signal?: AbortSignal): Promise { + return this.#mutateTarget(mountId, () => this.#finalizeAccessCredential(mountId, signal)); + } + + 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 +367,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 +379,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 abortable( + () => 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 +489,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 +527,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 +548,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 +573,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 +801,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { async #startLifecycle( target: DesktopRuntimeHostTargetGeneration, reportInitialFailure: boolean, + initialSignal?: AbortSignal, ): Promise> { let starting = true; try { @@ -764,7 +809,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) => { 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..c31941d5c3 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts @@ -0,0 +1,475 @@ +/* + * 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 { + SessionCollaborationImportResult, + SessionCollaborationMountSummary, +} from '../shared/session-collaboration.js'; +import { decodeDesktopCollaborationInvitation } from './runtime-host-collaboration-invitation.js'; +import { RuntimeHostPairingFinalizationInterruptedError } from './runtime-host-desktop-manager.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; +} + +interface GuestSessionMountDocument { + readonly schemaVersion: typeof STORE_SCHEMA_VERSION; + readonly mounts: readonly GuestSessionMount[]; +} + +interface LiveGuestActivation { + readonly kind: 'import' | 'startup'; + readonly controller: AbortController; + mountId?: string; + stage: 'connecting' | 'finalizing'; + finalization?: Promise; + task: Promise; +} + +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 activations = new Set(); + const removingMounts = new Set(); + 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 ( + activation: LiveGuestActivation, + mount: GuestSessionMount, + ): Promise => { + activation.stage = 'connecting'; + await input.mount(resolveMountTarget(mount), activation.controller.signal); + activation.controller.signal.throwIfAborted(); + if (removingMounts.has(mount.mountId)) { + throw new Error('Shared Session mount was removed while connecting'); + } + activation.stage = 'finalizing'; + const finalization = input.finalizeAccess(mount.mountId, activation.controller.signal); + activation.finalization = finalization; + try { + await finalization; + activation.controller.signal.throwIfAborted(); + } finally { + if (activation.finalization === finalization) activation.finalization = undefined; + } + }; + + const beginStartupReconciliation = (mount: GuestSessionMount): void => { + if ( + closed || + removingMounts.has(mount.mountId) || + [...activations].some((activation) => activation.mountId === mount.mountId) + ) return; + const activation: LiveGuestActivation = { + kind: 'startup', + controller: new AbortController(), + mountId: mount.mountId, + stage: 'connecting', + task: Promise.resolve(), + }; + activations.add(activation); + activation.task = (async () => { + let delayMs = 1_000; + while (!closed && !activation.controller.signal.aborted) { + if (!(await load()).has(mount.mountId)) return; + try { + await activate(activation, mount); + return; + } catch (error) { + if ( + closed || + activation.controller.signal.aborted || + removingMounts.has(mount.mountId) + ) return; + activation.stage = 'connecting'; + onError(asError(error), mount); + await wait(delayMs, activation.controller.signal); + delayMs = Math.min(delayMs * 2, STARTUP_RETRY_MAX_MS); + } + } + })().finally(() => { + activations.delete(activation); + }); + void activation.task.catch((error) => { + if (!activation.controller.signal.aborted) onError(asError(error), mount); + }); + }; + + const remove = async (mountId: string): Promise => { + removingMounts.add(mountId); + try { + const matching = [...activations].filter((activation) => activation.mountId === mountId); + for (const activation of matching) { + if (activation.stage === 'connecting') { + activation.controller.abort(new Error('Shared Session mount was removed')); + } + } + await Promise.allSettled( + matching.flatMap((activation) => + activation.finalization ? [activation.finalization] : [], + ), + ); + 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; + for (const activation of activations) { + if (activation.mountId === mountId) { + activation.controller.abort(new Error('Shared Session mount was removed')); + } + } + void input.unmount(mountId).catch((error) => onError(asError(error), removed)); + } finally { + removingMounts.delete(mountId); + } + }; + + const runImport = async ( + code: string, + allowInsecure: boolean, + activation: LiveGuestActivation, + ): Promise => { + activation.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, + }); + activation.mountId = mount.mountId; + const retained = await mutate(async () => { + activation.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`, + }; + } + let reconcile = false; + try { + await activate(activation, mount); + activation.controller.signal.throwIfAborted(); + if (!(await load()).has(mount.mountId)) { + throw new Error('Shared Session mount was removed while connecting'); + } + return { kind: 'connected', mountId: mount.mountId }; + } catch (error) { + if ( + activation.stage === 'finalizing' && + error instanceof RuntimeHostPairingFinalizationInterruptedError + ) { + reconcile = true; + } else { + await mutate(async () => { + const next = new Map(await load()); + next.delete(mount.mountId); + await persist(next); + }); + activation.controller.abort(new Error('Shared Session mount activation failed')); + await input.unmount(mount.mountId).catch(() => undefined); + } + return { + kind: 'error', + reason: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed', + message: asError(error).message, + }; + } finally { + activations.delete(activation); + if (reconcile) beginStartupReconciliation(mount); + } + }; + + const importInvitation = ( + code: string, + allowInsecure: boolean, + ): Promise => { + if (closed) return Promise.reject(new Error('Shared Session mount service is closed')); + const activation: LiveGuestActivation = { + kind: 'import', + controller: new AbortController(), + stage: 'connecting', + task: Promise.resolve(), + }; + activations.add(activation); + const task = runImport(code, allowInsecure, activation).finally(() => { + activations.delete(activation); + }); + activation.task = 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 activation of activations) { + if (activation.stage === 'connecting') { + activation.controller.abort(new Error('Shared Session mount service is closed')); + } + } + await Promise.allSettled([...activations].map((activation) => activation.task)); + await mutationTail; + activations.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..20d8dd908c 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -135,6 +135,10 @@ import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { TestProxyInput } from '@maka/core/settings/network-settings'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; +import type { + SessionCollaborationImportResult, + SessionCollaborationMountSummary, +} from '../shared/session-collaboration.js'; /** * Outcome of importing artwork. `cancelled` is the user closing the dialog and * is not an error; the rest name why the file could not become an icon, so the @@ -200,6 +204,7 @@ import type { OnboardingMilestone, OnboardingMilestoneId, OnboardingState } from import type { PersistedRuntimeHostProfile, RuntimeHostProfile, + RuntimeHostProfileAccess, } from '@maka/runtime-host/client'; export interface OnboardingSnapshot { state: OnboardingState; @@ -325,18 +330,9 @@ export interface DesktopRuntimeHostProfileSnapshot { readonly pairingRecoveryPending?: true; } -export type DesktopSessionCollaborationImportResult = - | { readonly kind: 'connected' } - | { readonly kind: 'pairing_pending'; readonly profileId: string } - | { - readonly kind: 'error'; - readonly reason: - | 'invalid_code' - | 'insecure_confirmation_required' - | 'peer_path_unavailable' - | 'connection_failed'; - readonly message?: string; - }; +export type DesktopSessionCollaborationImportResult = SessionCollaborationImportResult; + +export type DesktopGuestSessionMountSummary = SessionCollaborationMountSummary; export type DesktopSessionCollaborationPrepareResult = | { @@ -409,12 +405,21 @@ export interface DesktopRuntimeHostProfileChangedEvent { readonly profileId: string; readonly profileName: string; readonly profileKind: RuntimeHostProfileKind; + readonly profileAccess: RuntimeHostProfileAccess; readonly readiness: 'connecting' | 'ready' | 'reconnecting' | 'unavailable'; readonly hostId?: string; readonly isDefault: boolean; readonly removed?: boolean; } +export interface DesktopRuntimeHostIdentity extends DesktopRuntimeHostRef { + readonly targetEpoch: string; + readonly profileName: string; + readonly profileKind: RuntimeHostProfileKind; + readonly profileAccess: RuntimeHostProfileAccess; + readonly readiness: 'ready' | 'reconnecting'; +} + export type DesktopLocalRuntimeHostRemoteAccessSnapshot = | { readonly state: 'unsupported'; readonly message: string; readonly managedService?: true } | { readonly state: 'off'; readonly managedService?: true; readonly sharedAccess?: true } @@ -701,6 +706,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..bbe0af9c28 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -256,6 +256,7 @@ const runtimeHostMetadata = new Map< readonly profileId: string; readonly profileName: string; readonly profileKind: RuntimeHostProfileKind; + readonly profileAccess: 'owner' | 'session_guest'; } >(); const runtimeHostSessionScopes = new Map(); @@ -308,6 +309,7 @@ ipcRenderer.on( profileId: change.profileId, profileName: change.profileName, profileKind: change.profileKind, + profileAccess: change.profileAccess, }); if (change.isDefault) activeRuntimeHost = nextScope; } else if (change.isDefault) { @@ -334,12 +336,14 @@ function recordRuntimeHostIdentity(value: unknown): { profileId?: unknown; profileName?: unknown; profileKind?: unknown; + profileAccess?: unknown; readiness?: unknown; }; if ( typeof metadata.profileId !== 'string' || typeof metadata.profileName !== 'string' || !isRuntimeHostProfileKind(metadata.profileKind) || + (metadata.profileAccess !== 'owner' && metadata.profileAccess !== 'session_guest') || (metadata.readiness !== 'ready' && metadata.readiness !== 'reconnecting') ) { throw new Error('Desktop Runtime Host identity is invalid'); @@ -351,6 +355,7 @@ function recordRuntimeHostIdentity(value: unknown): { profileId: metadata.profileId, profileName: metadata.profileName, profileKind: metadata.profileKind, + profileAccess: metadata.profileAccess, }); return { scope, readiness: metadata.readiness }; } @@ -718,16 +723,35 @@ async function invokeSessionInput( function projectSessionSummary( scope: DesktopTargetScope, session: SessionSummary, +): DesktopSessionSummary { + const projected = projectSessionCatalogSummary(scope, session); + runtimeHostSessionScopes.set(projected.id, runtimeHostScopeKey(scope)); + return projected; +} + +function projectSessionCatalogSummary( + scope: DesktopTargetScope, + session: SessionSummary, ): DesktopSessionSummary { const metadata = runtimeHostMetadataFor(scope); if (!metadata) throw new Error('Desktop Runtime Host metadata is unavailable'); - recordRuntimeHostSessionScope(scope, session.id); return projectDesktopSessionSummary( { ...scope, ...metadata }, session, ); } +function recordSessionCatalogScopes(sessions: readonly DesktopSessionSummary[]): void { + for (const session of sessions) { + const scopeKey = runtimeHostProfiles.get(session.profileId); + const scope = scopeKey ? runtimeHostScopes.get(scopeKey) : undefined; + if (!scopeKey || !scope || scope.hostId !== session.runtimeHostId) { + throw new Error('Desktop Runtime Host Session scope is unavailable'); + } + runtimeHostSessionScopes.set(session.id, scopeKey); + } +} + function projectOnboardingSnapshot( scope: DesktopTargetScope, snapshot: OnboardingSnapshot, @@ -871,16 +895,18 @@ async function listDesktopSessions( return sessions.map((session) => projectSessionSummary(parent.scope, session)); } const scopes = await runtimeHostScopeList(); - return collectRuntimeHostSessionCatalogs( + const sessions = await collectRuntimeHostSessionCatalogs( scopes.map(async (scope) => { const sessions = await ipcRenderer.invoke( 'sessions:list', scope, filter, ) as SessionCatalogSummary[]; - return sessions.map((session) => projectSessionSummary(scope, session)); + return sessions.map((session) => projectSessionCatalogSummary(scope, session)); }), ); + recordSessionCatalogScopes(sessions); + return sessions; } async function listDesktopSessionsWithCoverage(): Promise<{ @@ -888,14 +914,21 @@ async function listDesktopSessionsWithCoverage(): Promise<{ completeHostIds: string[]; }> { const scopes = await runtimeHostScopeList(); - return collectRuntimeHostSessionCatalogsWithCoverage( - scopes.map((scope) => ({ - hostId: scope.hostId, - sessions: ipcRenderer.invoke('sessions:list', scope) - .then((sessions: SessionCatalogSummary[]) => - sessions.map((session) => projectSessionSummary(scope, session))), - })), + const catalog = await collectRuntimeHostSessionCatalogsWithCoverage( + scopes.map((scope) => { + const metadata = runtimeHostMetadataFor(scope); + if (!metadata) throw new Error('Desktop Runtime Host metadata is unavailable'); + return { + hostId: scope.hostId, + access: metadata.profileAccess, + sessions: ipcRenderer.invoke('sessions:list', scope) + .then((sessions: SessionCatalogSummary[]) => + sessions.map((session) => projectSessionCatalogSummary(scope, session))), + }; + }), ); + recordSessionCatalogScopes(catalog.sessions); + return catalog; } async function createDesktopSessionOnScope( @@ -1252,6 +1285,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/preload/runtime-host-session-catalog.ts b/apps/desktop/src/preload/runtime-host-session-catalog.ts index 91b721d6b6..d4755a8949 100644 --- a/apps/desktop/src/preload/runtime-host-session-catalog.ts +++ b/apps/desktop/src/preload/runtime-host-session-catalog.ts @@ -21,6 +21,7 @@ import type { DesktopSessionSummary } from './bridge-contract.js'; export interface RuntimeHostSessionCatalogRequest { readonly hostId: string; + readonly access: 'owner' | 'session_guest'; readonly sessions: Promise; } @@ -34,17 +35,27 @@ export async function collectRuntimeHostSessionCatalogsWithCoverage( ): Promise { const results = await Promise.allSettled(requests.map((request) => request.sessions)); const fulfilled = results.flatMap((result, index) => result.status === 'fulfilled' - ? [{ hostId: requests[index]!.hostId, sessions: result.value }] + ? [{ ...requests[index]!, sessions: result.value }] : []); + const fulfilledRequests = new Set( + results.flatMap((result, index) => result.status === 'fulfilled' ? [requests[index]!] : []), + ); if (requests.length > 0 && fulfilled.length === 0) { throw new AggregateError( results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []), 'Every Runtime Host Session Catalog request failed', ); } + const hostIds = [...new Set(requests.map((request) => request.hostId))]; return { sessions: sortSessionCatalogs(fulfilled.flatMap((entry) => entry.sessions)), - completeHostIds: fulfilled.map((entry) => entry.hostId), + completeHostIds: hostIds.filter((hostId) => { + const hostRequests = requests.filter((request) => request.hostId === hostId); + const ownerRequests = hostRequests.filter((request) => request.access === 'owner'); + return ownerRequests.length > 0 + ? ownerRequests.some((request) => fulfilledRequests.has(request)) + : hostRequests.every((request) => fulfilledRequests.has(request)); + }), }; } @@ -63,7 +74,14 @@ export async function collectRuntimeHostSessionCatalogs( } function sortSessionCatalogs(sessions: DesktopSessionSummary[]): DesktopSessionSummary[] { - return sessions.sort((left, right) => { + const unique = new Map(); + for (const session of sessions) { + const current = unique.get(session.id); + if (!current || (current.shared === true && session.shared !== true)) { + unique.set(session.id, session); + } + } + return [...unique.values()].sort((left, right) => { if (left.activityAt === undefined || right.activityAt === undefined) { throw new Error('Runtime Host Session Catalog activity is unavailable'); } diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index da3017850d..4a9b383fdf 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -45,13 +45,13 @@ import type { DesktopRuntimeHostProfileChangedEvent, WindowCommand, } from '../preload/bridge-contract.js'; -import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; import { mergeShellRunNotification, mergeShellRunUpdates, ShellRunHydration, type ShellRunUpdatesBySession, } from './shell-run-update-state.js'; +import { runtimeHostChangeRetiresSession } from '../shared/runtime-host-identity.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, @@ -182,15 +182,13 @@ export function useAppShellBootstrapSubscriptions(options: { options.handleConnectionEvent(event); }); const handleRuntimeHostChange = useEffectEvent((event: DesktopRuntimeHostProfileChangedEvent) => { - if ((event.removed || event.readiness === 'unavailable') && event.hostId) { + void options.refreshSessions().then((sessions) => { const activeSessionId = options.activeIdRef.current; - if (activeSessionId && desktopSessionHostId(activeSessionId) === event.hostId) { - options.setActiveId(undefined); - options.setMessages([]); - options.clearSessionRendererState(activeSessionId); - } - } - void options.refreshSessions(); + if (!runtimeHostChangeRetiresSession(event, activeSessionId, sessions)) return; + options.setActiveId(undefined); + options.setMessages([]); + options.clearSessionRendererState(activeSessionId); + }); if (event.readiness !== 'ready') return; if (!event.isDefault) return; void options.refreshProjects(); @@ -327,17 +325,10 @@ export function useAppShellBootstrapSubscriptions(options: { }, []); } -function desktopSessionHostId(sessionId: string): string | undefined { - try { - return parseDesktopSessionKey(sessionId).hostId; - } catch { - return undefined; - } -} - export function useActiveSessionEvents(options: { uiLocale: UiLocale; activeId: string | undefined; + activeProfileId: string | undefined; activeIdRef: RefBox; handleEvent: (sessionId: string, event: SessionEvent) => void; beginObservationSeed?: (sessionId: string) => number; @@ -357,8 +348,7 @@ export function useActiveSessionEvents(options: { ) => { if (!isDisposed() && options.activeIdRef.current === sessionId) { const snapshot = store.snapshot(); - const next = [...snapshot.messages]; - options.setMessages(next); + options.setMessages([...snapshot.messages]); if (snapshot.ready) options.setMessageLoadPending(false); } }); @@ -422,7 +412,6 @@ export function useActiveSessionEvents(options: { let observationRetryTimer: ReturnType | undefined; let unsubscribeSessionEvents = () => {}; const transcript = new DesktopTranscriptRangeStore(activeId); - const subscribedAt = Date.now(); options.setMessageLoadErrorBySession((current) => { if (!current[activeId]) return current; const next = { ...current }; @@ -433,7 +422,7 @@ export function useActiveSessionEvents(options: { ...current, [activeId]: createSessionEventStreamSubscription({ sessionId: activeId, - now: subscribedAt, + now: Date.now(), }), })); const openTranscript = (signal: AbortSignal) => @@ -511,7 +500,7 @@ export function useActiveSessionEvents(options: { unsubscribeSessionEvents(); markSessionEventStreamClosed(activeId); }; - }, [activeId]); + }, [activeId, options.activeProfileId]); } export function useShellRunUpdates(options: { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b899ce429f..72c00b81be 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1129,7 +1129,7 @@ function AppShellContent({ // 1. `data-maka-reduced-motion="true"` — PR-IR-04 reduced variant // 2. `data-maka-e2e-fixture="true"` — PR-IR-02 any capture // 3. `prefers-reduced-motion: reduce` — OS-level user preference - function handleLineageBadgeClick(targetTurnId: string): void { + function handleLineageBadgeClick(targetTurnId: string) { requestAnimationFrame(() => { const el = document.querySelector(`[data-turn-id="${CSS.escape(targetTurnId)}"]`); if (!el || !('scrollIntoView' in el)) return; @@ -2350,14 +2350,14 @@ function AppShellContent({ const [activeEventSeed, setActiveEventSeed] = useState(EMPTY_LIVE_CONTENT_SEED); const activeEventSeedRef = useRef(activeEventSeed); activeEventSeedRef.current = activeEventSeed; - const beginObservationSeed = (sessionId: string): number => { + const beginObservationSeed = (sessionId: string) => { const next = beginLiveContentSeed(activeEventSeedRef.current, sessionId); activeEventSeedRef.current = next; markDisplayPending(sessionId); setActiveEventSeed(next); return next.generation; }; - const completeObservationSeed = (sessionId: string, generation?: number): void => { + const completeObservationSeed = (sessionId: string, generation?: number) => { const current = activeEventSeedRef.current; const expected = generation ?? current.generation; if (current.sessionId !== sessionId || current.generation !== expected) return; @@ -2377,6 +2377,7 @@ function AppShellContent({ useActiveSessionEvents({ uiLocale, activeId, + activeProfileId: activeSession?.profileId, activeIdRef, handleEvent, beginObservationSeed, 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..d5c0973e3e --- /dev/null +++ b/apps/desktop/src/renderer/features/session-collaboration/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { 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..4e13de0d11 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-collaboration/ports.ts @@ -0,0 +1,37 @@ +/* + * 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 type { + SessionCollaborationImportResult, + SessionCollaborationMountSummary, +} from '../../../shared/session-collaboration.js'; + +export type { + SessionCollaborationImportResult, + SessionCollaborationMountSummary, +} from '../../../shared/session-collaboration.js'; + +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} +