From 4502680b64032d972ca197c8373e4d20c12bfcb1 Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 28 Aug 2026 21:47:02 +0800 Subject: [PATCH 1/7] feat(runtime-host): manage peer meshes from Desktop Add owner-only Mesh management operations and expose them through the CLI and Desktop local or SSH-managed endpoints. Preserve Mesh state by peer identity and keep invitations out of command arguments. Generated-by: OpenAI Codex --- .../runtime-host-ssh-terminal.test.ts | 58 ++- apps/desktop/src/main/runtime-host-boot.ts | 22 +- .../src/main/runtime-host-peer-client.ts | 18 +- .../main/runtime-host-peer-mesh-management.ts | 194 ++++++++++ .../src/main/runtime-host-ssh-terminal.ts | 64 +++ apps/desktop/src/preload/bridge-contract.d.ts | 20 + apps/desktop/src/preload/preload.ts | 16 + .../locales/settings-projects-copy.ts | 9 + .../runtime-host-peer-mesh-dialog.tsx | 363 +++++++++++++++++ .../runtime-host-profiles-section.tsx | 36 ++ .../renderer/styles/settings/runtime-host.css | 46 +++ docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + .../runtime-host-operator-command.test.ts | 44 +++ packages/cli/src/cli-core.ts | 19 + packages/cli/src/runtime-host-cli.ts | 99 ++++- ...ntime-host-peer-mesh-management-command.ts | 309 +++++++++++++++ .../cli/src/runtime-host-service-command.ts | 1 + ...runtime-host-service-management-command.ts | 2 + .../src/__tests__/connection-session.test.ts | 16 +- .../__tests__/operation-dispatcher.test.ts | 4 +- .../src/__tests__/peer-mesh.test.ts | 4 + packages/runtime-host/src/operator/index.ts | 9 + .../operator/peer-mesh-management-frame.ts | 150 ++++++++ .../src/operator/service-management-frame.ts | 2 + packages/runtime-host/src/peer-mesh/limits.ts | 25 ++ packages/runtime-host/src/peer-mesh/model.ts | 24 +- packages/runtime-host/src/peer-mesh/node.ts | 195 +++++++++- packages/runtime-host/src/peer-mesh/owner.ts | 11 +- packages/runtime-host/src/peer-mesh/store.ts | 22 +- packages/runtime-host/src/protocol/index.ts | 13 +- .../runtime-host/src/protocol/operations.ts | 2 + .../runtime-host/src/protocol/peer-mesh.ts | 364 ++++++++++++++++++ .../src/server/execution-service.ts | 54 ++- .../runtime-host/src/server/host-kernel.ts | 4 + packages/runtime-host/src/server/index.ts | 4 + .../src/server/operation-dispatcher.ts | 17 +- .../src/server/peer-mesh-authority.ts | 169 ++++++++ 38 files changed, 2349 insertions(+), 64 deletions(-) create mode 100644 apps/desktop/src/main/runtime-host-peer-mesh-management.ts create mode 100644 apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx create mode 100644 packages/cli/src/runtime-host-peer-mesh-management-command.ts create mode 100644 packages/runtime-host/src/operator/peer-mesh-management-frame.ts create mode 100644 packages/runtime-host/src/peer-mesh/limits.ts create mode 100644 packages/runtime-host/src/protocol/peer-mesh.ts create mode 100644 packages/runtime-host/src/server/peer-mesh-authority.ts 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..d8c8092eb7 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,58 @@ 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: { + meshId: 'mesh-id', + role: 'member', + localPeerId: 'peer-b', + authorityPeerId: 'peer-a', + revision: 2, + closed: false, + members: ['peer-a', 'peer-b'], + memberRoutes: [ + { 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 +939,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 +976,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..8a4132fca3 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -48,11 +48,11 @@ import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, createRuntimeHostCandidateLaunchBarrier, - createRuntimeHostPeerClientFromEnvironment, LOCAL_RUNTIME_HOST_PROFILE, 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 +181,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 +222,20 @@ 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() +const runtimeHostPeerOwner = runtimeHostPeerConfiguration + ? await openRuntimeHostPeerMeshOwner({ + ...runtimeHostPeerConfiguration, + dataRoot: join(userDataDir, 'peer-mesh'), + }) : undefined; +const runtimeHostPeerClient = runtimeHostPeerOwner?.client; +const runtimeHostDirectPeerAvailable = runtimeHostPeerOwner !== undefined; const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId( join(userDataDir, "runtime-host-client.json"), ); @@ -542,6 +548,12 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, }); +const runtimeHostPeerMeshManagement = createDesktopRuntimeHostPeerMeshManagement({ + ipcMain, + localMesh: runtimeHostPeerOwner?.mesh, + profiles: runtimeHostProfileService, + runRemote: runtimeHostSshTerminal.runPeerMeshManagement, +}); const defaultRuntimeHostRecovery = createRuntimeHostDefaultRecovery({ defaultProfileId: () => runtimeHostManager?.defaultProfileId() ?? @@ -1646,7 +1658,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(), 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..904955a2ba --- /dev/null +++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts @@ -0,0 +1,194 @@ +/* + * 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 { decodePeerMeshInvitation, type PeerMeshNode } from '@maka/runtime-host/peer-mesh'; +import type { + PeerMeshInvitationV1, + PeerMeshProjection, + 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; + 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': + return projectPeerMeshStatus(await mesh.create()); + case 'invite': + return mesh.invite(requiredValue(meshId, 'Mesh ID')); + case 'join': + return projectPeerMeshStatus( + await mesh.join(requiredValue(invitation, 'Peer Mesh invitation')), + ); + case 'remove': + return projectPeerMeshStatus( + await mesh.remove(requiredValue(meshId, 'Mesh ID'), requiredValue(peerId, 'Peer ID')), + ); + case 'leave': + await mesh.leave(requiredValue(meshId, 'Mesh ID')); + return snapshot(); + case 'close': + return projectPeerMeshStatus(await mesh.closeMesh(requiredValue(meshId, 'Mesh ID'))); + 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..16bfd0fb57 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,19 @@ 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 ttlMs?: number; + readonly invitation?: string; + readonly signal?: AbortSignal; +} + export interface DesktopRuntimeHostSshCleanupInput { readonly destination: string; readonly sshPort?: number; @@ -271,6 +289,9 @@ export function createDesktopRuntimeHostSshTerminal(input: { runPeerManagement( input: DesktopRuntimeHostSshPeerManagementInput, ): Promise; + runPeerMeshManagement( + input: DesktopRuntimeHostSshPeerMeshManagementInput, + ): Promise>; cleanupManagedDeployment(input: DesktopRuntimeHostSshCleanupInput): Promise; close(): Promise; } { @@ -510,6 +531,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 +543,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 +564,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { receivedProgress = true; if (activeTerminal) suppressPresentation(activeTerminal); options.onProgress?.(next); + sendInput(); return; } if (frame) { @@ -556,6 +585,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 +891,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 +1354,22 @@ 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] : []), + ...(input.ttlMs === undefined ? [] : ['--ttl-ms', String(input.ttlMs)]), + ...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..6f723a7064 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -520,6 +520,18 @@ 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').PeerMeshProjection + | import('@maka/runtime-host/protocol').PeerMeshInvitationV1; + type RuntimeHostUpdatePolicyResult = Extract< RuntimeHostServiceManagementFrame, { kind: 'result'; action: 'update_policy' } @@ -729,6 +741,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..da19c6ffc8 --- /dev/null +++ b/apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx @@ -0,0 +1,363 @@ +/* + * 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 type { PeerMeshProjection, PeerMeshQueryResult } from '@maka/runtime-host/protocol'; +import { Badge, Button, MoreMenu, Text, TextArea, useToast, useUiLocale } from '@maka/ui'; +import type { DesktopRuntimeHostPeerMeshTarget } from '../../preload/bridge-contract.js'; +import { settingsActionErrorMessage } from './settings-error-copy.js'; + +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 [invitation, setInvitation] = useState<{ + readonly meshId: string; + readonly code: string; + readonly expiresAt: number; + }>(); + 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(settingsActionErrorMessage(failure, locale))); + }, [locale, refresh]); + + async function run(action: 'create' | 'reconcile'): Promise { + setWorking(true); + setError(undefined); + try { + await window.maka.runtimeHostPeerMesh.execute(props.target, action); + await refresh(); + } catch (failure) { + setError(settingsActionErrorMessage(failure, locale)); + } finally { + setWorking(false); + } + } + + async function join(): Promise { + setWorking(true); + setError(undefined); + try { + await window.maka.runtimeHostPeerMesh.execute(props.target, 'join', { + invitation: joinDraft.trim(), + }); + setJoinDraft(''); + await refresh(); + } catch (failure) { + setError(settingsActionErrorMessage(failure, locale)); + } 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 (!('expiresAt' in result)) throw new Error(copy.invalidResult); + setInvitation({ meshId, code: JSON.stringify(result), expiresAt: result.expiresAt }); + await refresh(); + } catch (failure) { + setError(settingsActionErrorMessage(failure, locale)); + } 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 { + await window.maka.runtimeHostPeerMesh.execute(props.target, action, { meshId, peerId }); + if (action === 'close' && invitation?.meshId === meshId) setInvitation(undefined); + await refresh(); + } catch (failure) { + setError(settingsActionErrorMessage(failure, locale)); + } finally { + setWorking(false); + } + } + + async function copyInvitation(): Promise { + if (!invitation) return; + try { + await navigator.clipboard.writeText(invitation.code); + toast.success(copy.invitationCopied); + } catch (failure) { + setError(settingsActionErrorMessage(failure, locale)); + } + } + + return ( + { + if (!open && !working) props.onClose(); + }} + purpose="form" + width={680} + > + { + if (!open && !working) props.onClose(); + }} + /> + )} + content={( + +
+ {error ? : null} + {snapshot && !snapshot.available ? ( + + ) : null} + {snapshot?.available ? ( +
+ {copy.thisPeer} + {snapshot.localPeerId ? abbreviate(snapshot.localPeerId) : '—'} +
+ ) : null} + {snapshot?.meshes.length === 0 ? ( + {copy.empty} + ) : null} + {snapshot?.meshes.map((mesh) => ( + void createInvitation(mesh.meshId)} + onRemove={(peerId) => void mutate('remove', mesh.meshId, peerId)} + onLeave={() => void mutate('leave', mesh.meshId)} + onClose={() => void mutate('close', mesh.meshId)} + /> + ))} + {snapshot?.available ? ( +
+