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 c5a918c24f..a08f663a00 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 @@ -29,6 +29,7 @@ import { encodeRuntimeHostAccessManagementFrame, encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, + encodeRuntimeHostPeerMeshManagementFrame, runtimeHostAccessCredentialFingerprint, RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, RUNTIME_HOST_SETUP_FRAME_PREFIX, @@ -637,6 +638,62 @@ test('keeps a prepared access credential out of the SSH terminal projection', as await harness.terminal.close(); }); +test('sends a Mesh invitation only after the authenticated remote operator requests it', async () => { + const harness = createHarness('pending'); + const invitation = JSON.stringify({ secret: 'one-time-mesh-secret' }); + const management = harness.terminal.runPeerMeshManagement({ + destination: 'operator@example.com', + operatorPath: '/home/operator/.local/share/maka/operator', + action: 'join', + invitation, + expectedTarget: { + serviceId: 'b'.repeat(64), + rootPath: '/srv/maka', + rootId: 'a'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', + }, + }); + await waitFor(() => harness.pty.hasDataListener()); + const command = harness.launchArgs.at(-1)?.at(-1) ?? ''; + assert.match(command, /mesh.*join.*--framed/u); + assert.doesNotMatch(command, /one-time-mesh-secret/u); + assert.deepEqual(harness.pty.writes, []); + + harness.pty.emitData( + encodeRuntimeHostPeerMeshManagementFrame({ kind: 'input', action: 'join' }), + ); + assert.deepEqual(harness.pty.writes, [`${invitation}\r`]); + harness.pty.emitData( + encodeRuntimeHostPeerMeshManagementFrame({ + kind: 'result', + action: 'join', + result: { + localPeerId: 'peer-b', + available: true, + meshes: [ + { + meshId: 'mesh-id', + role: 'member', + authorityPeerId: 'peer-a', + revision: 2, + closed: false, + members: [ + { peerId: 'peer-a', state: 'route_available', expiresAt: Date.now() + 60_000 }, + { peerId: 'peer-b', state: 'local' }, + ], + pendingInvitationCount: 0, + }, + ], + }, + }), + ); + harness.pty.exit(0); + + assert.equal((await management).kind, 'result'); + assert.doesNotMatch(JSON.stringify(harness.events), /one-time-mesh-secret/u); + await harness.terminal.close(); +}); + test('rejects a framed service result for a different action', async () => { const harness = createHarness('pending'); const management = harness.terminal.runServiceManagement({ @@ -886,6 +943,7 @@ class FakePty { deferKill = false; exitOnForceKill = false; readonly killSignals: Array = []; + readonly writes: string[] = []; readonly #dataListeners = new Set<(data: string) => void>(); readonly #exitListeners = new Set<(event: { exitCode: number; signal: number }) => void>(); #resolveExit!: () => void; @@ -922,7 +980,9 @@ class FakePty { this.#resolveExit(); } - write(): void {} + write(data: string): void { + this.writes.push(data); + } resize(): void {} kill(signal?: string): void { this.killSignals.push(signal); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 5476d7e02f..3673f39b43 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -53,6 +53,7 @@ import { loadOrCreateRuntimeHostClientInstanceId, listRuntimeHostWslDistributions, } from "@maka/runtime-host/client"; +import { openRuntimeHostPeerMeshOwner } from '@maka/runtime-host/peer-mesh'; import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; import { runtimeHostProfileUsesHostWorkspace } from "@maka/runtime-host/profile-kind"; import { createCredentialMcpOAuthStorage, McpClientManager } from "@maka/mcp"; @@ -181,6 +182,7 @@ import { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-oper import { createDesktopLocalRuntimeHostRemoteAccess } from './runtime-host-local-remote-access.js'; import { createDesktopRuntimeHostOnboarding } from "./runtime-host-onboarding.js"; import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js"; +import { createDesktopRuntimeHostPeerMeshManagement } from './runtime-host-peer-mesh-management.js'; import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js"; import { RuntimeHostOAuthPresentation } from "./runtime-host-oauth-presentation.js"; import { registerRuntimeHostPermissionsIpc } from "./runtime-host-permissions-ipc-main.js"; @@ -221,15 +223,35 @@ await resolveShellEnv(); const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000; const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); const userDataDir = app.getPath("userData"); -const runtimeHostDirectPeerAvailable = await configureDesktopRuntimeHostPeerClient({ +const runtimeHostPeerConfiguration = await configureDesktopRuntimeHostPeerClient({ isPackaged: app.isPackaged, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, clientDataRoot: userDataDir, }); -const runtimeHostPeerClient = runtimeHostDirectPeerAvailable - ? createRuntimeHostPeerClientFromEnvironment() - : undefined; +let runtimeHostPeerOwner: Awaited> | undefined; +let runtimeHostPeerMesh: Awaited>['mesh'] | undefined; +let runtimeHostPeerClient: + | ReturnType + | undefined; +if (runtimeHostPeerConfiguration) { + try { + runtimeHostPeerOwner = await openRuntimeHostPeerMeshOwner({ + ...runtimeHostPeerConfiguration, + dataRoot: join(userDataDir, 'peer-mesh'), + }); + runtimeHostPeerClient = runtimeHostPeerOwner.client; + runtimeHostPeerMesh = runtimeHostPeerOwner.mesh; + void runtimeHostPeerOwner.closed.catch((error) => { + runtimeHostPeerMesh = undefined; + console.error('[runtime-host] Peer Mesh stopped; Direct peer remains available:', error); + }); + } catch (error) { + console.error('[runtime-host] Peer Mesh is unavailable; continuing with Direct peer:', error); + runtimeHostPeerClient = createRuntimeHostPeerClientFromEnvironment(); + } +} +const runtimeHostDirectPeerAvailable = runtimeHostPeerClient !== undefined; const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId( join(userDataDir, "runtime-host-client.json"), ); @@ -542,6 +564,12 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, }); +const runtimeHostPeerMeshManagement = createDesktopRuntimeHostPeerMeshManagement({ + ipcMain, + localMesh: () => runtimeHostPeerMesh, + profiles: runtimeHostProfileService, + runRemote: runtimeHostSshTerminal.runPeerMeshManagement, +}); const defaultRuntimeHostRecovery = createRuntimeHostDefaultRecovery({ defaultProfileId: () => runtimeHostManager?.defaultProfileId() ?? @@ -1646,7 +1674,9 @@ async function closeRuntimeHostDesktop(): Promise { permissionOverlay.dismiss(); const results = await Promise.allSettled([ Promise.resolve().then(() => runtimeHostManagement.close()), + Promise.resolve().then(() => runtimeHostPeerMeshManagement.close()), runtimeHostManager?.close(), + runtimeHostPeerOwner?.close() ?? runtimeHostPeerClient?.close(), runtimeHostOnboarding.close(), localRuntimeHostRemoteAccess.close(), runtimeHostSetupPackage.close(), diff --git a/apps/desktop/src/main/runtime-host-peer-client.ts b/apps/desktop/src/main/runtime-host-peer-client.ts index 395e194f93..e674802116 100644 --- a/apps/desktop/src/main/runtime-host-peer-client.ts +++ b/apps/desktop/src/main/runtime-host-peer-client.ts @@ -28,11 +28,15 @@ export async function configureDesktopRuntimeHostPeerClient(input: { readonly resourcesPath: string; readonly clientDataRoot: string; readonly environment?: NodeJS.ProcessEnv; -}): Promise { +}): Promise<{ readonly nativePath: string; readonly keyPath: string } | undefined> { const environment = input.environment ?? process.env; const explicitNativePath = environment.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH?.trim(); const explicitKeyPath = environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH?.trim(); - if (explicitNativePath || explicitKeyPath) return Boolean(explicitNativePath && explicitKeyPath); + if (explicitNativePath || explicitKeyPath) { + return explicitNativePath && explicitKeyPath + ? { nativePath: explicitNativePath, keyPath: explicitKeyPath } + : undefined; + } const nativePath = input.isPackaged ? join(input.resourcesPath, 'runtime-host-peer', NATIVE_FILE) : join( @@ -48,12 +52,10 @@ export async function configureDesktopRuntimeHostPeerClient(input: { try { await access(nativePath); } catch { - return false; + return undefined; } environment.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH = nativePath; - environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH = join( - input.clientDataRoot, - 'runtime-host-client.peer.key', - ); - return true; + const keyPath = join(input.clientDataRoot, 'runtime-host-client.peer.key'); + environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH = keyPath; + return { nativePath, keyPath }; } diff --git a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts new file mode 100644 index 0000000000..6ce22d79a7 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts @@ -0,0 +1,196 @@ +/* + * 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 { IpcMain } from 'electron'; +import type { PeerMeshNode } from '@maka/runtime-host/peer-mesh'; +import { + decodePeerMeshInvitation, + type PeerMeshInvitationResult, + type PeerMeshQueryResult, +} from '@maka/runtime-host/protocol'; +import { projectPeerMeshStatus } from '@maka/runtime-host/server'; +import type { + DesktopRuntimeHostPeerMeshTarget, +} from '../preload/bridge-contract.js'; +import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; +import type { + DesktopRuntimeHostSshPeerMeshManagementInput, + createDesktopRuntimeHostSshTerminal, +} from './runtime-host-ssh-terminal.js'; + +type SshTerminal = ReturnType; +type PeerMeshAction = DesktopRuntimeHostSshPeerMeshManagementInput['action']; + +export function createDesktopRuntimeHostPeerMeshManagement(input: { + readonly ipcMain: Pick; + readonly localMesh?: () => PeerMeshNode | undefined; + readonly profiles: Pick; + readonly runRemote: SshTerminal['runPeerMeshManagement']; +}): { close(): void } { + const execute = async ( + targetValue: unknown, + actionValue: unknown, + meshIdValue?: unknown, + peerIdValue?: unknown, + invitationValue?: unknown, + ): Promise => { + const target = requireTarget(targetValue); + const action = requireAction(actionValue); + const meshId = actionNeedsMesh(action) ? requireIdentifier(meshIdValue, 'Mesh ID') : undefined; + const peerId = action === 'remove' ? requireIdentifier(peerIdValue, 'Peer ID') : undefined; + const invitation = action === 'join' ? requireInvitation(invitationValue) : undefined; + if (target.kind === 'desktop') { + return executeLocal(input.localMesh?.(), action, meshId, peerId, invitation); + } + const managed = await input.profiles.resolveManagedService(target.profileId); + if ( + !managed || + managed.state !== 'active' || + managed.profile.transport.kind !== 'ssh' || + !managed.deployment.deploymentId + ) { + 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, + action, + expectedTarget: { + serviceId: managed.deployment.id, + rootPath: managed.deployment.rootPath, + rootId: managed.profile.rootId, + deploymentId: managed.deployment.deploymentId, + }, + ...(meshId ? { meshId } : {}), + ...(peerId ? { peerId } : {}), + ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), + }); + 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), + ); + return { close: () => input.ipcMain.removeHandler(channel) }; +} + +async function executeLocal( + mesh: PeerMeshNode | undefined, + action: PeerMeshAction, + meshId: string | undefined, + peerId: string | undefined, + invitation: ReturnType | undefined, +): Promise { + if (!mesh) { + if (action === 'status') return { available: false, meshes: [] }; + throw new Error('This Desktop build does not include Direct peer support'); + } + const snapshot = (): PeerMeshQueryResult => ({ + available: true, + localPeerId: mesh.localPeerId(), + meshes: mesh.status().map(projectPeerMeshStatus), + }); + switch (action) { + case 'status': + return snapshot(); + case 'create': + await mesh.create(); + return snapshot(); + case 'invite': { + const created = await mesh.invite(requiredValue(meshId, 'Mesh ID')); + return { invitation: created, snapshot: snapshot() }; + } + case 'join': + await mesh.join(requiredValue(invitation, 'Peer Mesh invitation')); + 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')); + return snapshot(); + case 'close': + await mesh.closeMesh(requiredValue(meshId, 'Mesh ID')); + return snapshot(); + case 'reconcile': + await mesh.reconcile(); + return snapshot(); + } +} + +function requiredValue(value: T | undefined, label: string): T { + if (value === undefined) throw new Error(`${label} is required`); + return value; +} + +function requireTarget(value: unknown): DesktopRuntimeHostPeerMeshTarget { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Peer Mesh target is invalid'); + } + const record = value as Record; + if (record.kind === 'desktop' && Object.keys(record).length === 1) return { kind: 'desktop' }; + if ( + record.kind === 'managed_host' && + Object.keys(record).length === 2 && + typeof record.profileId === 'string' && + record.profileId.length > 0 && + record.profileId.length <= 128 + ) { + return { kind: 'managed_host', profileId: record.profileId }; + } + throw new Error('Peer Mesh target is invalid'); +} + +function requireAction(value: unknown): PeerMeshAction { + if ( + value === 'status' || value === 'create' || value === 'invite' || value === 'join' || + value === 'remove' || value === 'leave' || value === 'close' || value === 'reconcile' + ) return value; + throw new Error('Peer Mesh action is invalid'); +} + +function actionNeedsMesh(action: PeerMeshAction): boolean { + return action === 'invite' || action === 'remove' || action === 'leave' || action === 'close'; +} + +function requireIdentifier(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 256) { + throw new Error(`${label} 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'); + } + try { + return decodePeerMeshInvitation(JSON.parse(value) as unknown); + } catch { + throw new Error('Peer Mesh invitation is invalid'); + } +} diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 97b133c390..daa5b28754 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -39,6 +39,7 @@ import { decodeRuntimeHostActivationFrame, decodeRuntimeHostAccessManagementFrame, decodeRuntimeHostPeerManagementFrame, + decodeRuntimeHostPeerMeshManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, RUNTIME_HOST_ACTIVATION_FRAME_MAX_BYTES, @@ -49,6 +50,8 @@ import { RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, 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, type RuntimeHostAccessManagementFrame, @@ -56,6 +59,8 @@ import { type RuntimeHostManagedUpdatePolicy, type RuntimeHostPeerManagementAction, type RuntimeHostPeerManagementFrame, + type RuntimeHostPeerMeshManagementAction, + type RuntimeHostPeerMeshManagementFrame, type RuntimeHostOperatorCapability, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, @@ -173,6 +178,18 @@ export interface DesktopRuntimeHostSshPeerManagementInput { readonly signal?: AbortSignal; } +export interface DesktopRuntimeHostSshPeerMeshManagementInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly action: RuntimeHostPeerMeshManagementAction; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly meshId?: string; + readonly peerId?: string; + readonly invitation?: string; + readonly signal?: AbortSignal; +} + export interface DesktopRuntimeHostSshCleanupInput { readonly destination: string; readonly sshPort?: number; @@ -271,6 +288,9 @@ export function createDesktopRuntimeHostSshTerminal(input: { runPeerManagement( input: DesktopRuntimeHostSshPeerManagementInput, ): Promise; + runPeerMeshManagement( + input: DesktopRuntimeHostSshPeerMeshManagementInput, + ): Promise>; cleanupManagedDeployment(input: DesktopRuntimeHostSshCleanupInput): Promise; close(): Promise; } { @@ -510,6 +530,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { readonly frameAction: (frame: Frame) => string; readonly isTerminalFrame?: (frame: Frame) => boolean; readonly onProgress?: (frame: Frame) => void; + readonly inputLine?: string; readonly label: string; readonly timeoutMs?: number; }): Promise => { @@ -521,6 +542,12 @@ export function createDesktopRuntimeHostSshTerminal(input: { let failure: Error | undefined; let activeTerminal: ActiveTerminal | undefined; let receivedProgress = false; + let inputSent = false; + const sendInput = () => { + if (inputSent || options.inputLine === undefined || !activeTerminal) return; + inputSent = true; + activeTerminal.pty.write(`${options.inputLine}\r`); + }; const filter = createRuntimeHostFramedOutputFilter({ prefix: options.prefix, pendingMaxBytes: options.pendingMaxBytes, @@ -536,6 +563,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { receivedProgress = true; if (activeTerminal) suppressPresentation(activeTerminal); options.onProgress?.(next); + sendInput(); return; } if (frame) { @@ -556,6 +584,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { true, ); activeTerminal = terminal; + if (receivedProgress) sendInput(); if (frame) completePresentation(terminal); else if (receivedProgress) suppressPresentation(terminal); const wait = await waitForTerminalProcess(process, { @@ -861,6 +890,24 @@ export function createDesktopRuntimeHostSshTerminal(input: { frameAction: (frame) => frame.action, label: 'Remote Runtime Host direct-peer management', }), + runPeerMeshManagement: async (meshInput) => { + const frame = await runFramedManagement({ + ...meshInput, + remoteCommand: runtimeHostPeerMeshManagementRemoteCommand(meshInput), + prefix: RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: RUNTIME_HOST_PEER_MESH_MANAGEMENT_FRAME_MAX_BYTES, + decode: decodeRuntimeHostPeerMeshManagementFrame, + action: meshInput.action, + frameAction: (candidate) => candidate.action, + isTerminalFrame: (candidate) => candidate.kind !== 'input', + ...(meshInput.invitation ? { inputLine: meshInput.invitation } : {}), + label: 'Remote Runtime Host Peer Mesh management', + }); + if (frame.kind === 'input') { + throw new Error('Remote Runtime Host Peer Mesh management ended before its result'); + } + return frame; + }, cleanupManagedDeployment: async (cleanupInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); cleanupInput.signal?.throwIfAborted(); @@ -1306,6 +1353,21 @@ function runtimeHostPeerManagementRemoteCommand( return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; } +function runtimeHostPeerMeshManagementRemoteCommand( + input: DesktopRuntimeHostSshPeerMeshManagementInput, +): string { + const command = [ + input.operatorPath, + 'mesh', + input.action, + '--framed', + ...(input.meshId ? ['--mesh', input.meshId] : []), + ...(input.peerId ? ['--peer', input.peerId] : []), + ...managedServiceTargetArgs(input.expectedTarget), + ].map(quotePosix).join(' '); + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; +} + function runtimeHostManagedDeploymentCleanupRemoteCommand( input: DesktopRuntimeHostSshCleanupInput, ): string { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 3bfc79a6ce..e5f9f1e5f1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -520,6 +520,17 @@ export interface DesktopRuntimeHostDirectPeerSnapshot { readonly managementAvailable: boolean; } +export type DesktopRuntimeHostPeerMeshTarget = + | { readonly kind: 'desktop' } + | { readonly kind: 'managed_host'; readonly profileId: string }; + +export type DesktopRuntimeHostPeerMeshAction = + import('@maka/runtime-host/operator').RuntimeHostPeerMeshManagementAction; + +export type DesktopRuntimeHostPeerMeshResult = + | import('@maka/runtime-host/protocol').PeerMeshQueryResult + | import('@maka/runtime-host/protocol').PeerMeshInvitationResult; + type RuntimeHostUpdatePolicyResult = Extract< RuntimeHostServiceManagementFrame, { kind: 'result'; action: 'update_policy' } @@ -729,6 +740,14 @@ export interface MakaBridge { ): Promise; }; + runtimeHostPeerMesh: { + execute( + target: DesktopRuntimeHostPeerMeshTarget, + action: DesktopRuntimeHostPeerMeshAction, + input?: { readonly meshId?: string; readonly peerId?: string; readonly invitation?: string }, + ): Promise; + }; + newTasks: { getCatalog(): Promise; subscribeChanges(handler: () => void): () => void; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 155ac853c6..63c7344366 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1374,6 +1374,22 @@ const makaBridge = { ); }, }, + runtimeHostPeerMesh: { + execute( + target: import('./bridge-contract.js').DesktopRuntimeHostPeerMeshTarget, + action: import('./bridge-contract.js').DesktopRuntimeHostPeerMeshAction, + input: { readonly meshId?: string; readonly peerId?: string; readonly invitation?: string } = {}, + ) { + return ipcRenderer.invoke( + 'runtime-host-peer-mesh:execute', + target, + action, + input.meshId, + input.peerId, + input.invitation, + ); + }, + }, newTasks: { getCatalog(): Promise { return loadNewTaskCatalog(); diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 048f63fbca..84ed7af676 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -138,6 +138,9 @@ export type SettingsProjectsCopy = { directPeerDisable: string; directPeerAddProfile: string; directPeerActionFailed: string; + peerMesh: string; + peerMeshHelp: string; + managePeerMesh: string; installedVersion: string; operatingSystem: string; processId: string; @@ -424,6 +427,9 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { directPeerDisable: '停用', directPeerAddProfile: '添加到 Desktop', directPeerActionFailed: 'Direct peer 操作失败', + peerMesh: 'Peer Mesh', + peerMeshHelp: '管理本 Desktop peer 的私有 Mesh membership 和邀请', + managePeerMesh: '管理 Peer Mesh', installedVersion: '版本', operatingSystem: '系统', processId: '进程 ID', @@ -710,6 +716,9 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { directPeerDisable: 'Disable', directPeerAddProfile: 'Add to Desktop', directPeerActionFailed: 'Direct peer action failed', + peerMesh: 'Peer Mesh', + peerMeshHelp: 'Manage private Mesh memberships and invitations for this Desktop peer', + managePeerMesh: 'Manage Peer Mesh', installedVersion: 'Version', operatingSystem: 'System', processId: 'Process ID', diff --git a/apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx new file mode 100644 index 0000000000..35054fc1a2 --- /dev/null +++ b/apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx @@ -0,0 +1,705 @@ +/* + * 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, useState } from 'react'; +import { Banner } 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 type { PeerMeshProjection, PeerMeshQueryResult } from '@maka/runtime-host/protocol'; +import { + Badge, + Button, + MoreMenu, + redactSecrets, + Text, + TextArea, + useToast, + useUiLocale, +} from '@maka/ui'; +import { ArrowLeft, Copy, ICON_SIZE, KeyRound, Network, Plus, RefreshCcw } from '@maka/ui/icons'; +import type { DesktopRuntimeHostPeerMeshTarget } from '../../preload/bridge-contract.js'; + +type PeerMeshDialogView = + | { readonly kind: 'overview' } + | { readonly kind: 'join' } + | { + readonly kind: 'invitation'; + readonly meshId: string; + readonly code: string; + readonly expiresAt: number; + }; + +export function RuntimeHostPeerMeshDialog(props: { + readonly target: DesktopRuntimeHostPeerMeshTarget; + readonly targetName: string; + readonly onClose: () => void; +}) { + const locale = useUiLocale(); + const copy = peerMeshCopy(locale); + const toast = useToast(); + const [snapshot, setSnapshot] = useState(); + const [joinDraft, setJoinDraft] = useState(''); + const [view, setView] = useState({ kind: 'overview' }); + const [error, setError] = useState(); + const [working, setWorking] = useState(false); + + const refresh = useCallback(async () => { + const result = await window.maka.runtimeHostPeerMesh.execute(props.target, 'status'); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }, [copy.invalidResult, props.target]); + + useEffect(() => { + void refresh().catch((failure) => setError(peerMeshErrorMessage(failure, copy.unknownError))); + }, [copy.unknownError, refresh]); + + async function run(action: 'create' | 'reconcile'): Promise { + setWorking(true); + setError(undefined); + try { + const result = await window.maka.runtimeHostPeerMesh.execute(props.target, action); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + setWorking(false); + } + } + + async function join(): Promise { + setWorking(true); + setError(undefined); + try { + const result = await window.maka.runtimeHostPeerMesh.execute(props.target, 'join', { + invitation: joinDraft.trim(), + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setJoinDraft(''); + setView({ kind: 'overview' }); + setSnapshot(result); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + setWorking(false); + } + } + + async function createInvitation(meshId: string): Promise { + setWorking(true); + setError(undefined); + try { + const result = await window.maka.runtimeHostPeerMesh.execute(props.target, 'invite', { + meshId, + }); + if (!isInvitationResult(result)) throw new Error(copy.invalidResult); + setView({ + kind: 'invitation', + meshId, + code: JSON.stringify(result.invitation), + expiresAt: result.invitation.expiresAt, + }); + setSnapshot(result.snapshot); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + setWorking(false); + } + } + + 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; + setWorking(true); + setError(undefined); + try { + const result = await window.maka.runtimeHostPeerMesh.execute(props.target, action, { + meshId, + peerId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + setWorking(false); + } + } + + async function copyInvitation(): Promise { + if (view.kind !== 'invitation') return; + try { + await navigator.clipboard.writeText(view.code); + toast.success(copy.invitationCopied); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + + return ( + { + if (!open && !working) props.onClose(); + }} + purpose="form" + width={680} + maxHeight="calc(100dvh - 64px)" + > + } + onOpenChange={(open) => { + if (!open && !working) props.onClose(); + }} + /> + } + content={ + +
+ {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 run('create')} + onRefresh={() => void run('reconcile')} + /> + )} +
+
+ } + footer={ + view.kind === 'overview' ? undefined : ( + + +
+ ); +} + +function Overview(props: { + readonly snapshot: PeerMeshQueryResult | undefined; + readonly copy: ReturnType; + readonly working: boolean; + 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; +}) { + const { snapshot, copy } = props; + if (!snapshot) { + return ( + + {copy.loading} + + ); + } + if (!snapshot.available) { + return ; + } + return ( + <> +
+ +
+ + {copy.thisPeer} + + + {snapshot.localPeerId ? abbreviate(snapshot.localPeerId) : '—'} + +
+
+ {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)} + /> + ))} +
+ + )} + + ); +} + +function JoinView(props: { + readonly value: string; + readonly working: boolean; + readonly copy: ReturnType; + readonly onChange: (value: string) => void; +}) { + return ( +
+
+ +
+ + {props.copy.joinTitle} + + + {props.copy.joinHint} + +
+
+