diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 5c663cf37a..2f8f24e25e 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -178,7 +178,6 @@ "src/renderer/settings/runtime-host-interaction-boundary.tsx", "src/renderer/settings/runtime-host-management-dialog.tsx", "src/renderer/settings/runtime-host-onboarding-dialog.tsx", - "src/renderer/settings/runtime-host-peer-mesh-dialog.tsx", "src/renderer/settings/runtime-host-profiles-section.tsx", "src/renderer/settings/runtime-host-project-directory-editor.tsx", "src/renderer/settings/runtime-host-settings-bridge.ts", @@ -3911,34 +3910,6 @@ "react": 1 } }, - "src/renderer/settings/runtime-host-peer-mesh-dialog.tsx": { - "bridgePaths": { - "window.maka.runtimeHostPeerMesh.execute": 6 - }, - "environmentCapabilities": { - "navigator.clipboard.writeText": 1 - }, - "hookCalls": { - "useEffect": 1, - "useState": 5, - "useToast": 1, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../../preload/bridge-contract.js": 1, - "@astryxdesign/core": 1, - "@astryxdesign/core/Dialog": 1, - "@astryxdesign/core/Layout": 1, - "@astryxdesign/core/Stack": 1, - "@maka/runtime-host/protocol": 2, - "@maka/ui": 1, - "@maka/ui/icons": 1, - "react": 1 - } - }, "src/renderer/settings/runtime-host-profiles-section.tsx": { "bridgePaths": { "window.maka.localRuntimeHostRemoteAccess.createConnectionCode": 1, @@ -3949,7 +3920,6 @@ "window.maka.runtimeHostProfiles.addAndEnable": 1, "window.maka.runtimeHostProfiles.getSnapshot": 1, "window.maka.runtimeHostProfiles.remove": 1, - "window.maka.runtimeHostProfiles.resolvePairingRecovery": 1, "window.maka.runtimeHostProfiles.setDefault": 1, "window.maka.runtimeHostProfiles.setEnabled": 1, "window.maka.runtimeHostProfiles.subscribeChanges": 1 @@ -3969,6 +3939,7 @@ "actionFactories": [], "dependencyPaths": { "../../preload/bridge-contract.js": 1, + "../features/runtime-host-management": 1, "../locales/session-collaboration-copy.js": 1, "../locales/settings-projects-copy.js": 1, "../session-collaboration-dialog.js": 1, @@ -3976,7 +3947,6 @@ "./runtime-host-connection-code-dialog.js": 1, "./runtime-host-management-dialog.js": 1, "./runtime-host-onboarding-dialog.js": 1, - "./runtime-host-peer-mesh-dialog.js": 1, "./settings-error-copy.js": 1, "./settings-section.js": 1, "@astryxdesign/core": 1, @@ -5570,7 +5540,7 @@ "nonTriviaTokens": 206 }, "src/renderer/main.tsx": { - "importDeclarations": 17, + "importDeclarations": 8, "bridgePaths": { "window.maka.onboarding.getSnapshot": 2 }, @@ -5586,23 +5556,14 @@ "../preload/bridge-contract.js": 1, "./app": 1, "./cached-theme-bootstrap": 1, - "./features/goals": 1, - "./features/module-hub": 1, - "./features/session-navigation": 1, - "./features/task-entry": 1, - "./features/workbar": 1, - "./platform/desktop/create-goal-services": 1, - "./platform/desktop/create-module-hub-services": 1, - "./platform/desktop/create-session-navigation-services": 1, - "./platform/desktop/create-task-entry-services": 1, - "./platform/desktop/create-workbar-services": 1, + "./composition/desktop-feature-services": 1, "./styles.css": 1, "./use-system-ui-locale": 1, "@maka/ui": 1, "react-dom/client": 1 }, - "importSpecifiers": 16, - "nonTriviaTokens": 412 + "importSpecifiers": 8, + "nonTriviaTokens": 268 } }, "rootDebtClosure": { 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 e6a39be1aa..877b84d429 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 @@ -562,6 +562,29 @@ test('keeps Local and remote Hosts active and routes work by owning Host', async await manager.close(); }); +test('keeps independent shared-session credentials active for the same Host', async () => { + const candidates = [ + candidateHarness({ hostId: 'host-local' }).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')); + + assert.deepEqual(manager.entries().map(({ target }) => target.profile.id), [ + 'local', + 'shared-one', + 'shared-two', + ]); + assert.notEqual(manager.current('shared-one')?.epoch, manager.current('shared-two')?.epoch); + 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); @@ -1442,6 +1465,7 @@ function ready(candidate: DesktopRuntimeHostCandidate): DesktopRuntimeHostCandid function remoteTarget( id: string, target = 'default', + access?: 'session_guest', ): NonNullable { return { profile: { @@ -1450,6 +1474,7 @@ function remoteTarget( kind: 'remote', transport: { kind: 'tls', url: `wss://${target}.example.com/` }, rootId: 'a'.repeat(64), + ...(access ? { access } : {}), }, credential: `credential-${target}`, }; diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts index 8b9e7082d5..26f95d8b5d 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -24,6 +24,7 @@ import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import test from 'node:test'; import { + encodeRuntimeHostPeerMeshManagementFrame, encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, @@ -206,3 +207,91 @@ test('local update runs the selected package against the exact managed deploymen ); assert.deepEqual(phases, ['staging']); }); + +test('local Peer Mesh join keeps invitations off argv and accepts bounded large results', async (t) => { + let args: readonly string[] | undefined; + let input = ''; + const largeSnapshot = { + available: true, + localPeerId: 'local-peer', + meshes: Array.from({ length: 16 }, (_, meshIndex) => ({ + meshId: `mesh-${meshIndex}`, + role: 'authority' as const, + authorityPeerId: 'local-peer', + revision: 1, + closed: false, + members: Array.from({ length: 64 }, (_, memberIndex) => ({ + peerId: `peer-${meshIndex}-${memberIndex}-${'x'.repeat(48)}`, + endpointKind: 'client' as const, + displayName: `Member ${meshIndex}-${memberIndex} ${'x'.repeat(60)}`, + state: 'route_available' as const, + expiresAt: 4_000_000_000_000, + })), + pendingInvitationCount: 0, + })), + transit: { + meshId: null, + allowedMemberCount: 0, + activeReservationCount: 0, + activeCircuitCount: 0, + maxReservationCount: 32, + maxCircuitCount: 8, + maxCircuitsPerPeer: 2, + maxCircuitDurationSeconds: 7_200, + maxCircuitBytes: 256 * 1024 * 1024, + }, + }; + const spawnProcess = ((_command, commandArgs) => { + args = Array.isArray(commandArgs) ? commandArgs : undefined; + const child = new EventEmitter() as ReturnType; + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + stdin.on('data', (chunk: Buffer) => { input += chunk.toString('utf8'); }); + Object.assign(child, { pid: 1234, stdin, stdout, stderr, kill: () => true }); + process.nextTick(() => { + const resultFrame = encodeRuntimeHostPeerMeshManagementFrame({ + kind: 'result', + action: 'join', + result: largeSnapshot, + }); + assert.ok(resultFrame.length > 30_000); + stdout.write( + encodeRuntimeHostPeerMeshManagementFrame({ kind: 'input', action: 'join' }) + + resultFrame.slice(0, 30_000), + ); + stdout.end(resultFrame.slice(30_000)); + stderr.end(); + child.emit('close', 0, null); + }); + return child; + }) as typeof spawn; + const operator = createDesktopRuntimeHostLocalOperator({ spawnProcess }); + t.after(() => operator.close()); + const invitation = JSON.stringify({ secret: 'one-time-mesh-secret' }); + + const result = await operator.runPeerMesh({ + operatorPath: '/tmp/maka/operator', + action: 'join', + target: { + serviceId: 'b'.repeat(64), + rootPath: '/tmp/maka/root', + rootId: 'a'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', + }, + invitation, + }); + + assert.deepEqual(args, [ + 'mesh', 'join', '--framed', + '--expected-service-id', 'b'.repeat(64), + '--expected-root-path', '/tmp/maka/root', + '--expected-root-id', 'a'.repeat(64), + '--expected-deployment-id', '00000000-0000-4000-8000-000000000001', + ]); + assert.equal(input, `${invitation}\n`); + assert.equal( + result.kind === 'result' && result.action === 'join' ? result.result.meshes.length : 0, + 16, + ); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 4c2722b571..053647b292 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -1243,6 +1243,7 @@ function unusedUpdateDependencies() { function unusedDirectPeerProfileDependencies() { return { + assertPairingComplete: () => undefined, resolveManagedDirectPeerProfile: async (): Promise => assert.fail('direct peer profile inspection is not expected'), upsertManagedDirectPeerProfile: async (): Promise => 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 83040e9f66..e4a8759ed1 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 @@ -18,7 +18,7 @@ */ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, test } from "node:test"; @@ -551,6 +551,94 @@ test('imports shared access without requiring or persisting an Owner credential' 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); @@ -689,6 +777,11 @@ test("keeps a managed Direct route on the SSH profile credential authority", asy finalizePairing: async () => undefined, }); + await assert.rejects( + service.resolveCollaborationConnectionTarget(MANAGED_PROFILE), + /Enable Direct peer access/u, + ); + await service.upsertManagedDirectPeerProfile(MANAGED_PROFILE.id, { peerId: "12D3KooWpeer", routeHints: ["/ip4/192.0.2.8/udp/44001/quic-v1"], @@ -709,6 +802,10 @@ test("keeps a managed Direct route on the SSH profile credential authority", asy routeHints: ["/ip4/192.0.2.8/udp/44001/quic-v1"], coordinationRelays: [], }); + assert.deepEqual( + await service.resolveCollaborationConnectionTarget(MANAGED_PROFILE), + { name: MANAGED_PROFILE.name, transport: direct.profile.transport }, + ); assert.equal((await catalog.resolve(MANAGED_PROFILE.id)).credential, "owner-token"); const beforeRejectedRemoval = { @@ -959,7 +1056,53 @@ test("reactivates the previous credential after a pre-rebind rotation crash", as ); }); -test("does not let unfinished pairing recovery override a later disable", async () => { +test('recovers a new profile after a crash before its enable preference is written', async () => { + const root = await clientRoot(); + const credentialStore = createClientRuntimeHostCredentialStore(root); + const catalog = createClientRuntimeHostProfileCatalog(root, credentialStore); + await catalog.create(MANAGED_PROFILE, 'new-token'); + await writeDesktopRuntimeHostPairingIntents(credentialStore, [ + createDesktopRuntimeHostPairingIntent({ + target: { profile: MANAGED_PROFILE, credential: 'new-token' }, + wasEnabled: false, + }), + ]); + const startup = await resolveDesktopRuntimeHostStartup(root, { catalog, credentialStore }); + assert.deepEqual(startup.preferences.enabledRemoteProfileIds, []); + const enabled: ResolvedRuntimeHostProfile[] = []; + const finalized: string[] = []; + const service = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup, + catalog, + credentialStore, + states: () => [connectingLocal()], + enable: async (target) => { + enabled.push(target); + }, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async (profileId) => { + finalized.push(profileId); + }, + }); + + await service.startEnabledProfiles(); + + assert.deepEqual(enabled.map((target) => target.credential), ['new-token']); + assert.deepEqual(finalized, [MANAGED_PROFILE.id]); + assert.equal( + (await service.getSnapshot()).entries.find(({ profile }) => profile.id === MANAGED_PROFILE.id) + ?.enabled, + true, + ); + assert.deepEqual( + (await resolveDesktopRuntimeHostStartup(root, { catalog, credentialStore })).pairingIntents, + [], + ); +}); + +test("does not let journal cleanup failure lock a completed pairing", async () => { const root = await clientRoot(); const credentials = createClientRuntimeHostCredentialStore(root); const credentialStore = { @@ -1000,10 +1143,90 @@ test("does not let unfinished pairing recovery override a later disable", async await service.rotateManagedCredential(access, "new-token"); - assert.equal((await service.getSnapshot()).pairingRecoveryPending, true); + assert.equal((await service.getSnapshot()).pairingRecoveryPending, undefined); + await service.setEnabled(MANAGED_PROFILE.id, false); + assert.equal( + (await service.getSnapshot()).entries.find(({ profile }) => profile.id === MANAGED_PROFILE.id) + ?.enabled, + false, + ); + + const restarted = await resolveDesktopRuntimeHostStartup(root, { catalog, credentialStore }); + assert.equal(restarted.pairingIntents.length, 0); + const reenabled: ResolvedRuntimeHostProfile[] = []; + const restartedService = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup: restarted, + catalog, + credentialStore, + states: () => [connectingLocal()], + enable: async (target) => { + reenabled.push(target); + }, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + await restartedService.startEnabledProfiles(); + assert.deepEqual(reenabled, []); + assert.equal( + (await restartedService.getSnapshot()).entries.find( + ({ profile }) => profile.id === MANAGED_PROFILE.id, + )?.enabled, + false, + ); +}); + +test('discarding a committed rotation unlocks the restored local profile', async () => { + const root = await clientRoot(); + const catalog = createClientRuntimeHostProfileCatalog(root); + await catalog.create(MANAGED_PROFILE, 'old-token'); + await createDesktopRuntimeHostManagedServiceStore(root).save( + MANAGED_PROFILE, + MANAGED_SERVICE, + ); + await writeFile( + join(root, 'runtime-host-profile-selection.json'), + `${JSON.stringify({ + schemaVersion: 2, + defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id, + enabledRemoteProfileIds: [MANAGED_PROFILE.id], + })}\n`, + ); + const startup = await resolveDesktopRuntimeHostStartup(root, { catalog }); + let restoringOldCredential = false; + const service = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup, + catalog, + states: () => [connectingLocal()], + enable: async (target) => { + if (restoringOldCredential && target.credential === 'old-token') { + throw new Error('old credential was revoked remotely'); + } + }, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => { + throw new RuntimeHostPairingFinalizationInterruptedError(); + }, + }); + const access = await service.resolveManagedAccess(MANAGED_PROFILE.id); + assert.ok(access); await assert.rejects( - () => service.setEnabled(MANAGED_PROFILE.id, false), - /unfinished pairing/u, + service.rotateManagedCredential(access, 'new-token'), + RuntimeHostPairingFinalizationInterruptedError, + ); + + restoringOldCredential = true; + const abandonedLock = join(root, 'runtime-host-profiles.json.lock'); + await mkdir(abandonedLock); + const discarded = await service.discardPairing(MANAGED_PROFILE.id); + assert.equal(discarded.pairingRecoveryPending, undefined); + assert.equal((await catalog.resolve(MANAGED_PROFILE.id)).credential, 'old-token'); + assert.equal( + discarded.entries.find(({ profile }) => profile.id === MANAGED_PROFILE.id)?.readiness, + 'unavailable', ); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 5caf67bd9b..418bde49c0 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -170,9 +170,11 @@ test('keeps setup credentials out of the interactive terminal projection', async assert.deepEqual(progress, ['installing_service']); assert.doesNotMatch(JSON.stringify(harness.events), /secret-access-token|MAKA_RUNTIME/u); assert.match(JSON.stringify(harness.events), /Password/u); - assert.match(harness.launchArgs.at(-1)?.at(-1) ?? '', /mktemp -d/u); - assert.match(harness.launchArgs.at(-1)?.at(-1) ?? '', /--prefix/u); - assert.match(harness.launchArgs.at(-1)?.at(-1) ?? '', /trap.*HUP.*trap.*INT.*trap.*TERM/u); + const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; + assert.match(remoteCommand, /mktemp -d/u); + assert.match(remoteCommand, /--prefix/u); + assert.match(remoteCommand, /trap.*HUP.*trap.*INT.*trap.*TERM/u); + assert.doesNotMatch(remoteCommand, /--update-existing/u); await harness.terminal.close(); }); @@ -914,6 +916,7 @@ test('uploads a development release archive before running the same remote setup assert.match(remoteCommand, /MAKA_RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY=/u); assert.ok(remoteCommand.includes(integrity)); assert.match(remoteCommand, /--defer-pairing-commit/u); + assert.match(remoteCommand, /--update-existing/u); assert.match(remoteCommand, /cd.*\$HOME/u); assert.match(remoteCommand, /rm -f/u); assert.match(remoteCommand, /exec \/bin\/sh -c/u); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 382b68b170..a4d554b222 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -245,6 +245,7 @@ if (runtimeHostPeerConfiguration) { runtimeHostPeerOwner = await openRuntimeHostPeerMeshOwner({ ...runtimeHostPeerConfiguration, dataRoot: join(userDataDir, 'peer-mesh'), + endpointKind: 'client', }); runtimeHostPeerClient = runtimeHostPeerOwner.client; runtimeHostPeerMesh = runtimeHostPeerOwner.mesh; @@ -594,6 +595,8 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ const runtimeHostPeerMeshManagement = createDesktopRuntimeHostPeerMeshManagement({ ipcMain, localMesh: () => runtimeHostPeerMesh, + localHost: localRuntimeHostRemoteAccess, + runLocal: localRuntimeHostOperator.runPeerMesh, profiles: runtimeHostProfileService, runRemote: runtimeHostSshTerminal.runPeerMeshManagement, }); @@ -933,6 +936,8 @@ runtimeHostManager = await startRuntimeHostDesktopManager( activateSshOperator: runtimeHostSshTerminal.activateSshOperator, resolveLocalCollaborationConnectionTarget: () => localRuntimeHostRemoteAccess.createCollaborationConnectionTarget(), + resolveProfileCollaborationConnectionTarget: (profile) => + runtimeHostProfileService.resolveCollaborationConnectionTarget(profile), }, { upgradePrompts: createRuntimeHostUpgradePrompts( diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index e7af0a281f..cce904ca8b 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -151,6 +151,9 @@ export interface DesktopRuntimeHostCandidateDeps { ) => Promise; readonly resolveLocalCollaborationConnectionTarget?: () => Promise; + readonly resolveProfileCollaborationConnectionTarget?: ( + profile: PersistedRuntimeHostProfile, + ) => Promise; readonly createSessionCopyCleanup: (input: { removeSession: (sessionId: string) => Promise; resumeSessionCopy: (input: { @@ -454,11 +457,8 @@ async function startProfileDesktopRuntimeHostCandidate( runtimeHostProfileAccess(profileTarget.profile), undefined, undefined, - profileTarget.profile.kind === 'remote' - ? { - name: profileTarget.profile.name, - transport: profileTarget.profile.transport, - } + profileTarget.profile.kind === 'remote' && input.resolveProfileCollaborationConnectionTarget + ? () => input.resolveProfileCollaborationConnectionTarget!(profileTarget.profile) : undefined, ), }; @@ -477,7 +477,9 @@ export async function createDesktopRuntimeHostCandidate( targetAccess: RuntimeHostProfileAccess = 'owner', hostPid?: number, ownedProcess?: RuntimeHostSpawnedProcess, - collaborationConnectionTarget?: DesktopCollaborationConnectionTarget, + resolveCollaborationConnectionTarget?: () => + | DesktopCollaborationConnectionTarget + | Promise, ): Promise { const target: DesktopRuntimeHostTargetPolicy = { kind: targetKind, @@ -812,7 +814,7 @@ export async function createDesktopRuntimeHostCandidate( ); } registerRuntimeHostCollaborationIpc(client, ipc, async () => { - if (collaborationConnectionTarget) return collaborationConnectionTarget; + if (resolveCollaborationConnectionTarget) return resolveCollaborationConnectionTarget(); if (target.kind === 'local' && deps.resolveLocalCollaborationConnectionTarget) { return deps.resolveLocalCollaborationConnectionTarget(); } diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 82d4cc97ca..0db7ba5c0e 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -489,7 +489,13 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const rootId = target.target.profile.kind !== 'local' ? target.target.profile.rootId : target.hostId; - if (rootId === profileTarget.profile.rootId) { + if ( + rootId === profileTarget.profile.rootId && + !( + isSessionGuestProfile(target.target.profile) && + isSessionGuestProfile(profileTarget.profile) + ) + ) { throw new Error(`Runtime Host ${profileTarget.profile.rootId} is already enabled`); } } @@ -1150,6 +1156,12 @@ function withRuntimeHostTarget( return profileTarget ? { ...base, profileTarget } : base; } +function isSessionGuestProfile( + profile: ResolvedRuntimeHostProfile['profile'], +): boolean { + return profile.kind === 'remote' && profile.access === 'session_guest'; +} + function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index 6657e788fc..34f2ea2c9d 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -29,6 +29,7 @@ import { import { decodeRuntimeHostAccessManagementFrame, decodeRuntimeHostPeerManagementFrame, + decodeRuntimeHostPeerMeshManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, @@ -36,12 +37,16 @@ import { RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_MAX_BYTES, + RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, type RuntimeHostAccessManagementFrame, type RuntimeHostManagedUpdatePolicy, type RuntimeHostPeerManagementFrame, + type RuntimeHostPeerMeshManagementAction, + type RuntimeHostPeerMeshManagementFrame, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, type RuntimeHostSetupFrame, @@ -170,6 +175,16 @@ export function createDesktopRuntimeHostLocalOperator(input: { readonly allowInterruptActiveTasks?: boolean; readonly signal?: AbortSignal; }): Promise; + runPeerMesh(input: { + readonly operatorPath: string; + readonly action: RuntimeHostPeerMeshManagementAction; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly meshId?: string | null; + readonly peerId?: string; + readonly displayName?: string | null; + readonly invitation?: string; + readonly signal?: AbortSignal; + }): Promise>; runAccess(input: { readonly operatorPath: string; readonly target: DesktopRuntimeHostLocalServiceTarget; @@ -284,6 +299,39 @@ export function createDesktopRuntimeHostLocalOperator(input: { active, }).then((frame) => requirePeerFrame(frame, command.action, command.target)); }, + runPeerMesh(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runPeerMeshFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + 'mesh', + command.action, + '--framed', + ...(typeof command.meshId === 'string' + ? ['--mesh', command.meshId] + : command.meshId === null + ? ['--off'] + : []), + ...(command.peerId ? ['--peer', command.peerId] : []), + ...(command.displayName === null + ? ['--clear-name'] + : command.displayName + ? ['--name', command.displayName] + : []), + ...managedTargetArgs(command.target), + ], + }, + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + ...(command.invitation ? { inputLine: command.invitation } : {}), + action: command.action, + }); + }, runAccess(command) { if (closed) throw new Error('Local Runtime Host operator is closed'); return runSingleFrameProcess({ @@ -572,6 +620,42 @@ function runServiceFrameProcess(input: { }); } +function runPeerMeshFrameProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; + readonly action: RuntimeHostPeerMeshManagementAction; + readonly inputLine?: string; +}): Promise> { + let result: Exclude | undefined; + let failure: Error | undefined; + return runFramedProcess({ + ...input, + prefix: RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostPeerMeshManagementFrame, + label: 'Local Runtime Host Peer Mesh management', + onFrame(frame) { + if (frame.action !== input.action) { + failure = new Error('Local Runtime Host Peer Mesh management returned an unrelated result'); + } else if (frame.kind !== 'input') { + if (result) { + failure = new Error('Local Runtime Host Peer Mesh management returned multiple results'); + } else { + result = frame; + } + } + }, + result: () => result, + failure: () => failure, + acceptNonzeroResult: true, + pendingMaxBytes: RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_MAX_BYTES, + }); +} + function combinedSignal( operation: AbortSignal | undefined, closing: AbortSignal, @@ -613,6 +697,7 @@ function runSingleFrameProcess(input: { readonly terminate: typeof terminateChildProcessTree; readonly signal?: AbortSignal; readonly active: Set; + readonly inputLine?: string; }): Promise { let result: Frame | undefined; let failure: Error | undefined; @@ -693,6 +778,8 @@ function runFramedProcess(input: { readonly result: () => Result | undefined; readonly failure: () => Error | undefined; readonly acceptNonzeroResult?: boolean; + readonly inputLine?: string; + readonly pendingMaxBytes?: number; }): Promise { input.signal?.throwIfAborted(); return new Promise((resolve, reject) => { @@ -700,17 +787,18 @@ function runFramedProcess(input: { ...(input.cwd ? { cwd: input.cwd } : {}), detached: process.platform !== 'win32', env: input.environment, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [input.inputLine === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], windowsHide: true, }); input.active.add(child); + if (input.inputLine !== undefined) child.stdin?.end(`${input.inputLine}\n`); let filterFailure: Error | undefined; let stopFailure: Error | undefined; let stderr = ''; let settled = false; const filter = createRuntimeHostFramedOutputFilter({ prefix: input.prefix, - pendingMaxBytes: SETUP_FRAME_PENDING_MAX, + pendingMaxBytes: input.pendingMaxBytes ?? SETUP_FRAME_PENDING_MAX, decode: input.decode, label: input.label, onFrame: (frame) => { diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index c1564d67e2..9381767178 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -81,6 +81,7 @@ export function createDesktopRuntimeHostManagement(input: { readonly profiles: Pick< DesktopRuntimeHostProfileService, | 'resolveManagedService' + | 'assertPairingComplete' | 'resolveManagedAccess' | 'rotateManagedCredential' | 'markManagedServiceUninstalling' @@ -284,6 +285,9 @@ export function createDesktopRuntimeHostManagement(input: { } const provider = providers.get(profileId); const execute = async (): Promise => { + if (managementAction !== 'status') { + input.profiles.assertPairingComplete(profileId); + } if (!provider) { return runManagedAction(profileId, managementAction, allowInterruptActiveTasksValue); } @@ -341,6 +345,7 @@ export function createDesktopRuntimeHostManagement(input: { const managedMutationTarget = async (profileIdValue: unknown) => { const profileId = requireProfileId(profileIdValue); + input.profiles.assertPairingComplete(profileId); const managed = await resolveManagedService(profileId); const transport = managed.profile.transport; if (managed.state !== 'active' || transport.kind !== 'ssh') { @@ -553,6 +558,7 @@ export function createDesktopRuntimeHostManagement(input: { throw new Error('Runtime Host update interruption authority is invalid'); } const profileId = requireProfileId(profileIdValue); + input.profiles.assertPairingComplete(profileId); const provider = providers.get(profileId); let execute: () => Promise; let reconnect: () => Promise; @@ -624,6 +630,7 @@ export function createDesktopRuntimeHostManagement(input: { throw new Error('Runtime Host configuration interruption authority is invalid'); } const profileId = requireProfileId(profileIdValue); + input.profiles.assertPairingComplete(profileId); const provider = providers.get(profileId); let execute: () => Promise; let reconnect: () => Promise; @@ -690,6 +697,9 @@ export function createDesktopRuntimeHostManagement(input: { ): Promise => { const policy = policyValue === undefined ? undefined : requireUpdatePolicy(policyValue); const providerProfileId = requireProfileId(profileIdValue); + if (policy !== undefined) { + input.profiles.assertPairingComplete(providerProfileId); + } const provider = providers.get(providerProfileId); const execute = provider ? async (next?: RuntimeHostManagedUpdatePolicy) => @@ -729,6 +739,7 @@ export function createDesktopRuntimeHostManagement(input: { profileIdValue: unknown, ): Promise => { const profileId = requireProfileId(profileIdValue); + input.profiles.assertPairingComplete(profileId); const provider = providers.get(profileId); let execute: () => Promise; let reconnect: () => Promise; diff --git a/apps/desktop/src/main/runtime-host-pairing-journal.ts b/apps/desktop/src/main/runtime-host-pairing-journal.ts index 42bd64ace4..38169122e3 100644 --- a/apps/desktop/src/main/runtime-host-pairing-journal.ts +++ b/apps/desktop/src/main/runtime-host-pairing-journal.ts @@ -99,16 +99,31 @@ export async function writeDesktopRuntimeHostPairingIntents( throw new Error('Runtime Host pairing recovery journal has too many entries'); } if (intents.length === 0) { - await credentials.deleteSecret( - PAIRING_JOURNAL_CREDENTIAL_SLOT, - 'runtime_host_access', - ); + try { + await credentials.deleteSecret( + PAIRING_JOURNAL_CREDENTIAL_SLOT, + 'runtime_host_access', + ); + } catch (deleteError) { + // Some credential backends can update an existing item while deletion is + // temporarily unavailable. Persisting an empty, valid journal is the + // terminal record; physical deletion is only garbage collection. + try { + await credentials.setSecret( + PAIRING_JOURNAL_CREDENTIAL_SLOT, + 'runtime_host_access', + encodePairingJournal([]), + ); + } catch (writeError) { + throw new AggregateError( + [deleteError, writeError], + 'Runtime Host pairing recovery journal could not be cleared', + ); + } + } return; } - const contents = JSON.stringify({ - schemaVersion: PAIRING_JOURNAL_SCHEMA_VERSION, - intents, - }); + const contents = encodePairingJournal(intents); if (Buffer.byteLength(contents) > PAIRING_JOURNAL_MAX_BYTES) { throw new Error('Runtime Host pairing recovery journal exceeds its size limit'); } @@ -119,6 +134,13 @@ export async function writeDesktopRuntimeHostPairingIntents( ); } +function encodePairingJournal(intents: readonly DesktopRuntimeHostPairingIntent[]): string { + return JSON.stringify({ + schemaVersion: PAIRING_JOURNAL_SCHEMA_VERSION, + intents, + }); +} + function decodePairingJournal(value: unknown): readonly DesktopRuntimeHostPairingIntent[] { const journal = requireExactRecord(value, ['schemaVersion', 'intents']); if ( diff --git a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts index f56c8f2046..7c6a24f95f 100644 --- a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts +++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts @@ -33,22 +33,45 @@ import type { DesktopRuntimeHostSshPeerMeshManagementInput, createDesktopRuntimeHostSshTerminal, } from './runtime-host-ssh-terminal.js'; +import type { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js'; +import type { + DesktopRuntimeHostLocalManagementTarget, + DesktopLocalRuntimeHostRemoteAccess, +} from './runtime-host-local-remote-access.js'; type SshTerminal = ReturnType; +type LocalOperator = ReturnType; type PeerMeshAction = DesktopRuntimeHostSshPeerMeshManagementInput['action']; +type PeerMeshResult = PeerMeshQueryResult | PeerMeshInvitationResult; + +interface ManagedPeerMeshCommand { + readonly action: PeerMeshAction; + readonly meshId?: string | null; + readonly peerId?: string; + readonly invitation?: string; + readonly displayName?: string | null; + readonly signal?: AbortSignal; +} + +type RunManagedPeerMeshCommand = (command: ManagedPeerMeshCommand) => Promise; export function createDesktopRuntimeHostPeerMeshManagement(input: { readonly ipcMain: Pick; readonly localMesh?: () => PeerMeshNode | undefined; + readonly localHost: Pick; + readonly runLocal: LocalOperator['runPeerMesh']; readonly profiles: Pick; readonly runRemote: SshTerminal['runPeerMeshManagement']; }): { close(): void } { + const activeOperations = new Map(); const execute = async ( targetValue: unknown, actionValue: unknown, meshIdValue?: unknown, peerIdValue?: unknown, invitationValue?: unknown, + displayNameValue?: unknown, + signal?: AbortSignal, ): Promise => { const target = requireTarget(targetValue); const action = requireAction(actionValue); @@ -60,8 +83,51 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { : undefined; const peerId = action === 'remove' ? requireIdentifier(peerIdValue, 'Peer ID') : undefined; const invitation = action === 'join' ? requireInvitation(invitationValue) : undefined; + const displayName = + action === 'rename' || action === 'rename-mesh' + ? requireDisplayName(displayNameValue) + : undefined; if (target.kind === 'desktop') { - return executeLocal(input.localMesh?.(), action, meshId, peerId, invitation); + if (action === 'reconcile') { + return reconcileDesktopTarget( + input.localMesh?.(), + input.localHost, + input.runLocal, + signal, + ); + } + return executeLocal( + input.localMesh?.(), + action, + meshId, + peerId, + invitation, + displayName, + signal, + ); + } + if (target.kind === 'local_host') { + return input.localHost.inspectManaged(async (managed) => { + const run: RunManagedPeerMeshCommand = async (command) => { + const response = await input.runLocal({ + operatorPath: managed.operatorPath, + target: managedTarget(managed), + ...command, + signal: command.signal, + }); + if (response.kind === 'error') throw new Error(response.error.message); + return response.result; + }; + if (action === 'reconcile') return reconcileManagedTarget(input.localMesh?.(), run, signal); + return run({ + action, + ...(meshId !== undefined ? { meshId } : {}), + ...(peerId ? { peerId } : {}), + ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), + ...(displayName !== undefined ? { displayName } : {}), + signal, + }); + }); } const managed = await input.profiles.resolveManagedService(target.profileId); if ( @@ -72,35 +138,165 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { ) { throw new Error('This Runtime Host does not have an active SSH management channel'); } - const response = await input.runRemote({ - destination: managed.profile.transport.destination, - ...(managed.profile.transport.sshPort === undefined - ? {} - : { sshPort: managed.profile.transport.sshPort }), - operatorPath: managed.control.operatorPath, + const transport = managed.profile.transport; + const run: RunManagedPeerMeshCommand = async (command) => { + const response = await input.runRemote({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + expectedTarget: { + serviceId: managed.deployment.id, + rootPath: managed.deployment.rootPath, + rootId: managed.profile.rootId, + deploymentId: managed.deployment.deploymentId, + }, + ...command, + signal: command.signal, + }); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== command.action) { + throw new Error('Runtime Host returned an unrelated Mesh result'); + } + return response.result; + }; + if (action === 'reconcile') return reconcileManagedTarget(input.localMesh?.(), run, signal); + return run({ action, - expectedTarget: { - serviceId: managed.deployment.id, - rootPath: managed.deployment.rootPath, - rootId: managed.profile.rootId, - deploymentId: managed.deployment.deploymentId, - }, ...(meshId !== undefined ? { meshId } : {}), ...(peerId ? { peerId } : {}), ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), + ...(displayName !== undefined ? { displayName } : {}), + signal, }); - if (response.kind === 'error') throw new Error(response.error.message); - if (response.action !== action) throw new Error('Runtime Host returned an unrelated Mesh result'); - return response.result; }; const channel = 'runtime-host-peer-mesh:execute'; input.ipcMain.handle( channel, - (_event, target, action, meshId, peerId, invitation) => - execute(target, action, meshId, peerId, invitation), + async (_event, target, action, meshId, peerId, invitation, displayName, operationIdValue) => { + const operationId = requireOperationId(operationIdValue); + if (!operationId) { + return execute(target, action, meshId, peerId, invitation, displayName); + } + const controller = new AbortController(); + if (activeOperations.has(operationId)) throw new Error('Peer Mesh operation is already active'); + activeOperations.set(operationId, controller); + try { + return await execute( + target, + action, + meshId, + peerId, + invitation, + displayName, + controller.signal, + ); + } finally { + activeOperations.delete(operationId); + } + }, ); - return { close: () => input.ipcMain.removeHandler(channel) }; + const cancelChannel = 'runtime-host-peer-mesh:cancel'; + input.ipcMain.handle(cancelChannel, (_event, operationIdValue) => { + const operationId = requireOperationId(operationIdValue, true); + if (!operationId) throw new Error('Peer Mesh operation ID is required'); + activeOperations.get(operationId)?.abort(new Error('Peer Mesh operation was cancelled')); + }); + return { + close: () => { + for (const controller of activeOperations.values()) { + controller.abort(new Error('Peer Mesh management closed')); + } + activeOperations.clear(); + input.ipcMain.removeHandler(channel); + input.ipcMain.removeHandler(cancelChannel); + }, + }; +} + +async function reconcileDesktopTarget( + desktopMesh: PeerMeshNode | undefined, + localHost: Pick, + runLocal: LocalOperator['runPeerMesh'], + signal?: AbortSignal, +): Promise { + if (!desktopMesh) throw new Error('This Desktop build does not include Direct peer support'); + const failures: unknown[] = []; + const localSnapshot = await localHost.getSnapshot(); + if (localSnapshot.state === 'on') { + await localHost.inspectManaged(async (managed) => { + const run: RunManagedPeerMeshCommand = async (command) => { + const response = await runLocal({ + operatorPath: managed.operatorPath, + target: managedTarget(managed), + ...command, + }); + if (response.kind === 'error') throw new Error(response.error.message); + return response.result; + }; + await reconcileManagedTarget(desktopMesh, run, signal); + }).catch((error) => failures.push(error)); + } + await desktopMesh.reconcile(signal).catch((error) => failures.push(error)); + if (failures.length > 0) { + throw failures.length === 1 + ? failures[0] + : new AggregateError(failures, 'Peer Mesh synchronization failed'); + } + return projectPeerMeshQuery(desktopMesh); +} + +async function reconcileManagedTarget( + desktopMesh: PeerMeshNode | undefined, + run: RunManagedPeerMeshCommand, + signal?: AbortSignal, +): Promise { + if (!desktopMesh) return requireQueryResult(await run({ action: 'reconcile', signal })); + + const desktop = projectPeerMeshQuery(desktopMesh); + const managed = requireQueryResult(await run({ action: 'status', signal })); + const managedById = new Map(managed.meshes.map((mesh) => [mesh.meshId, mesh])); + let recovered = false; + for (const desktopMembership of desktop.meshes) { + const managedMembership = managedById.get(desktopMembership.meshId); + if (!managedMembership) continue; + if ( + desktopMembership.role === 'authority' && + managedMembership.role === 'member' && + authorityRouteNeedsRecovery(managedMembership) + ) { + const invitation = await desktopMesh.invite(desktopMembership.meshId); + await run({ action: 'join', invitation: JSON.stringify(invitation), signal }); + recovered = true; + continue; + } + if ( + desktopMembership.role === 'member' && + managedMembership.role === 'authority' && + authorityRouteNeedsRecovery(desktopMembership) + ) { + const invited = await run({ action: 'invite', meshId: managedMembership.meshId, signal }); + const invitation = requireInvitationResult(invited).invitation; + await desktopMesh.join(invitation, signal); + recovered = true; + } + } + return requireQueryResult(await run({ action: recovered ? 'status' : 'reconcile', signal })); +} + +function authorityRouteNeedsRecovery(mesh: PeerMeshQueryResult['meshes'][number]): boolean { + const authority = mesh.members.find(({ peerId }) => peerId === mesh.authorityPeerId); + return authority === undefined || authority.state === 'unknown' || authority.state === 'stale'; +} + +function requireQueryResult(result: PeerMeshResult): PeerMeshQueryResult { + if ('available' in result) return result; + throw new Error('Runtime Host returned an unrelated Peer Mesh result'); +} + +function requireInvitationResult(result: PeerMeshResult): PeerMeshInvitationResult { + if ('invitation' in result) return result; + throw new Error('Runtime Host returned an unrelated Peer Mesh result'); } async function executeLocal( @@ -109,6 +305,8 @@ async function executeLocal( meshId: string | null | undefined, peerId: string | undefined, invitation: ReturnType | undefined, + displayName: string | null | undefined, + signal?: AbortSignal, ): Promise { if (!mesh) { if (action === 'status') return { available: false, meshes: [] }; @@ -126,26 +324,46 @@ async function executeLocal( return { invitation: created, snapshot: snapshot() }; } case 'join': - await mesh.join(requiredValue(invitation, 'Peer Mesh invitation')); + await mesh.join(requiredValue(invitation, 'Peer Mesh invitation'), signal); return snapshot(); case 'remove': await mesh.remove(requiredValue(meshId, 'Mesh ID'), requiredValue(peerId, 'Peer ID')); return snapshot(); case 'leave': - await mesh.leave(requiredValue(meshId, 'Mesh ID')); + await mesh.leave(requiredValue(meshId, 'Mesh ID'), signal); return snapshot(); case 'close': await mesh.closeMesh(requiredValue(meshId, 'Mesh ID')); return snapshot(); case 'reconcile': - await mesh.reconcile(); + await mesh.reconcile(signal); return snapshot(); case 'transit': await mesh.setTransitMesh(meshId ?? null); return snapshot(); + case 'rename': + await mesh.setDisplayName(requiredDisplayName(displayName)); + return snapshot(); + case 'rename-mesh': + await mesh.setMeshDisplayName( + requiredValue(meshId, 'Mesh ID'), + requiredDisplayName(displayName), + ); + return snapshot(); } } +function requiredDisplayName(value: string | null | undefined): string | null { + if (value === undefined) throw new Error('Display name is required'); + return value; +} + +function requireDisplayName(value: unknown): string | null { + if (value === null) return null; + if (typeof value !== 'string') throw new Error('Peer Mesh display name is invalid'); + return value; +} + function requiredValue(value: T | null | undefined, label: string): NonNullable { if (value === undefined || value === null) throw new Error(`${label} is required`); return value; @@ -157,6 +375,9 @@ function requireTarget(value: unknown): DesktopRuntimeHostPeerMeshTarget { } const record = value as Record; if (record.kind === 'desktop' && Object.keys(record).length === 1) return { kind: 'desktop' }; + if (record.kind === 'local_host' && Object.keys(record).length === 1) { + return { kind: 'local_host' }; + } if ( record.kind === 'managed_host' && Object.keys(record).length === 2 && @@ -169,6 +390,17 @@ function requireTarget(value: unknown): DesktopRuntimeHostPeerMeshTarget { throw new Error('Peer Mesh target is invalid'); } +function managedTarget( + target: DesktopRuntimeHostLocalManagementTarget, +): Omit { + return { + serviceId: target.serviceId, + rootPath: target.rootPath, + rootId: target.rootId, + deploymentId: target.deploymentId, + }; +} + function requireAction(value: unknown): PeerMeshAction { if ( value === 'status' || value === 'create' || value === 'invite' || value === 'join' || @@ -176,7 +408,9 @@ function requireAction(value: unknown): PeerMeshAction { value === 'leave' || value === 'close' || value === 'reconcile' || - value === 'transit' + value === 'transit' || + value === 'rename' || + value === 'rename-mesh' ) return value; throw new Error('Peer Mesh action is invalid'); } @@ -187,7 +421,8 @@ function actionNeedsMesh(action: PeerMeshAction): boolean { action === 'remove' || action === 'leave' || action === 'close' || - action === 'transit' + action === 'transit' || + action === 'rename-mesh' ); } @@ -198,6 +433,19 @@ function requireIdentifier(value: unknown, label: string): string { return value; } +function requireOperationId(value: unknown, required = false): string | undefined { + if (value === undefined && !required) return undefined; + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 128 || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw new Error('Peer Mesh operation ID is invalid'); + } + return value; +} + function requireInvitation(value: unknown): ReturnType { if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 128 * 1024) { throw new Error('Peer Mesh invitation is invalid'); diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index dd91bf7eb6..c6b42484c1 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -18,7 +18,7 @@ */ import { createHash, randomUUID } from "node:crypto"; -import { open, readFile, rename, rm } from "node:fs/promises"; +import { lstat, open, readFile, rename, rm, rmdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { createClientRuntimeHostCredentialStore, @@ -69,6 +69,7 @@ import { 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; const PREFERENCES_FILE = "runtime-host-profile-selection.json"; @@ -109,6 +110,10 @@ export interface DesktopRuntimeHostProfileService { resolveManagedService( profileId: string, ): Promise; + resolveCollaborationConnectionTarget( + profile: PersistedRuntimeHostProfile, + ): Promise; + assertPairingComplete(profileId: string): void; resolveManagedAccess( profileId: string, ): Promise; @@ -137,7 +142,8 @@ export interface DesktopRuntimeHostProfileService { credential: string, ): Promise; startEnabledProfiles(): Promise; - resolvePairingRecovery(): Promise; + resolvePairingRecovery(profileId?: string): Promise; + discardPairing(profileId: string): Promise; setEnabled(profileId: string, enabled: boolean): Promise; reconnect(profileId: string, expectedRootId: string): Promise; setDefault(profileId: string): Promise; @@ -310,6 +316,7 @@ export function createDesktopRuntimeHostProfileService(input: { const mutateProfiles = (operation: () => Promise): Promise => mutate(async () => { + await recoverAbandonedProfileLock(profilePath); assertPreferencesWritable(); if (pairingReadFailure) { throw new Error( @@ -335,7 +342,7 @@ export function createDesktopRuntimeHostProfileService(input: { await persistPairingIntents(new Map(pairingIntents).set(profileId, intent)); }; - const assertPairingComplete = (profileId: string): void => { + const requirePairingComplete = (profileId: string): void => { if (pairingIntents.has(profileId)) { throw new Error('Resolve this Runtime Host\'s unfinished pairing before changing it'); } @@ -363,6 +370,7 @@ export function createDesktopRuntimeHostProfileService(input: { findDesktopRuntimeHostManagedServiceBinding(managedDocument, profile) ? { managedService: true as const } : {}), + ...(pairingIntents.has(profile.id) ? { pairingPending: true as const } : {}), enabled: isEnabled, isDefault: preferences.defaultProfileId === profile.id, readiness: isEnabled ? (state?.readiness ?? "unavailable") : "disabled", @@ -384,12 +392,17 @@ export function createDesktopRuntimeHostProfileService(input: { preferences = next; }; - const clearPairingIntentBestEffort = async (profileId: string): Promise => { + const clearPairingIntent = async (profileId: string): Promise => { const next = new Map(pairingIntents); next.delete(profileId); - await persistPairingIntents(next).catch((error) => - console.error("[runtime-host] completed pairing recovery could not be cleared:", error), - ); + try { + await persistPairingIntents(next); + } catch (error) { + // Keep the in-memory recovery lock when neither deletion nor an empty + // journal can be persisted. Retrying is safe and prevents a stale intent + // from becoming ambiguous after restart. + throw new RuntimeHostPairingFinalizationInterruptedError({ cause: error }); + } }; const ensureEnabled = async (target: ResolvedRuntimeHostProfile): Promise => { @@ -494,7 +507,7 @@ export function createDesktopRuntimeHostProfileService(input: { await ensureEnabled(target); await activateTarget(target, "terminal"); await input.finalizePairing(target.profile.id); - await clearPairingIntentBestEffort(target.profile.id); + await clearPairingIntent(target.profile.id); }; const rollbackPairingIntent = async ( @@ -514,10 +527,11 @@ export function createDesktopRuntimeHostProfileService(input: { if (!intent.previous) { await managedServices.removeForProfileIfCurrent(intent.target.profile); } - await clearPairingIntentBestEffort(intent.target.profile.id); + await clearPairingIntent(intent.target.profile.id); return; } const rollbackFailures: unknown[] = []; + let reactivationFailure: Error | undefined; await input.disable(current.profile.id).catch((error) => rollbackFailures.push(error)); if (intent.previous) { const restored = await catalog @@ -549,9 +563,9 @@ export function createDesktopRuntimeHostProfileService(input: { (error) => rollbackFailures.push(error), ); if (intent.wasEnabled) { - await activateTarget(previousTarget, "terminal").catch((error) => - rollbackFailures.push(error), - ); + await activateTarget(previousTarget, "terminal").catch((error) => { + reactivationFailure = asError(error); + }); } } } @@ -588,8 +602,9 @@ export function createDesktopRuntimeHostProfileService(input: { "Runtime Host pairing failed and its previous profile could not be restored", ); } - unavailable.delete(current.profile.id); - await clearPairingIntentBestEffort(intent.target.profile.id); + if (reactivationFailure) unavailable.set(current.profile.id, reactivationFailure); + else unavailable.delete(current.profile.id); + await clearPairingIntent(intent.target.profile.id); }; const recoverPairingIntent = async ( @@ -607,7 +622,7 @@ export function createDesktopRuntimeHostProfileService(input: { if (!intent.previous) { await managedServices.removeForProfileIfCurrent(intent.target.profile); } - await clearPairingIntentBestEffort(intent.target.profile.id); + await clearPairingIntent(intent.target.profile.id); if ( current && (preferences.defaultProfileId === current.profile.id || @@ -679,11 +694,13 @@ export function createDesktopRuntimeHostProfileService(input: { requireSaveInput(value); return mutateProfiles(async () => { const currentDocument = await catalog.read(); - const existing = currentDocument.profiles.find((profile) => - profile.kind === 'remote' && - profile.rootId === value.profile.rootId && - sameRemoteRuntimeHostProfileTarget(profile, value.profile), - ); + 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 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; @@ -787,10 +804,11 @@ export function createDesktopRuntimeHostProfileService(input: { if (bundle.target.transport.kind === 'plaintext' && !allowInsecure) { return { kind: 'error', reason: 'insecure_confirmation_required' }; } + const profileId = `shared-${randomUUID()}`; try { await addAndEnableVerified({ profile: { - id: `shared-${randomUUID()}`, + id: profileId, name: `${bundle.target.name} · Shared`, kind: 'remote', rootId: invitation.rootId, @@ -801,9 +819,12 @@ export function createDesktopRuntimeHostProfileService(input: { }); return { kind: 'connected' }; } catch (error) { + if (pairingIntents.has(profileId)) { + return { kind: 'pairing_pending', profileId }; + } return { kind: 'error', - reason: 'connection_failed', + reason: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed', message: asError(error).message, }; } @@ -871,6 +892,30 @@ export function createDesktopRuntimeHostProfileService(input: { return binding; }); }, + resolveCollaborationConnectionTarget(profile) { + return mutate(async () => { + if (profile.kind !== 'remote') { + throw new Error('This Runtime Host does not expose a shareable peer endpoint'); + } + if (profile.transport.kind !== 'ssh') { + return { name: profile.name, transport: profile.transport }; + } + const direct = (await catalog.read()).profiles.find( + (candidate) => candidate.id === managedDirectPeerProfileId(profile.id), + ); + if ( + !direct || + direct.kind !== 'remote' || + direct.rootId !== profile.rootId || + direct.transport.kind !== 'libp2p-direct' + ) { + throw new Error( + 'Enable Direct peer access for this Runtime Host before sharing its Sessions', + ); + } + return { name: profile.name, transport: direct.transport }; + }); + }, resolveManagedAccess(profileId) { return mutate(async () => { if (pairingReadFailure || pairingIntents.has(profileId)) { @@ -894,6 +939,9 @@ export function createDesktopRuntimeHostProfileService(input: { : undefined; }); }, + assertPairingComplete(profileId) { + requirePairingComplete(profileId); + }, resolveManagedDirectPeerProfile(profileId) { return mutate(async () => { const peerProfileId = managedDirectPeerProfileId(profileId); @@ -905,7 +953,7 @@ export function createDesktopRuntimeHostProfileService(input: { }, upsertManagedDirectPeerProfile(profileId, peer) { return mutateProfiles(async () => { - assertPairingComplete(profileId); + requirePairingComplete(profileId); const source = await catalog.resolve(profileId); if ( source.profile.kind !== 'remote' || @@ -959,7 +1007,7 @@ export function createDesktopRuntimeHostProfileService(input: { }, removeManagedDirectPeerProfile(profileId) { return mutateProfiles(async () => { - assertPairingComplete(profileId); + requirePairingComplete(profileId); const peerProfileId = managedDirectPeerProfileId(profileId); if (preferences.enabledRemoteProfileIds.includes(peerProfileId)) { throw new Error('Disable the Direct peer profile before changing its listener'); @@ -992,7 +1040,7 @@ export function createDesktopRuntimeHostProfileService(input: { 'Re-onboard this Runtime Host before uninstalling it; its legacy binding has no deployment generation', ); } - assertPairingComplete(expected.profile.id); + requirePairingComplete(expected.profile.id); const document = await catalog.read(); const current = document.profiles.find( (profile) => profile.id === expected.profile.id, @@ -1021,7 +1069,7 @@ export function createDesktopRuntimeHostProfileService(input: { }, markManagedServiceCleanupPending(expected) { return mutateProfiles(async () => { - assertPairingComplete(expected.profile.id); + requirePairingComplete(expected.profile.id); const current = (await catalog.read()).profiles.find( (profile) => profile.id === expected.profile.id, ); @@ -1038,7 +1086,7 @@ export function createDesktopRuntimeHostProfileService(input: { }, clearManagedServiceBinding(expected) { return mutateProfiles(async () => { - assertPairingComplete(expected.profile.id); + requirePairingComplete(expected.profile.id); const current = (await catalog.read()).profiles.find( (profile) => profile.id === expected.profile.id, ); @@ -1075,7 +1123,7 @@ export function createDesktopRuntimeHostProfileService(input: { } return Promise.all(tasks).then(() => undefined); }, - resolvePairingRecovery() { + resolvePairingRecovery(profileId) { return mutate(async () => { assertPreferencesWritable(); if (pairingReadFailure) { @@ -1094,9 +1142,32 @@ export function createDesktopRuntimeHostProfileService(input: { pairingReadFailure = undefined; } } - for (const intent of [...pairingIntents.values()]) { - await recoverPairingIntent(intent); + const intents = profileId === undefined + ? [...pairingIntents.values()] + : [pairingIntents.get(profileId)].filter( + (intent): intent is DesktopRuntimeHostPairingIntent => intent !== undefined, + ); + const failures: Error[] = []; + for (const intent of intents) { + const failure = await recoverPairingIntent(intent); + if (failure) failures.push(failure); } + if (failures.length > 0) { + throw failures.length === 1 + ? failures[0] + : new AggregateError(failures, 'Some Runtime Hosts are still unreachable'); + } + return snapshot(); + }); + }, + discardPairing(profileId) { + return mutateProfiles(async () => { + const intent = pairingIntents.get(profileId); + if (!intent) return snapshot(); + await rollbackPairingIntent( + intent, + new Error('Runtime Host pairing was discarded'), + ); return snapshot(); }); }, @@ -1110,7 +1181,7 @@ export function createDesktopRuntimeHostProfileService(input: { await enable(profileId); return snapshot(); } - assertPairingComplete(profileId); + requirePairingComplete(profileId); if (preferences.defaultProfileId === profileId) { throw new Error("Choose another default Runtime Host before disabling this one"); } @@ -1123,7 +1194,7 @@ export function createDesktopRuntimeHostProfileService(input: { }, reconnect(profileId, expectedRootId) { return mutateProfiles(async () => { - assertPairingComplete(profileId); + requirePairingComplete(profileId); if (!preferences.enabledRemoteProfileIds.includes(profileId)) { throw new Error('Enable this Runtime Host before reconnecting it'); } @@ -1160,7 +1231,7 @@ export function createDesktopRuntimeHostProfileService(input: { if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { throw new Error("Local Runtime Host cannot be removed"); } - assertPairingComplete(profileId); + requirePairingComplete(profileId); if (preferences.enabledRemoteProfileIds.includes(profileId)) { throw new Error("Disable a Runtime Host before removing it"); } @@ -1255,13 +1326,40 @@ function assertRootIsNotEnabled( return stateRootId === rootId; }); const duplicate = duplicateProfile ?? duplicateState?.target.profile; - if (duplicate) { + if (duplicate && !(isSessionGuestProfile(target.profile) && isSessionGuestProfile(duplicate))) { throw new Error( `Runtime Host profile "${duplicate.name}" is already connected to this computer; disable it before adding another connection`, ); } } +async function recoverAbandonedProfileLock(profilePath: string): Promise { + const lockPath = `${profilePath}.lock`; + const lock = await lstat(lockPath).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + }); + if ( + !lock || + !lock.isDirectory() || + lock.isSymbolicLink() + ) { + return; + } + // Electron's single-instance authority excludes another Desktop writer for + // this client data root. Legacy directory locks contain no owner identity, + // so only reclaim an old, empty marker; unexpected contents still fail loud. + await rmdir(lockPath).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + }); +} + +function isSessionGuestProfile( + profile: ResolvedRuntimeHostProfile['profile'], +): boolean { + return profile.kind === 'remote' && profile.access === 'session_guest'; +} + async function persistIfCurrentTarget( catalog: RuntimeHostProfileCatalog, profilePath: string, @@ -1301,6 +1399,7 @@ export function registerDesktopRuntimeHostProfileIpc( "runtime-host-profiles:set-default", "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()); @@ -1313,8 +1412,13 @@ export function registerDesktopRuntimeHostProfileIpc( ); ipcMain.handle(channels[4], (_event, profileId: string) => service.setDefault(profileId)); ipcMain.handle(channels[5], (_event, profileId: string) => service.remove(profileId)); - ipcMain.handle(channels[6], () => service.resolvePairingRecovery()); - ipcMain.handle(channels[7], (_event, code: string, allowInsecure: boolean) => + ipcMain.handle(channels[6], (_event, profileId?: string) => + service.resolvePairingRecovery(profileId), + ); + ipcMain.handle(channels[7], (_event, profileId: string) => + service.discardPairing(profileId), + ); + ipcMain.handle(channels[8], (_event, code: string, allowInsecure: boolean) => service.importCollaborationInvitation(code, allowInsecure), ); return () => { @@ -1352,6 +1456,16 @@ function connectionCodeImportFailure( return 'unknown'; } +function errorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object' || !('code' in error)) return 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/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index fb4be4acca..cfcd12baac 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -191,6 +191,7 @@ export interface DesktopRuntimeHostSshPeerMeshManagementInput { readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; readonly meshId?: string | null; readonly peerId?: string; + readonly displayName?: string | null; readonly invitation?: string; readonly signal?: AbortSignal; } @@ -1179,6 +1180,10 @@ function runtimeHostSetupRemoteCommand( 'desktop-client', '--lifecycle', input.lifecycle === 'on_demand' ? 'on-demand' : 'supervised', + // Development archives identify every source revision as a distinct exact + // package. Re-running Add computer is the explicit replacement gesture in + // that environment; released packages keep using the normal update UI. + ...(setupPackage.kind === 'development_archive' ? ['--update-existing'] : []), '--defer-pairing-commit', ...(input.projectDirectoryRoots === undefined ? [] @@ -1371,6 +1376,11 @@ function runtimeHostPeerMeshManagementRemoteCommand( ? ['--off'] : []), ...(input.peerId ? ['--peer', input.peerId] : []), + ...(input.displayName === null + ? ['--clear-name'] + : input.displayName + ? ['--name', input.displayName] + : []), ...managedServiceTargetArgs(input.expectedTarget), ].map(quotePosix).join(' '); return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index df55b7d7aa..0eb9473594 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -310,6 +310,7 @@ export type AppUpdateInstallResult = export interface DesktopRuntimeHostProfileEntry { readonly profile: RuntimeHostProfile; readonly managedService?: true; + readonly pairingPending?: true; readonly enabled: boolean; readonly isDefault: boolean; readonly readiness: 'disabled' | 'connecting' | 'ready' | 'reconnecting' | 'unavailable'; @@ -326,11 +327,13 @@ export interface DesktopRuntimeHostProfileSnapshot { 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; }; @@ -556,6 +559,7 @@ export interface DesktopRuntimeHostDirectPeerSnapshot { export type DesktopRuntimeHostPeerMeshTarget = | { readonly kind: 'desktop' } + | { readonly kind: 'local_host' } | { readonly kind: 'managed_host'; readonly profileId: string }; export type DesktopRuntimeHostPeerMeshAction = @@ -732,9 +736,10 @@ export interface MakaBridge { ): Promise; importConnectionCode(code: string): Promise; remove(profileId: string): Promise; + discardPairing(profileId: string): Promise; setEnabled(profileId: string, enabled: boolean): Promise; setDefault(profileId: string): Promise; - resolvePairingRecovery(): Promise; + resolvePairingRecovery(profileId?: string): Promise; subscribeChanges( handler: (event: DesktopRuntimeHostProfileChangedEvent) => void, ): () => void; @@ -816,8 +821,11 @@ export interface MakaBridge { readonly meshId?: string | null; readonly peerId?: string; readonly invitation?: string; + readonly displayName?: string | null; + readonly operationId?: string; }, ): Promise; + cancel(operationId: string): Promise; }; newTasks: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c3f7025879..5552ff34fd 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -247,6 +247,7 @@ import { let activeRuntimeHost: DesktopTargetScope | undefined; let activeRuntimeHostGeneration = 0; +type RuntimeHostScopeKey = string; const runtimeHostScopes = new Map(); const runtimeHostProfiles = new Map(); const runtimeHostMetadata = new Map< @@ -257,35 +258,58 @@ const runtimeHostMetadata = new Map< readonly profileKind: RuntimeHostProfileKind; } >(); +const runtimeHostSessionScopes = new Map(); const newTaskChangeListeners = new Set<() => void>(); let previousMainProcessInterruptionRead: Promise | undefined; +function runtimeHostScopeKey(scope: DesktopTargetScope): RuntimeHostScopeKey { + return `${scope.hostId}\u0000${scope.targetEpoch}`; +} + +function runtimeHostMetadataFor(scope: DesktopTargetScope) { + return runtimeHostMetadata.get(runtimeHostScopeKey(scope)); +} + +function recordRuntimeHostSessionScope(scope: DesktopTargetScope, sessionId: string): string { + const projected = desktopSessionKey({ hostId: scope.hostId, sessionId }); + runtimeHostSessionScopes.set(projected, runtimeHostScopeKey(scope)); + return projected; +} + type RuntimeHostProfileWireEvent = DesktopRuntimeHostProfileChangedEvent; ipcRenderer.on( 'runtime-host-profiles:changed', (_event, change: RuntimeHostProfileWireEvent) => { - const previousHostId = runtimeHostProfiles.get(change.profileId); + const previousScopeKey = runtimeHostProfiles.get(change.profileId); + const nextScope = change.hostId + ? { hostId: change.hostId, targetEpoch: change.epoch } + : undefined; + const nextScopeKey = nextScope ? runtimeHostScopeKey(nextScope) : undefined; if ( - previousHostId && - (change.removed || (change.hostId !== undefined && previousHostId !== change.hostId)) + previousScopeKey && + (change.removed || (nextScopeKey !== undefined && previousScopeKey !== nextScopeKey)) ) { - runtimeHostScopes.delete(previousHostId); + for (const [sessionId, scopeKey] of runtimeHostSessionScopes) { + if (scopeKey !== previousScopeKey) continue; + if (nextScopeKey) runtimeHostSessionScopes.set(sessionId, nextScopeKey); + else runtimeHostSessionScopes.delete(sessionId); + } + runtimeHostScopes.delete(previousScopeKey); + runtimeHostMetadata.delete(previousScopeKey); if (change.removed) { - runtimeHostMetadata.delete(previousHostId); runtimeHostProfiles.delete(change.profileId); } } - if (change.hostId) { - const scope = { hostId: change.hostId, targetEpoch: change.epoch }; - runtimeHostScopes.set(scope.hostId, scope); - runtimeHostProfiles.set(change.profileId, change.hostId); - runtimeHostMetadata.set(change.hostId, { + if (nextScope && nextScopeKey) { + runtimeHostScopes.set(nextScopeKey, nextScope); + runtimeHostProfiles.set(change.profileId, nextScopeKey); + runtimeHostMetadata.set(nextScopeKey, { profileId: change.profileId, profileName: change.profileName, profileKind: change.profileKind, }); - if (change.isDefault) activeRuntimeHost = scope; + if (change.isDefault) activeRuntimeHost = nextScope; } else if (change.isDefault) { activeRuntimeHost = undefined; } @@ -320,9 +344,10 @@ function recordRuntimeHostIdentity(value: unknown): { ) { throw new Error('Desktop Runtime Host identity is invalid'); } - runtimeHostScopes.set(scope.hostId, scope); - runtimeHostProfiles.set(metadata.profileId, scope.hostId); - runtimeHostMetadata.set(scope.hostId, { + const scopeKey = runtimeHostScopeKey(scope); + runtimeHostScopes.set(scopeKey, scope); + runtimeHostProfiles.set(metadata.profileId, scopeKey); + runtimeHostMetadata.set(scopeKey, { profileId: metadata.profileId, profileName: metadata.profileName, profileKind: metadata.profileKind, @@ -338,16 +363,17 @@ async function runtimeHostScopeList(): Promise { if (!Array.isArray(identities)) { throw new Error('Desktop Runtime Host identities are unavailable'); } - const authoritativeHostIds = new Set(); + const authoritativeScopeKeys = new Set(); const readyScopes: DesktopTargetScope[] = []; for (const identity of identities) { const { scope, readiness } = recordRuntimeHostIdentity(identity); - authoritativeHostIds.add(scope.hostId); + authoritativeScopeKeys.add(runtimeHostScopeKey(scope)); if (readiness === 'ready') readyScopes.push(scope); } - for (const hostId of runtimeHostScopes.keys()) { - if (authoritativeHostIds.has(hostId)) continue; - runtimeHostScopes.delete(hostId); + for (const scopeKey of runtimeHostScopes.keys()) { + if (authoritativeScopeKeys.has(scopeKey)) continue; + runtimeHostScopes.delete(scopeKey); + runtimeHostMetadata.delete(scopeKey); } return readyScopes; } @@ -359,7 +385,12 @@ async function runtimeHostSessionRef(sessionId: string): Promise<{ }> { const ref = parseDesktopSessionKey(sessionId); await runtimeHostScopeList(); - const scope = runtimeHostScopes.get(ref.hostId); + const recordedScopeKey = runtimeHostSessionScopes.get(sessionId); + let scope = recordedScopeKey ? runtimeHostScopes.get(recordedScopeKey) : undefined; + if (!scope) { + const candidates = [...runtimeHostScopes.values()].filter(({ hostId }) => hostId === ref.hostId); + if (candidates.length === 1) scope = candidates[0]; + } if (!scope) throw new Error('The Runtime Host for this task is unavailable'); return { scope, sessionId: ref.sessionId }; } @@ -373,8 +404,8 @@ type TaskDiagnosticRuntimeHostResolution = DiagnosticRuntimeHostResolution<'task type ManualDiagnosticRuntimeHostResolution = DiagnosticRuntimeHostResolution<'default' | 'task'>; type ManualDiagnosticHostSelector = - | { readonly kind: 'host'; readonly hostId: string } - | { readonly kind: 'profile'; readonly profileId: string }; + | { readonly kind: 'profile'; readonly profileId: string } + | { readonly kind: 'session'; readonly sessionId: string }; async function resolveManualDiagnosticRuntimeHost( value: DesktopManualDiagnosticTarget | undefined, @@ -395,10 +426,14 @@ async function resolveTaskDiagnosticRuntimeHost( } catch { return { hostTarget: 'task' }; } - const hostId = selector.kind === 'host' - ? selector.hostId - : runtimeHostProfiles.get(selector.profileId); - const scope = hostId ? runtimeHostScopes.get(hostId) : undefined; + if (selector.kind === 'session') { + try { + return { hostTarget: 'task', scope: (await runtimeHostSessionRef(selector.sessionId)).scope }; + } catch { + return { hostTarget: 'task' }; + } + } + const scope = runtimeHostScopes.get(runtimeHostProfiles.get(selector.profileId) ?? ''); return { hostTarget: 'task', ...(scope ? { scope } : {}) }; } @@ -417,7 +452,7 @@ function parseDiagnosticTarget(value: unknown): { Buffer.byteLength(record.sessionId, 'utf8') <= 512 ) { return { - selector: { kind: 'host', hostId: parseDesktopSessionKey(record.sessionId).hostId }, + selector: { kind: 'session', sessionId: record.sessionId }, }; } if ( @@ -441,11 +476,10 @@ function parseDiagnosticTarget(value: unknown): { typeof record.eventId === 'string' && Buffer.byteLength(record.eventId, 'utf8') <= 512 ) { - const session = parseDesktopSessionKey(record.sessionId); return { - selector: { kind: 'host', hostId: session.hostId }, + selector: { kind: 'session', sessionId: record.sessionId }, execution: { - sessionId: session.sessionId, + sessionId: parseDesktopSessionKey(record.sessionId).sessionId, turnId: record.turnId, eventId: record.eventId, }, @@ -467,7 +501,7 @@ async function activeRuntimeHostRef(): Promise { async function localRuntimeHostRef(): Promise { const scopes = await runtimeHostScopeList(); const scope = scopes.find( - (candidate) => runtimeHostMetadata.get(candidate.hostId)?.profileKind === 'local', + (candidate) => runtimeHostMetadataFor(candidate)?.profileKind === 'local', ); if (!scope) throw new Error('The Local Runtime Host is unavailable'); return scope; @@ -478,9 +512,9 @@ async function runtimeHostScope(host: DesktopRuntimeHostRef): Promise projectSessionSummary(scope, session)), sessionSendOutcomes: Object.fromEntries( Object.entries(snapshot.sessionSendOutcomes).map(([sessionId, outcome]) => [ - desktopSessionKey({ hostId: scope.hostId, sessionId }), + recordRuntimeHostSessionScope(scope, sessionId), outcome, ]), ), @@ -749,7 +784,7 @@ function projectShellRunUpdate( update: ShellRunUpdate, ): ShellRunUpdate { const sessionId = (value: string): string => - desktopSessionKey({ hostId: scope.hostId, sessionId: value }); + recordRuntimeHostSessionScope(scope, value); return { ...update, sessionId: sessionId(update.sessionId), @@ -784,7 +819,7 @@ function subscribeRuntimeHostEvent( } catch { return; } - const current = runtimeHostScopes.get(scope.hostId); + const current = runtimeHostScopes.get(runtimeHostScopeKey(scope)); if ( !current || eventScope.hostId !== current.hostId || @@ -812,7 +847,7 @@ function subscribeEveryRuntimeHostEvent( } catch { return; } - const current = runtimeHostScopes.get(scope.hostId); + const current = runtimeHostScopes.get(runtimeHostScopeKey(scope)); if (!current || current.targetEpoch !== scope.targetEpoch) return; handler(scope, ...(args as unknown as T)); }; @@ -912,7 +947,7 @@ function subscribeSelectedRuntimeHostEvent( return subscribeEveryRuntimeHostEvent(channel, (scope, ...args: T) => { if ( scope.hostId !== host.hostId || - runtimeHostProfiles.get(host.profileId) !== scope.hostId + runtimeHostProfiles.get(host.profileId) !== runtimeHostScopeKey(scope) ) return; handler(...args); }); @@ -1261,7 +1296,7 @@ const makaBridge = { }, async getDefaultHost(): Promise { const scope = await activeRuntimeHostRef(); - const metadata = runtimeHostMetadata.get(scope.hostId); + const metadata = runtimeHostMetadataFor(scope); if (!metadata) throw new Error('The default Runtime Host identity is unavailable'); return { profileId: metadata.profileId, hostId: scope.hostId }; }, @@ -1274,14 +1309,17 @@ const makaBridge = { remove(profileId: string) { return ipcRenderer.invoke('runtime-host-profiles:remove', profileId); }, + discardPairing(profileId: string) { + return ipcRenderer.invoke('runtime-host-profiles:discard-pairing', profileId); + }, setEnabled(profileId: string, enabled: boolean) { return ipcRenderer.invoke('runtime-host-profiles:set-enabled', profileId, enabled); }, setDefault(profileId: string) { return ipcRenderer.invoke('runtime-host-profiles:set-default', profileId); }, - resolvePairingRecovery() { - return ipcRenderer.invoke('runtime-host-profiles:resolve-pairing-recovery'); + resolvePairingRecovery(profileId?: string) { + return ipcRenderer.invoke('runtime-host-profiles:resolve-pairing-recovery', profileId); }, subscribeChanges(handler: (event: DesktopRuntimeHostProfileChangedEvent) => void) { const listener = ( @@ -1469,6 +1507,8 @@ const makaBridge = { readonly meshId?: string | null; readonly peerId?: string; readonly invitation?: string; + readonly displayName?: string | null; + readonly operationId?: string; } = {}, ) { return ipcRenderer.invoke( @@ -1478,8 +1518,13 @@ const makaBridge = { input.meshId, input.peerId, input.invitation, + input.displayName, + input.operationId, ); }, + cancel(operationId: string) { + return ipcRenderer.invoke('runtime-host-peer-mesh:cancel', operationId); + }, }, newTasks: { getCatalog(): Promise { @@ -1629,7 +1674,7 @@ const makaBridge = { return subscribeEveryRuntimeHostEvent('tasks:changed', (scope, event: TaskLedgerChangedEvent) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -1642,7 +1687,7 @@ const makaBridge = { return subscribeEveryRuntimeHostEvent('deepResearch:changed', (scope, event: DeepResearchChangedEvent) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -1754,7 +1799,7 @@ const makaBridge = { ...result, candidates: result.candidates.map((candidate) => ({ ...candidate, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: candidate.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, candidate.sessionId), })), }; }, @@ -1780,10 +1825,7 @@ const makaBridge = { ok: true, result: { ...result.result, - targetSessionId: desktopSessionKey({ - hostId: scope.hostId, - sessionId: result.result.targetSessionId, - }), + targetSessionId: recordRuntimeHostSessionScope(scope, result.result.targetSessionId), }, }; }, @@ -1915,7 +1957,7 @@ const makaBridge = { (scope, event: { sessionId: string; interactions: ActiveInteractionRequestEvent[] }) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -1975,7 +2017,7 @@ const makaBridge = { let unsubscribeObservationSeed = () => {}; const observeDispatch = runtimeHostSessionRef(sessionId).then((session) => { if (disposed) return { completion: Promise.resolve() }; - const profileId = runtimeHostMetadata.get(session.scope.hostId)?.profileId; + const profileId = runtimeHostMetadataFor(session.scope)?.profileId; if (!profileId) throw new Error('The Runtime Host profile for this task is unavailable'); // Keep the renderer listener across Host target epochs. The observer // registry restores this observer on the replacement target. Profile @@ -1984,14 +2026,14 @@ const makaBridge = { unsubscribeEvents = subscribeEveryRuntimeHostEvent( `sessions:event:${session.sessionId}`, (scope, event: SessionEvent) => { - if (runtimeHostMetadata.get(scope.hostId)?.profileId !== profileId) return; + if (runtimeHostMetadataFor(scope)?.profileId !== profileId) return; handler(projectDesktopSessionEvent(scope, event)); }, ); unsubscribeObservationSeed = subscribeEveryRuntimeHostEvent( 'sessions:observation-seed', (scope, payload: { sessionId?: string; phase?: string }) => { - if (runtimeHostMetadata.get(scope.hostId)?.profileId !== profileId) return; + if (runtimeHostMetadataFor(scope)?.profileId !== profileId) return; if (payload.sessionId !== session.sessionId) return; if (payload.phase === 'pending' || payload.phase === 'ready') { onObservationSeed?.(payload.phase); @@ -2033,10 +2075,7 @@ const makaBridge = { ...event, ...(event.sessionId ? { - sessionId: desktopSessionKey({ - hostId: scope.hostId, - sessionId: event.sessionId, - }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), } : {}), }), @@ -2322,7 +2361,7 @@ const makaBridge = { }, subscribeLocalChanges(handler: () => void): () => void { return subscribeEveryRuntimeHostEvent('projects:changed', (scope) => { - if (runtimeHostMetadata.get(scope.hostId)?.profileKind === 'local') handler(); + if (runtimeHostMetadataFor(scope)?.profileKind === 'local') handler(); }); }, add(host?: DesktopRuntimeHostRef): Promise< @@ -2395,10 +2434,7 @@ const makaBridge = { return snapshot ? { ...snapshot, - sessionId: desktopSessionKey({ - hostId: session.scope.hostId, - sessionId: snapshot.sessionId, - }), + sessionId: recordRuntimeHostSessionScope(session.scope, snapshot.sessionId), } : null; }, @@ -2445,14 +2481,14 @@ const makaBridge = { return subscribeEveryRuntimeHostEvent('shell-runs:pty-data', (scope, event: ShellRunPtyDataEvent) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, subscribeResync(handler: (event: { sessionId: string }) => void): () => void { return subscribeEveryRuntimeHostEvent('shell-runs:resync', (scope, event: { sessionId: string }) => handler({ - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -2706,10 +2742,7 @@ const makaBridge = { ...entry, target: { ...entry.target, - sessionId: desktopSessionKey({ - hostId: scope.hostId, - sessionId: entry.target.sessionId, - }), + sessionId: recordRuntimeHostSessionScope(scope, entry.target.sessionId), }, } : entry, @@ -2942,13 +2975,24 @@ const makaBridge = { return loadSessionUsageSummary(sessionId); }, subscribeUsageChanges(sessionId: string, handler: () => void): () => void { - const session = parseDesktopSessionKey(sessionId); - return subscribeEveryRuntimeHostEvent( - 'usage:changed', - (scope, event: { sessionId: string }) => { - if (scope.hostId === session.hostId && event.sessionId === session.sessionId) handler(); - }, - ); + let disposed = false; + let unsubscribe = () => {}; + void runtimeHostSessionRef(sessionId) + .then(({ scope, sessionId: rawSessionId }) => { + if (disposed) return; + unsubscribe = subscribeRuntimeHostEvent( + 'usage:changed', + scope, + (event: { sessionId: string }) => { + if (event.sessionId === rawSessionId) handler(); + }, + ); + }) + .catch(() => undefined); + return () => { + disposed = true; + unsubscribe(); + }; }, /** * What the session's context is made of right now (#2323). @@ -3263,10 +3307,7 @@ const makaBridge = { const scope = await activeRuntimeHostRef(); return { ...state, - activeSessionId: desktopSessionKey({ - hostId: scope.hostId, - sessionId: state.activeSessionId, - }), + activeSessionId: recordRuntimeHostSessionScope(scope, state.activeSessionId), }; }, }, @@ -3287,7 +3328,7 @@ const makaBridge = { return subscribeEveryRuntimeHostEvent('artifacts:changed', (scope, event: ArtifactChangedEvent) => handler({ ...event, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: event.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), }), ); }, @@ -3415,7 +3456,7 @@ const makaBridge = { (scope, payload: { sessionId: string; state: BrowserState }) => handler({ ...payload, - sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: payload.sessionId }), + sessionId: recordRuntimeHostSessionScope(scope, payload.sessionId), }), ); }, @@ -3425,7 +3466,7 @@ const makaBridge = { (scope, payload: { sessionIds: string[] }) => handler({ sessionIds: payload.sessionIds.map((sessionId) => - desktopSessionKey({ hostId: scope.hostId, sessionId }), + recordRuntimeHostSessionScope(scope, sessionId), ), }), ); diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx new file mode 100644 index 0000000000..ef4ff25bd6 --- /dev/null +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -0,0 +1,64 @@ +/* + * 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 { ReactNode } from 'react'; +import { GoalServicesProvider } from '../features/goals'; +import { ModuleHubServicesProvider } from '../features/module-hub'; +import { RuntimeHostManagementServicesProvider } from '../features/runtime-host-management'; +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 { 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'; + +export function createDesktopFeatureServices() { + return { + goal: createDesktopGoalServices(), + moduleHub: createDesktopModuleHubServices(), + runtimeHostManagement: createDesktopRuntimeHostManagementServices(), + sessionNavigation: createDesktopSessionNavigationServices(), + taskEntry: createDesktopTaskEntryServices(), + workbar: createDesktopWorkbarServices(), + }; +} + +export function DesktopFeatureServicesProvider(props: { + readonly services: ReturnType; + readonly children?: ReactNode; +}) { + return ( + + + + + + + {props.children} + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/features/runtime-host-management/index.ts b/apps/desktop/src/renderer/features/runtime-host-management/index.ts new file mode 100644 index 0000000000..0e0eacb49d --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { RuntimeHostPeerMeshDialog } from './ui/runtime-host-peer-mesh-dialog'; +export { PeerMeshPeerIdButton } from './ui/peer-mesh-peer-id-button'; +export { + RuntimeHostPairingRecoveryButton, + RuntimeHostProfileMoreMenu, +} from './ui/runtime-host-profile-pairing-actions'; +export type { RuntimeHostPairingActionCopy } from './ui/runtime-host-profile-pairing-actions'; +export { RuntimeHostManagementServicesProvider } from './services-context'; +export type { RuntimeHostManagementServices } from './ports'; diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts new file mode 100644 index 0000000000..325564d367 --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts @@ -0,0 +1,78 @@ +/* + * 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 { RuntimeHostPeerMeshManagementAction } from '@maka/runtime-host/operator'; +import type { + PeerMeshInvitationResult, + PeerMeshQueryResult, +} from '@maka/runtime-host/protocol'; + +export type PeerMeshTarget = + | { readonly kind: 'desktop' } + | { readonly kind: 'local_host' } + | { readonly kind: 'managed_host'; readonly profileId: string }; + +export interface PeerMeshOperationInput { + readonly meshId?: string | null; + readonly peerId?: string; + readonly invitation?: string; + readonly displayName?: string | null; + readonly operationId?: string; +} + +export interface PeerMeshDirectPeerSnapshot { + readonly state: 'unsupported' | 'not_configured' | 'disabled' | 'enabled'; + readonly peerId?: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; + readonly automaticRelayDiscovery: boolean; + readonly profilePresent: boolean; + readonly profileEnabled: boolean; + readonly clientAvailable: boolean; + readonly managementAvailable: boolean; +} + +export interface PeerMeshServices { + execute( + target: PeerMeshTarget, + action: RuntimeHostPeerMeshManagementAction, + input?: PeerMeshOperationInput, + ): Promise; + cancel(operationId: string): Promise; + getDirectPeer(profileId: string): Promise; + configureDirectPeer( + profileId: string, + enabled: boolean, + coordinationRelays: readonly string[], + automaticRelayDiscovery: boolean, + ): Promise; + copyText(value: string): Promise; + createOperationId(): string; + schedule(callback: () => void, delayMs: number): () => void; +} + +export interface RuntimeHostProfilePairingServices { + retry(profileId?: string): Promise; + discard(profileId: string): Promise; +} + +export interface RuntimeHostManagementServices { + readonly peerMesh: PeerMeshServices; + readonly profilePairing: RuntimeHostProfilePairingServices; +} diff --git a/apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx b/apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx new file mode 100644 index 0000000000..5bb2660de3 --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx @@ -0,0 +1,40 @@ +/* + * 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 { RuntimeHostManagementServices } from './ports.js'; + +const RuntimeHostManagementServicesContext = createContext(null); + +export function RuntimeHostManagementServicesProvider(props: { + readonly services: RuntimeHostManagementServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useRuntimeHostManagementServices(): RuntimeHostManagementServices { + const services = useContext(RuntimeHostManagementServicesContext); + if (!services) throw new Error('RuntimeHostManagementServicesProvider is missing'); + return services; +} diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/peer-mesh-peer-id-button.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/peer-mesh-peer-id-button.tsx new file mode 100644 index 0000000000..10e355ab22 --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/peer-mesh-peer-id-button.tsx @@ -0,0 +1,59 @@ +/* + * 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 { Button, useToast } from '@maka/ui'; +import { useRuntimeHostManagementServices } from '../services-context.js'; + +export function PeerMeshPeerIdButton(props: { + readonly peerId: string; + readonly displayValue: string; + readonly copyLabel: string; + readonly copiedTitle: string; + readonly failedTitle: string; + readonly errorMessage: (error: unknown) => string; + readonly className?: string; +}) { + const services = useRuntimeHostManagementServices(); + const toast = useToast(); + + async function copy(): Promise { + try { + await services.peerMesh.copyText(props.peerId); + toast.success(props.copiedTitle); + } catch (error) { + toast.error(props.failedTitle, props.errorMessage(error)); + } + } + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx new file mode 100644 index 0000000000..fe87f68137 --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx @@ -0,0 +1,1609 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { Banner } from '@astryxdesign/core'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { HStack } from '@astryxdesign/core/Stack'; +import { Tooltip } from '@astryxdesign/core/Tooltip'; +import type { PeerMeshProjection, PeerMeshQueryResult } from '@maka/runtime-host/protocol'; +import { + Badge, + Button, + MoreMenu, + redactSecrets, + Switch, + Text, + TextArea, + TextInput, + useToast, + useUiLocale, +} from '@maka/ui'; +import { + ArrowLeft, + ChevronDown, + Copy, + HelpCircle, + ICON_SIZE, + KeyRound, + Network, + Pencil, + Plus, + RefreshCcw, + Workflow, +} from '@maka/ui/icons'; +import { useRuntimeHostManagementServices } from '../services-context.js'; +import type { + PeerMeshDirectPeerSnapshot, + PeerMeshTarget, +} from '../ports.js'; + +type PeerMeshDialogView = + | { readonly kind: 'overview' } + | { readonly kind: 'join' } + | { + readonly kind: 'invitation'; + readonly meshId: string; + readonly code: string; + readonly expiresAt: number; + readonly hasCoordinationRelay: boolean; + }; + +type PeerMeshWorkingAction = + | 'refresh' + | 'create' + | 'join' + | 'invite' + | 'add-host' + | 'enable-peer' + | 'update' + | 'rename'; + +type ManagedHostPeerSetup = + | { readonly kind: 'idle' } + | { readonly kind: 'loading' } + | { readonly kind: 'ready'; readonly snapshot: PeerMeshDirectPeerSnapshot } + | { readonly kind: 'failed'; readonly message: string }; + +type LocalHostAvailability = + | { readonly kind: 'loading' } + | { readonly kind: 'unavailable' } + | { readonly kind: 'available'; readonly peerId: string }; + +const LOCAL_HOST_TARGET = { kind: 'local_host' } as const; + +export function RuntimeHostPeerMeshDialog(props: { + readonly target: PeerMeshTarget; + readonly targetName: string; + readonly offerLocalHost?: boolean; + readonly onClose: () => void; +}) { + const locale = useUiLocale(); + const copy = peerMeshCopy(locale); + const toast = useToast(); + const services = useRuntimeHostManagementServices().peerMesh; + const [snapshot, setSnapshot] = useState(); + const [localHost, setLocalHost] = useState({ kind: 'loading' }); + const [joinDraft, setJoinDraft] = useState(''); + const [view, setView] = useState({ kind: 'overview' }); + const [error, setError] = useState(); + const [workingAction, setWorkingAction] = useState(); + const [managedHostPeerSetup, setManagedHostPeerSetup] = useState( + props.target.kind === 'managed_host' ? { kind: 'loading' } : { kind: 'idle' }, + ); + const working = workingAction !== undefined; + const activeOperationId = useRef(undefined); + const cancelledOperationId = useRef(undefined); + const statusOperationIds = useRef(new Set()); + const refreshSequence = useRef(0); + const closed = useRef(false); + const [selectedEndpoint, setSelectedEndpoint] = useState<'desktop' | 'local_host'>( + props.target.kind === 'desktop' ? 'desktop' : 'local_host', + ); + const canSelectLocalHost = props.target.kind === 'desktop' && props.offerLocalHost === true; + const activeTarget = + canSelectLocalHost && selectedEndpoint === 'local_host' ? LOCAL_HOST_TARGET : props.target; + const offerLocalHost = canSelectLocalHost && selectedEndpoint === 'desktop'; + const managedProfileId = + activeTarget.kind === 'managed_host' ? activeTarget.profileId : undefined; + + const executeStatus = useCallback(async (target: PeerMeshTarget) => { + const operationId = services.createOperationId(); + statusOperationIds.current.add(operationId); + const cancelDeadline = services.schedule(() => { + void services.cancel(operationId); + }, 10_000); + try { + return await services.execute(target, 'status', { operationId }); + } finally { + cancelDeadline(); + statusOperationIds.current.delete(operationId); + } + }, [services]); + + const cancelStatusOperations = useCallback(() => { + for (const operationId of statusOperationIds.current) { + void services.cancel(operationId); + } + statusOperationIds.current.clear(); + }, [services]); + + const inspectManagedHostPeer = useCallback(async (profileId: string) => { + setManagedHostPeerSetup({ kind: 'loading' }); + try { + const directPeer = await services.getDirectPeer(profileId); + if (!closed.current) { + setManagedHostPeerSetup({ kind: 'ready', snapshot: directPeer }); + } + } catch (failure) { + if (!closed.current) { + setManagedHostPeerSetup({ + kind: 'failed', + message: peerMeshErrorMessage(failure, copy.unknownError), + }); + } + } + }, [copy.unknownError, services]); + + const refresh = useCallback(async () => { + if (closed.current) return; + const sequence = ++refreshSequence.current; + const [result, localHost] = await Promise.all([ + executeStatus(activeTarget), + offerLocalHost + ? executeStatus(LOCAL_HOST_TARGET).then( + (value) => ({ kind: 'result' as const, value }), + () => ({ kind: 'failed' as const }), + ) + : undefined, + ]); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (closed.current || sequence !== refreshSequence.current) return; + setSnapshot(result); + setError(undefined); + if (offerLocalHost) { + setLocalHost( + localHost?.kind === 'result' && isSnapshot(localHost.value) && localHost.value.localPeerId + ? { kind: 'available', peerId: localHost.value.localPeerId } + : { kind: 'unavailable' }, + ); + } + }, [activeTarget, copy.invalidResult, executeStatus, offerLocalHost]); + + useEffect(() => { + closed.current = false; + let disposed = false; + void refresh().catch((failure) => { + if (!disposed) { + if (offerLocalHost) setLocalHost({ kind: 'unavailable' }); + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + }); + return () => { + disposed = true; + closed.current = true; + refreshSequence.current += 1; + cancelStatusOperations(); + const operationId = activeOperationId.current; + if (operationId) void services.cancel(operationId); + }; + }, [cancelStatusOperations, copy.unknownError, offerLocalHost, refresh]); + + useEffect(() => { + if (view.kind !== 'overview' || working) return; + let disposed = false; + let cancelTimer: (() => void) | undefined; + const poll = async () => { + try { + await refresh(); + } catch (failure) { + if (!disposed) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } finally { + if (!disposed) cancelTimer = services.schedule(() => void poll(), 15_000); + } + }; + cancelTimer = services.schedule(() => void poll(), 15_000); + return () => { + disposed = true; + cancelTimer?.(); + cancelStatusOperations(); + }; + }, [cancelStatusOperations, copy.unknownError, refresh, services, view.kind, working]); + + useEffect(() => { + if (!managedProfileId) { + setManagedHostPeerSetup({ kind: 'idle' }); + return; + } + if (snapshot?.available === true) { + setManagedHostPeerSetup({ kind: 'idle' }); + return; + } + if (snapshot?.available !== false) return; + void inspectManagedHostPeer(managedProfileId); + }, [inspectManagedHostPeer, managedProfileId, snapshot?.available]); + + async function runOperation( + action: PeerMeshWorkingAction, + operation: (operationId: string) => Promise, + ): Promise { + if (closed.current) return false; + const operationId = services.createOperationId(); + activeOperationId.current = operationId; + setWorkingAction(action); + setError(undefined); + let completed = false; + let cancelled = false; + try { + await operation(operationId); + completed = true; + } catch (failure) { + if (!closed.current && cancelledOperationId.current !== operationId) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } finally { + cancelled = cancelledOperationId.current === operationId; + if (activeOperationId.current === operationId) activeOperationId.current = undefined; + if (cancelled) cancelledOperationId.current = undefined; + if (!closed.current) setWorkingAction(undefined); + } + return completed && !cancelled && !closed.current; + } + + async function refreshNow(): Promise { + if (working || closed.current) return; + setWorkingAction('refresh'); + setError(undefined); + try { + await refresh(); + } catch (failure) { + if (!closed.current) setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + if (!closed.current) setWorkingAction(undefined); + } + } + + function cancelOperation(): void { + const operationId = activeOperationId.current; + if (operationId) { + cancelledOperationId.current = operationId; + void services.cancel(operationId); + } + } + + function requestClose(): void { + closed.current = true; + if (working) cancelOperation(); + cancelStatusOperations(); + props.onClose(); + } + + async function createMesh(): Promise { + await runOperation('create', async (operationId) => { + const previousMeshIds = new Set(snapshot?.meshes.map(({ meshId }) => meshId)); + const result = await services.execute(activeTarget, 'create', { + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + const created = result.meshes.find(({ meshId }) => !previousMeshIds.has(meshId)); + if (created && offerLocalHost) { + await joinLocalHost(created.meshId, operationId); + await refresh(); + } + }); + } + + async function join(): Promise { + await runOperation('join', async (operationId) => { + const result = await services.execute(activeTarget, 'join', { + invitation: joinDraft.trim(), + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setJoinDraft(''); + setView({ kind: 'overview' }); + setSnapshot(result); + }); + } + + async function createInvitation(meshId: string): Promise { + await runOperation('invite', async (operationId) => { + const result = await services.execute(activeTarget, 'invite', { + meshId, + operationId, + }); + if (!isInvitationResult(result)) throw new Error(copy.invalidResult); + setView({ + kind: 'invitation', + meshId, + code: JSON.stringify(result.invitation), + expiresAt: result.invitation.expiresAt, + hasCoordinationRelay: result.invitation.coordinationRelays.length > 0, + }); + setSnapshot(result.snapshot); + }); + } + + async function addLocalHost(meshId: string): Promise { + await runOperation('add-host', async (operationId) => { + await joinLocalHost(meshId, operationId); + await refresh(); + }); + } + + async function enableManagedHostPeer(): Promise { + if (!managedProfileId || working || closed.current) return; + setWorkingAction('enable-peer'); + setError(undefined); + try { + const directPeer = await services.configureDirectPeer( + managedProfileId, + true, + [], + true, + ); + if (closed.current) return; + setManagedHostPeerSetup({ kind: 'ready', snapshot: directPeer }); + await refresh(); + } catch (failure) { + if (!closed.current) setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + if (!closed.current) setWorkingAction(undefined); + } + } + + async function joinLocalHost(meshId: string, operationId: string): Promise { + const prepared = await services.execute(activeTarget, 'invite', { + meshId, + operationId, + }); + if (!isInvitationResult(prepared)) throw new Error(copy.invalidResult); + if (cancelledOperationId.current === operationId) { + throw new Error('Peer Mesh operation was cancelled'); + } + const joined = await services.execute( + LOCAL_HOST_TARGET, + 'join', + { invitation: JSON.stringify(prepared.invitation), operationId }, + ); + if (!isSnapshot(joined)) throw new Error(copy.invalidResult); + } + + async function mutate( + action: 'remove' | 'leave' | 'close', + meshId: string, + peerId?: string, + ): Promise { + const confirmed = await toast.confirm({ + title: + action === 'close' + ? copy.closeConfirm + : action === 'leave' + ? copy.leaveConfirm + : copy.removeConfirm, + confirmLabel: + action === 'close' ? copy.closeMesh : action === 'leave' ? copy.leave : copy.remove, + cancelLabel: copy.cancel, + destructive: action !== 'leave', + }); + if (!confirmed) return; + await runOperation('update', async (operationId) => { + const result = await services.execute(activeTarget, action, { + meshId, + peerId, + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }); + } + + async function setTransit(meshId: string, enabled: boolean): Promise { + await runOperation('update', async (operationId) => { + const result = await services.execute(activeTarget, 'transit', { + meshId: enabled ? meshId : null, + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }); + } + + async function copyInvitation(): Promise { + if (view.kind !== 'invitation') return; + try { + await services.copyText(view.code); + toast.success(copy.invitationCopied); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + + async function rename(displayName: string | null): Promise { + const completed = await runOperation('rename', async (operationId) => { + const result = await services.execute(activeTarget, 'rename', { + displayName, + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }); + if (!completed) throw new Error('Peer Mesh rename did not complete'); + } + + async function renameMesh(meshId: string, displayName: string | null): Promise { + const completed = await runOperation('rename', async (operationId) => { + const result = await services.execute(activeTarget, 'rename-mesh', { + meshId, + displayName, + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }); + if (!completed) throw new Error('Peer Mesh rename did not complete'); + } + + async function copyPeerId(peerId: string): Promise { + try { + await services.copyText(peerId); + toast.success(copy.peerIdCopied); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + + async function copyMeshId(meshId: string): Promise { + try { + await services.copyText(meshId); + toast.success(copy.meshIdCopied); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + + return ( + { + if (!open) requestClose(); + }} + purpose="form" + width={680} + maxHeight="calc(100dvh - 64px)" + > + } + onOpenChange={(open) => { + if (!open) requestClose(); + }} + /> + } + content={ + +
+ {canSelectLocalHost ? ( +
+ { + refreshSequence.current += 1; + setSelectedEndpoint(value as 'desktop' | 'local_host'); + setSnapshot(undefined); + setLocalHost({ kind: 'loading' }); + setJoinDraft(''); + setView({ kind: 'overview' }); + setError(undefined); + }} + > + + + + + {selectedEndpoint === 'local_host' + ? copy.hostEndpointHelp + : copy.desktopEndpointHelp} + +
+ ) : null} + {workingAction ? ( + + ) + } + /> + ) : null} + {error ? : null} + {view.kind === 'invitation' ? ( + + ) : view.kind === 'join' ? ( + + ) : ( + void createInvitation(meshId)} + onRemove={(meshId, peerId) => void mutate('remove', meshId, peerId)} + onLeave={(meshId) => void mutate('leave', meshId)} + onClose={(meshId) => void mutate('close', meshId)} + onJoin={() => setView({ kind: 'join' })} + onCreate={() => void createMesh()} + onRefresh={() => void refreshNow()} + onSetTransit={(meshId, enabled) => void setTransit(meshId, enabled)} + onRename={rename} + onRenameMesh={renameMesh} + onCopyPeerId={(peerId) => void copyPeerId(peerId)} + onCopyMeshId={(meshId) => void copyMeshId(meshId)} + localHost={localHost} + onAddLocalHost={offerLocalHost ? (meshId) => void addLocalHost(meshId) : undefined} + managedHostPeerSetup={managedHostPeerSetup} + onEnableManagedHostPeer={() => void enableManagedHostPeer()} + onInspectManagedHostPeer={ + managedProfileId + ? () => void inspectManagedHostPeer(managedProfileId) + : undefined + } + /> + )} +
+
+ } + footer={ + view.kind === 'overview' ? undefined : ( + + +
+ ); +} + +function Overview(props: { + readonly snapshot: PeerMeshQueryResult | undefined; + readonly copy: ReturnType; + readonly working: boolean; + readonly localPeerLabel: string; + readonly onInvite: (meshId: string) => void; + readonly onRemove: (meshId: string, peerId: string) => void; + readonly onLeave: (meshId: string) => void; + readonly onClose: (meshId: string) => void; + readonly onJoin: () => void; + readonly onCreate: () => void; + readonly onRefresh: () => void; + readonly onSetTransit: (meshId: string, enabled: boolean) => void; + readonly onRename: (displayName: string | null) => Promise; + readonly onRenameMesh: (meshId: string, displayName: string | null) => Promise; + readonly onCopyPeerId: (peerId: string) => void; + readonly onCopyMeshId: (meshId: string) => void; + readonly localHost: LocalHostAvailability; + readonly onAddLocalHost?: (meshId: string) => void; + readonly managedHostPeerSetup: ManagedHostPeerSetup; + readonly onEnableManagedHostPeer: () => void; + readonly onInspectManagedHostPeer?: () => void; +}) { + const { snapshot, copy } = props; + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(''); + if (!snapshot) { + return ( + + {copy.loading} + + ); + } + if (!snapshot.available) { + return ( + + ); + } + return ( + <> +
+ +
+ + {props.localPeerLabel} + + {snapshot.localDisplayName ? ( + + {snapshot.localDisplayName} + + ) : null} + {snapshot.localPeerId ? ( + + ) : ( + + )} +
+
+ {editingName ? ( +
+ + +
+ ) : null} + {snapshot.meshes.length === 0 ? ( +
+ + + {copy.empty} + + + {copy.emptyHint} + + +
+ ) : ( + <> +
+
+ + {copy.meshes} + + + {copy.meshCount(snapshot.meshes.length)} + +
+ +
+
+ {snapshot.meshes.map((mesh) => ( + props.onInvite(mesh.meshId)} + onRemove={(peerId) => props.onRemove(mesh.meshId, peerId)} + onLeave={() => props.onLeave(mesh.meshId)} + onClose={() => props.onClose(mesh.meshId)} + onSetTransit={(enabled) => props.onSetTransit(mesh.meshId, enabled)} + onRename={(displayName) => props.onRenameMesh(mesh.meshId, displayName)} + onCopyPeerId={props.onCopyPeerId} + onCopyMeshId={props.onCopyMeshId} + localPeerLabel={props.localPeerLabel} + localHost={props.localHost} + onAddLocalHost={ + props.onAddLocalHost + ? () => props.onAddLocalHost?.(mesh.meshId) + : undefined + } + /> + ))} +
+ + )} + + ); +} + +function UnavailableEndpoint(props: { + readonly setup: ManagedHostPeerSetup; + readonly working: boolean; + readonly copy: ReturnType; + readonly onEnable: () => void; + readonly onInspect?: () => void; + readonly onRefresh: () => void; +}) { + const { copy, setup } = props; + if (setup.kind === 'loading') { + return ; + } + if (setup.kind === 'failed') { + return ( + + )} + /> + ); + } + if (setup.kind === 'idle') { + return ; + } + const directPeer = setup.snapshot; + if (!directPeer.managementAvailable) { + return ( + + ); + } + if (directPeer.state === 'enabled') { + return ( + + )} + /> + ); + } + return ( + + )} + /> + ); +} + +function JoinView(props: { + readonly value: string; + readonly working: boolean; + readonly copy: ReturnType; + readonly onChange: (value: string) => void; +}) { + return ( +
+
+ +
+ + {props.copy.joinTitle} + + + {props.copy.joinHint} + +
+
+