You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/31 03:04:24 UTC
(maka) 01/01: feat(desktop): manage WSL host projects
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/wsl-host-project-management
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 75b3ca6980399ee60d682f576f003784f683abe5
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Mon Aug 31 11:02:59 2026 +0800
feat(desktop): manage WSL host projects
---
apps/desktop/renderer-architecture.json | 6 +-
.../runtime-host-managed-services.test.ts | 44 ++++++
.../main/__tests__/runtime-host-management.test.ts | 103 +++++++++++++
.../main/__tests__/runtime-host-onboarding.test.ts | 30 +++-
.../__tests__/runtime-host-wsl-controller.test.ts | 78 +++++++++-
.../main/__tests__/task-entry-controller.test.ts | 5 +-
apps/desktop/src/main/runtime-host-boot.ts | 6 +-
.../src/main/runtime-host-managed-services.ts | 118 ++++++++++-----
apps/desktop/src/main/runtime-host-management.ts | 120 ++++++++++++---
apps/desktop/src/main/runtime-host-onboarding.ts | 42 ++++--
.../src/main/runtime-host-peer-mesh-management.ts | 2 +
.../src/main/runtime-host-profile-service.ts | 63 ++++++--
.../src/main/runtime-host-wsl-controller.ts | 166 +++++++++++++++++++++
apps/desktop/src/renderer/app-shell-overlays.tsx | 13 +-
apps/desktop/src/renderer/app-shell.tsx | 40 ++---
.../controller/use-task-entry-controller.ts | 4 +
.../settings/runtime-host-management-dialog.tsx | 22 +--
.../settings/runtime-host-profiles-section.tsx | 59 +++++---
.../src/renderer/settings/settings-modal.tsx | 11 +-
.../src/renderer/settings/settings-surface.tsx | 26 ++--
apps/desktop/src/renderer/use-settings-modal.ts | 34 ++++-
.../stories/settings/settings-pages.stories.tsx | 2 +-
packages/ui/src/conversation-copy.ts | 5 +-
packages/ui/src/workspace-picker.tsx | 11 +-
24 files changed, 834 insertions(+), 176 deletions(-)
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 2f8f24e25e..aa8e819b83 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -624,7 +624,7 @@
"react": 1
},
"importSpecifiers": 22,
- "nonTriviaTokens": 990
+ "nonTriviaTokens": 977
},
"src/renderer/app-shell-project-actions.ts": {
"importDeclarations": 9,
@@ -945,7 +945,7 @@
"useShellRunUpdates": 1,
"useShellSearch": 1,
"useStableActions": 7,
- "useState": 18,
+ "useState": 17,
"useSystemUiLocale": 1,
"useTaskEntryController": 1,
"useTaskSubmissionReadiness": 1,
@@ -1063,7 +1063,7 @@
"react": 1
},
"importSpecifiers": 187,
- "nonTriviaTokens": 15912
+ "nonTriviaTokens": 15908
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts
index fc044de4d0..6d274bcf42 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts
@@ -186,3 +186,47 @@ test("keeps Desktop service bindings outside the shared profile catalog", async
true,
);
});
+
+test("persists a WSL deployment through its environment control route", async () => {
+ const root = await mkdtemp(join(tmpdir(), "maka-managed-wsl-deployment-"));
+ roots.push(root);
+ const store = createDesktopRuntimeHostManagedServiceStore(root);
+ const environment = {
+ id: "ubuntu",
+ name: "Ubuntu",
+ kind: "environment" as const,
+ provider: { kind: "wsl" as const, distribution: "Ubuntu-24.04" },
+ rootId: "a".repeat(64),
+ operatorPath: "/home/operator/.local/share/maka/operator",
+ };
+ await store.save(environment, {
+ deployment: {
+ id: environment.rootId,
+ rootPath: "/home/operator/.config/Maka/workspaces/default",
+ deploymentId,
+ },
+ control: {
+ kind: "wsl_operator",
+ distribution: environment.provider.distribution,
+ operatorPath: environment.operatorPath,
+ },
+ });
+
+ assert.deepEqual(
+ findDesktopRuntimeHostManagedServiceBinding(await store.read(), environment),
+ {
+ profile: environment,
+ deployment: {
+ id: environment.rootId,
+ rootPath: "/home/operator/.config/Maka/workspaces/default",
+ deploymentId,
+ },
+ control: {
+ kind: "wsl_operator",
+ distribution: "Ubuntu-24.04",
+ operatorPath: environment.operatorPath,
+ },
+ state: "active",
+ },
+ );
+});
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 053647b292..46d44c9d1e 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -34,6 +34,7 @@ import type {
DesktopRuntimeHostSshUpdatePolicyInput,
DesktopRuntimeHostSshUpdateReconciliationInput,
} from '../runtime-host-ssh-terminal.js';
+import type { DesktopRuntimeHostWslManagementInput } from '../runtime-host-wsl-controller.js';
const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111';
@@ -88,6 +89,105 @@ test('requires explicit interruption authority before a provider restarts active
);
});
+test('routes WSL status and directory configuration through the persisted operator route', async () => {
+ const handlers = new Map<string, (...args: unknown[]) => unknown>();
+ const calls: DesktopRuntimeHostWslManagementInput[] = [];
+ const profile = {
+ id: 'ubuntu',
+ name: 'Ubuntu',
+ kind: 'environment' as const,
+ provider: { kind: 'wsl' as const, distribution: 'Ubuntu-24.04' },
+ rootId: 'a'.repeat(64),
+ operatorPath: '/home/operator/.local/share/maka/operator',
+ };
+ const binding = {
+ profile,
+ deployment: {
+ id: 'a'.repeat(64),
+ rootPath: '/home/operator/.config/Maka/workspaces/default',
+ deploymentId: DEPLOYMENT_ID,
+ },
+ control: {
+ kind: 'wsl_operator' as const,
+ distribution: 'Ubuntu-24.04',
+ operatorPath: profile.operatorPath,
+ },
+ state: 'active' as const,
+ };
+ let reconnects = 0;
+ createDesktopRuntimeHostManagement({
+ ...unusedUpdateDependencies(),
+ ipcMain: {
+ handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
+ removeHandler: (channel) => handlers.delete(channel),
+ },
+ profiles: {
+ ...unusedDirectPeerProfileDependencies(),
+ resolveManagedService: async () => binding,
+ resolveManagedAccess: async () => undefined,
+ rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
+ markManagedServiceUninstalling: async () => assert.fail('uninstall is not expected'),
+ markManagedServiceCleanupPending: async () => assert.fail('uninstall is not expected'),
+ clearManagedServiceBinding: async () => assert.fail('uninstall is not expected'),
+ },
+ runServiceManagement: async () => assert.fail('WSL must not use SSH management'),
+ runWslManagement: async (input) => {
+ calls.push(input);
+ return serviceResult(input.action);
+ },
+ runAccessManagement: async () => assert.fail('access management is not expected'),
+ cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
+ currentHostEpoch: () => 'before-configure',
+ awaitUpdatedConnection: async () => {
+ reconnects += 1;
+ },
+ });
+
+ const run = handlers.get('runtime-host-management:run');
+ const configure = handlers.get('runtime-host-management:configure-project-directories');
+ assert.ok(run);
+ assert.ok(configure);
+ await run({}, profile.id, 'status');
+ await configure(
+ {},
+ profile.id,
+ [{ label: 'Work', path: '/srv/work' }],
+ `sha256:${'b'.repeat(64)}`,
+ false,
+ );
+
+ assert.deepEqual(calls.map(({ action, distribution, operatorPath, expectedTarget }) => ({
+ action,
+ distribution,
+ operatorPath,
+ expectedTarget,
+ })), [
+ {
+ action: 'status',
+ distribution: 'Ubuntu-24.04',
+ operatorPath: profile.operatorPath,
+ expectedTarget: {
+ serviceId: 'a'.repeat(64),
+ rootPath: '/home/operator/.config/Maka/workspaces/default',
+ rootId: 'a'.repeat(64),
+ deploymentId: DEPLOYMENT_ID,
+ },
+ },
+ {
+ action: 'configure',
+ distribution: 'Ubuntu-24.04',
+ operatorPath: profile.operatorPath,
+ expectedTarget: {
+ serviceId: 'a'.repeat(64),
+ rootPath: '/home/operator/.config/Maka/workspaces/default',
+ rootId: 'a'.repeat(64),
+ deploymentId: DEPLOYMENT_ID,
+ },
+ },
+ ]);
+ assert.equal(reconnects, 0);
+});
+
test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const profile = {
@@ -450,6 +550,7 @@ test('publishes update progress and waits for the managed profile to reconnect',
clearManagedServiceBinding: async () => undefined,
},
runServiceManagement: async () => assert.fail('ordinary management is not expected'),
+ runWslManagement: async () => assert.fail('WSL management is not expected'),
runPeerManagement: async () => assert.fail('direct peer management is not expected'),
directPeerClientAvailable: false,
runUpdate: async (input, onProgress) => {
@@ -1224,6 +1325,8 @@ function serviceSummary(installedVersion: string) {
function unusedUpdateDependencies() {
return {
+ runWslManagement: async (): Promise<never> =>
+ assert.fail('WSL management is not expected'),
runUpdate: async (): Promise<never> => assert.fail('update is not expected'),
runUpdatePolicy: async (): Promise<never> => assert.fail('update policy is not expected'),
runUpdateReconciliation: async (): Promise<never> =>
diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
index c40b096d21..220a507e38 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
@@ -93,11 +93,19 @@ test('persists a verified on-demand SSH profile without endpoint or credential p
});
test('onboards WSL as a credential-free environment profile', async () => {
- let saved: DesktopRuntimeHostProfileAddInput | undefined;
+ let saved:
+ | {
+ readonly profile: Extract<
+ DesktopRuntimeHostProfileAddInput['profile'],
+ { kind: 'environment' }
+ >;
+ readonly managedService: DesktopRuntimeHostManagedServiceTarget;
+ }
+ | undefined;
const peerTargets: string[] = [];
const harness = createHarness({
profiles: {
- addAndEnable: async (input) => {
+ addManagedEnvironmentAndEnable: async (input) => {
saved = input;
return { kind: 'connected', snapshot: { entries: [], defaultProfileId: 'local' } };
},
@@ -105,6 +113,9 @@ test('onboards WSL as a credential-free environment profile', async () => {
runWslSetup: async (_input, _onProgress, onComplete) => {
onComplete();
return {
+ serviceId: 'a'.repeat(64),
+ deploymentId: '00000000-0000-4000-8000-000000000001',
+ rootPath: '/home/operator/.config/Maka/workspaces/default',
rootId: 'a'.repeat(64),
operatorPath: '/home/operator/.local/share/maka/operator',
};
@@ -121,7 +132,6 @@ test('onboards WSL as a credential-free environment profile', async () => {
});
assert.equal((result as { kind?: string }).kind, 'complete');
- assert.equal(saved?.credential, undefined);
assert.deepEqual(saved?.profile, {
id: saved?.profile.id,
name: 'Ubuntu-24.04',
@@ -130,6 +140,18 @@ test('onboards WSL as a credential-free environment profile', async () => {
rootId: 'a'.repeat(64),
operatorPath: '/home/operator/.local/share/maka/operator',
});
+ assert.deepEqual(saved?.managedService, {
+ deployment: {
+ id: 'a'.repeat(64),
+ rootPath: '/home/operator/.config/Maka/workspaces/default',
+ deploymentId: '00000000-0000-4000-8000-000000000001',
+ },
+ control: {
+ kind: 'wsl_operator',
+ distribution: 'Ubuntu-24.04',
+ operatorPath: '/home/operator/.local/share/maka/operator',
+ },
+ });
assert.deepEqual(peerTargets, ['none']);
await harness.onboarding.close();
});
@@ -296,7 +318,7 @@ function createHarness(overrides: HarnessOverrides = {}) {
const onboarding = createDesktopRuntimeHostOnboarding({
clientInstanceId: 'stable-client',
profiles: {
- addAndEnable: async () => assert.fail('profile must not be saved'),
+ addManagedEnvironmentAndEnable: async () => assert.fail('profile must not be saved'),
addAndEnableVerified: async () => assert.fail('profile must not be saved'),
...profiles,
},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
index 42fbc75a09..9e76469949 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
@@ -25,10 +25,86 @@ import { PassThrough } from 'node:stream';
import test from 'node:test';
import type { RuntimeHostWslProcessFactory } from '@maka/runtime-host/client';
import {
+ encodeRuntimeHostServiceManagementFrame,
encodeRuntimeHostSetupFrame,
+ RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV,
} from '@maka/runtime-host/operator';
-import { runDesktopRuntimeHostWslSetup } from '../runtime-host-wsl-controller.js';
+import {
+ runDesktopRuntimeHostWslManagement,
+ runDesktopRuntimeHostWslSetup,
+} from '../runtime-host-wsl-controller.js';
+
+test('WSL management invokes the stable operator directly with the exact deployment target', async () => {
+ let launch:
+ | { readonly executable: string; readonly args: readonly string[]; readonly environment: NodeJS.ProcessEnv }
+ | undefined;
+ const frame = encodeRuntimeHostServiceManagementFrame({
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'configure',
+ service: {
+ platform: 'linux',
+ arch: 'x64',
+ osRelease: '6.8.0',
+ state: 'running',
+ pid: 42,
+ lastExitCode: 0,
+ installedVersion: '0.2.0',
+ configurationFingerprint: `sha256:${'c'.repeat(64)}`,
+ projectDirectoryRoots: [{ label: 'Work', path: '/srv/work' }],
+ },
+ configuration: { kind: 'configured' },
+ });
+ await runDesktopRuntimeHostWslManagement({
+ distribution: 'Ubuntu',
+ operatorPath: '/home/operator/.local/share/maka/operator',
+ action: 'configure',
+ expectedTarget: {
+ serviceId: 'a'.repeat(64),
+ rootPath: '/home/operator/.config/Maka/workspaces/default',
+ rootId: 'a'.repeat(64),
+ deploymentId: '00000000-0000-4000-8000-000000000001',
+ },
+ projectDirectoryRoots: [{ label: 'Work', path: '/srv/work' }],
+ expectedConfigFingerprint: `sha256:${'b'.repeat(64)}`,
+ }, {
+ wslExecutable: 'wsl.exe',
+ processFactory: (executable, args, environment) => {
+ launch = { executable, args: [...args], environment };
+ const child = new EventEmitter() as ChildProcessWithoutNullStreams;
+ const stdin = new PassThrough();
+ const stdout = new PassThrough();
+ const stderr = new PassThrough();
+ Object.assign(child, { stdin, stdout, stderr, kill: () => true });
+ process.nextTick(() => {
+ stdout.end(frame);
+ stderr.end();
+ child.emit('close', 0, null);
+ });
+ return child;
+ },
+ });
+
+ assert.equal(launch?.executable, 'wsl.exe');
+ assert.deepEqual(launch?.args.slice(0, 5), [
+ '--distribution',
+ 'Ubuntu',
+ '--exec',
+ '/home/operator/.local/share/maka/operator',
+ 'configure',
+ ]);
+ assert.ok(launch?.args.includes('--expected-deployment-id'));
+ assert.equal(
+ launch?.environment[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV],
+ '1',
+ );
+ assert.ok(
+ launch?.environment.WSLENV?.split(':').includes(
+ RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
+ ),
+ );
+});
test('WSL setup forwards the development archive and its exact evidence', async () => {
const launches: string[][] = [];
diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
index 0988848d20..1e32f93f0c 100644
--- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
@@ -108,7 +108,10 @@ function deferred<T>() {
let latestController: TaskEntryController | undefined;
function ControllerProbe(props: { reportError(error: unknown): void }) {
- latestController = useTaskEntryController({ reportError: props.reportError });
+ latestController = useTaskEntryController({
+ reportError: props.reportError,
+ manageProjects() {},
+ });
return null;
}
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index a4d554b222..d79b40dba1 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -176,7 +176,10 @@ import {
import {
createDesktopRuntimeHostSshTerminal,
} from "./runtime-host-ssh-terminal.js";
-import { runDesktopRuntimeHostWslSetup } from './runtime-host-wsl-controller.js';
+import {
+ runDesktopRuntimeHostWslManagement,
+ runDesktopRuntimeHostWslSetup,
+} from './runtime-host-wsl-controller.js';
import {
createRuntimeHostSetupPackageResolver,
desktopRuntimeHostDevelopmentPeerTarget,
@@ -539,6 +542,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({
ipcMain,
profiles: runtimeHostProfileService,
runServiceManagement: runtimeHostSshTerminal.runServiceManagement,
+ runWslManagement: runDesktopRuntimeHostWslManagement,
runPeerManagement: runtimeHostSshTerminal.runPeerManagement,
directPeerClientAvailable: runtimeHostDirectPeerAvailable,
runUpdate: runtimeHostSshTerminal.runUpdate,
diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts
index 2c9202543d..eed5e99508 100644
--- a/apps/desktop/src/main/runtime-host-managed-services.ts
+++ b/apps/desktop/src/main/runtime-host-managed-services.ts
@@ -21,9 +21,9 @@ import { randomUUID } from "node:crypto";
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
- decodeRemoteRuntimeHostProfile,
- sameRemoteRuntimeHostProfileTarget,
- type RemoteRuntimeHostProfile,
+ decodePersistedRuntimeHostProfile,
+ sameResolvedRuntimeHostProfileTarget,
+ type PersistedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import { requireHostRootId } from "@maka/runtime-host/protocol";
import { withFileUpdateLock } from "@maka/storage/file-update-lock";
@@ -40,10 +40,16 @@ export interface DesktopRuntimeHostDeploymentBinding {
readonly deploymentId?: string;
}
-export interface DesktopRuntimeHostControlRoute {
- readonly kind: "ssh_operator";
- readonly operatorPath: string;
-}
+export type DesktopRuntimeHostControlRoute =
+ | {
+ readonly kind: "ssh_operator";
+ readonly operatorPath: string;
+ }
+ | {
+ readonly kind: "wsl_operator";
+ readonly distribution: string;
+ readonly operatorPath: string;
+ };
export interface DesktopRuntimeHostManagedServiceTarget {
readonly deployment: DesktopRuntimeHostDeploymentBinding;
@@ -51,7 +57,7 @@ export interface DesktopRuntimeHostManagedServiceTarget {
}
export interface DesktopRuntimeHostManagedServiceBinding {
- readonly profile: RemoteRuntimeHostProfile;
+ readonly profile: PersistedRuntimeHostProfile;
readonly deployment: DesktopRuntimeHostDeploymentBinding;
readonly control: DesktopRuntimeHostControlRoute;
readonly state: "active" | "uninstalling" | "cleanup_pending";
@@ -65,14 +71,14 @@ export interface DesktopRuntimeHostManagedServiceDocument {
export interface DesktopRuntimeHostManagedServiceStore {
read(): Promise<DesktopRuntimeHostManagedServiceDocument>;
save(
- profile: RemoteRuntimeHostProfile,
+ profile: PersistedRuntimeHostProfile,
target: DesktopRuntimeHostManagedServiceTarget,
): Promise<void>;
removeIfCurrent(
binding: DesktopRuntimeHostManagedServiceBinding,
): Promise<boolean>;
removeForProfileIfCurrent(
- profile: RemoteRuntimeHostProfile,
+ profile: PersistedRuntimeHostProfile,
): Promise<boolean>;
markUninstallingIfCurrent(
binding: DesktopRuntimeHostManagedServiceBinding,
@@ -96,12 +102,12 @@ export function createDesktopRuntimeHostManagedServiceStore(
export function findDesktopRuntimeHostManagedServiceBinding(
document: DesktopRuntimeHostManagedServiceDocument,
- profile: RemoteRuntimeHostProfile,
+ profile: PersistedRuntimeHostProfile,
): DesktopRuntimeHostManagedServiceBinding | undefined {
const binding = document.bindings.find(
(candidate) => candidate.profile.id === profile.id,
);
- return binding && sameRemoteRuntimeHostProfileTarget(binding.profile, profile)
+ return binding && sameManagedProfileTarget(binding.profile, profile)
? binding
: undefined;
}
@@ -113,7 +119,7 @@ export function sameDesktopRuntimeHostManagedServiceBinding(
return (
left.state === right.state &&
left.profile.id === right.profile.id &&
- sameRemoteRuntimeHostProfileTarget(left.profile, right.profile) &&
+ sameManagedProfileTarget(left.profile, right.profile) &&
sameBindingTarget(left, right)
);
}
@@ -158,17 +164,13 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan
}
save(
- value: RemoteRuntimeHostProfile,
+ value: PersistedRuntimeHostProfile,
managedTarget: DesktopRuntimeHostManagedServiceTarget,
): Promise<void> {
- const profile = decodeRemoteRuntimeHostProfile(value);
- if (profile.transport.kind !== "ssh") {
- return Promise.reject(
- new Error("A managed Runtime Host service requires SSH"),
- );
- }
+ const profile = decodePersistedRuntimeHostProfile(value);
const deployment = decodeDeployment(managedTarget.deployment);
const control = decodeControlRoute(managedTarget.control);
+ assertControlRouteMatchesProfile(profile, control);
return this.#exclusive(async () => {
const current = await this.#readUnlocked();
const bindings = current.bindings.filter(
@@ -224,14 +226,14 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan
return this.#remove(binding);
}
- removeForProfileIfCurrent(value: RemoteRuntimeHostProfile): Promise<boolean> {
- return this.#remove(undefined, undefined, decodeRemoteRuntimeHostProfile(value));
+ removeForProfileIfCurrent(value: PersistedRuntimeHostProfile): Promise<boolean> {
+ return this.#remove(undefined, undefined, decodePersistedRuntimeHostProfile(value));
}
#remove(
expected?: DesktopRuntimeHostManagedServiceBinding,
state?: DesktopRuntimeHostManagedServiceBinding["state"],
- profileOverride?: RemoteRuntimeHostProfile,
+ profileOverride?: PersistedRuntimeHostProfile,
): Promise<boolean> {
const profile = expected?.profile ?? profileOverride!;
return this.#exclusive(async () => {
@@ -241,7 +243,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan
);
if (
!binding ||
- !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) ||
+ !sameManagedProfileTarget(binding.profile, profile) ||
(expected && !sameBindingTarget(binding, expected)) ||
(state && binding.state !== state)
) {
@@ -270,7 +272,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan
);
if (
!binding ||
- !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) ||
+ !sameManagedProfileTarget(binding.profile, profile) ||
!sameBindingTarget(binding, expected) ||
!allowedStates.includes(binding.state)
) {
@@ -318,10 +320,9 @@ function decodeDocument(
"Runtime Host managed service binding",
["control", "deployment", "profile", "state"],
);
- const profile = decodeRemoteRuntimeHostProfile(binding.profile);
- if (profile.transport.kind !== "ssh") {
- throw new Error("A managed Runtime Host service requires SSH");
- }
+ const profile = decodePersistedRuntimeHostProfile(binding.profile);
+ const control = decodeControlRoute(binding.control);
+ assertControlRouteMatchesProfile(profile, control);
if (
binding.state !== "active" &&
binding.state !== "uninstalling" &&
@@ -332,7 +333,7 @@ function decodeDocument(
return Object.freeze({
profile,
deployment: decodeDeployment(binding.deployment),
- control: decodeControlRoute(binding.control),
+ control,
state: binding.state,
});
});
@@ -401,12 +402,16 @@ function decodeDeployment(value: unknown): DesktopRuntimeHostDeploymentBinding {
}
function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute {
+ const candidate = value as { readonly kind?: unknown } | null;
+ const kind = candidate?.kind;
const record = requireExactRecord(
value,
"Managed Runtime Host control route",
- ["kind", "operatorPath"],
+ kind === "wsl_operator"
+ ? ["distribution", "kind", "operatorPath"]
+ : ["kind", "operatorPath"],
);
- if (record.kind !== "ssh_operator") {
+ if (record.kind !== "ssh_operator" && record.kind !== "wsl_operator") {
throw new Error("Managed Runtime Host control route is invalid");
}
const operatorPath = requirePath(
@@ -416,7 +421,23 @@ function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute {
if (!operatorPath.startsWith("/")) {
throw new Error("Managed Runtime Host operator path must be absolute");
}
- return Object.freeze({ kind: "ssh_operator", operatorPath });
+ if (record.kind === "ssh_operator") {
+ return Object.freeze({ kind: "ssh_operator", operatorPath });
+ }
+ if (
+ typeof record.distribution !== "string" ||
+ record.distribution.length === 0 ||
+ record.distribution.length > 128 ||
+ record.distribution.trim() !== record.distribution ||
+ /[\u0000-\u001f\u007f]/u.test(record.distribution)
+ ) {
+ throw new Error("Managed WSL distribution is invalid");
+ }
+ return Object.freeze({
+ kind: "wsl_operator",
+ distribution: record.distribution,
+ operatorPath,
+ });
}
function decodeLegacyService(value: unknown): {
@@ -500,10 +521,39 @@ function sameBindingTarget(
left.deployment.rootPath === right.deployment.rootPath &&
left.deployment.deploymentId === right.deployment.deploymentId &&
left.control.kind === right.control.kind &&
- left.control.operatorPath === right.control.operatorPath
+ left.control.operatorPath === right.control.operatorPath &&
+ (left.control.kind !== "wsl_operator" ||
+ (right.control.kind === "wsl_operator" &&
+ left.control.distribution === right.control.distribution))
+ );
+}
+
+function sameManagedProfileTarget(
+ left: PersistedRuntimeHostProfile,
+ right: PersistedRuntimeHostProfile,
+): boolean {
+ return (
+ left.id === right.id &&
+ sameResolvedRuntimeHostProfileTarget({ profile: left }, { profile: right })
);
}
+function assertControlRouteMatchesProfile(
+ profile: PersistedRuntimeHostProfile,
+ control: DesktopRuntimeHostControlRoute,
+): void {
+ if (
+ control.kind === "ssh_operator"
+ ? profile.kind !== "remote" || profile.transport.kind !== "ssh"
+ : profile.kind !== "environment" ||
+ profile.provider.kind !== "wsl" ||
+ profile.provider.distribution !== control.distribution ||
+ profile.operatorPath !== control.operatorPath
+ ) {
+ throw new Error("Managed Runtime Host control route does not match its profile");
+ }
+}
+
function emptyDocument(): DesktopRuntimeHostManagedServiceDocument {
return Object.freeze({
schemaVersion: SCHEMA_VERSION,
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index 9381767178..8b83a6b0ed 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -61,6 +61,7 @@ import type {
DesktopRuntimeHostManagementProvider,
DesktopRuntimeHostManagementTerminalFrame,
} from './runtime-host-management-provider.js';
+import type { DesktopRuntimeHostWslManagementInput } from './runtime-host-wsl-controller.js';
const MANAGEMENT_ACTIONS = new Set<DesktopRuntimeHostManagementAction>([
'status',
@@ -94,6 +95,9 @@ export function createDesktopRuntimeHostManagement(input: {
readonly runServiceManagement: (
input: DesktopRuntimeHostSshManagementInput,
) => Promise<Exclude<RuntimeHostServiceManagementFrame, { kind: 'progress' }>>;
+ readonly runWslManagement: (
+ input: DesktopRuntimeHostWslManagementInput,
+ ) => Promise<Exclude<RuntimeHostServiceManagementFrame, { kind: 'progress' }>>;
readonly runAccessManagement: (
input: DesktopRuntimeHostSshAccessInput,
) => Promise<RuntimeHostAccessManagementFrame>;
@@ -176,9 +180,6 @@ export function createDesktopRuntimeHostManagement(input: {
): Promise<DesktopRuntimeHostManagementResponse> => {
const managed = await resolveManagedService(profileId);
const { profile, deployment, control } = managed;
- if (profile.transport.kind !== 'ssh') {
- throw new Error('This Runtime Host profile is not bound to a managed service');
- }
if (managed.state !== 'active' && managementAction !== 'uninstall') {
throw new Error('Finish uninstalling this Runtime Host service before managing it');
}
@@ -192,17 +193,41 @@ export function createDesktopRuntimeHostManagement(input: {
'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation',
);
}
+ const expectedTarget = {
+ serviceId: deployment.id,
+ rootPath: deployment.rootPath,
+ rootId: profile.rootId,
+ ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}),
+ };
+ if (control.kind === 'wsl_operator') {
+ if (
+ profile.kind !== 'environment' ||
+ profile.provider.kind !== 'wsl' ||
+ profile.provider.distribution !== control.distribution ||
+ profile.operatorPath !== control.operatorPath
+ ) {
+ throw new Error('This Runtime Host profile has an invalid WSL management route');
+ }
+ if (managementAction !== 'status') {
+ throw new Error('This WSL Runtime Host management action is not available');
+ }
+ const response = await input.runWslManagement({
+ distribution: control.distribution,
+ operatorPath: control.operatorPath,
+ action: managementAction,
+ expectedTarget,
+ });
+ return projectManagementFrame(response, false);
+ }
+ if (profile.kind !== 'remote' || profile.transport.kind !== 'ssh') {
+ throw new Error('This Runtime Host profile has an invalid SSH management route');
+ }
const managementInput: DesktopRuntimeHostSshManagementInput = {
destination: profile.transport.destination,
...(profile.transport.sshPort === undefined ? {} : { sshPort: profile.transport.sshPort }),
operatorPath: control.operatorPath,
action: managementAction,
- expectedTarget: {
- serviceId: deployment.id,
- rootPath: deployment.rootPath,
- rootId: profile.rootId,
- ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}),
- },
+ expectedTarget,
...(managementAction === 'install'
? {
rootPath: deployment.rootPath,
@@ -324,7 +349,11 @@ export function createDesktopRuntimeHostManagement(input: {
if (managed.state !== 'active') {
throw new Error('Finish uninstalling this Runtime Host service before managing access');
}
- if (managed.profile.transport.kind !== 'ssh') {
+ if (
+ managed.profile.kind !== 'remote' ||
+ managed.profile.transport.kind !== 'ssh' ||
+ managed.control.kind !== 'ssh_operator'
+ ) {
throw new Error('This Runtime Host profile does not have an SSH management channel');
}
return {
@@ -347,8 +376,12 @@ export function createDesktopRuntimeHostManagement(input: {
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') {
+ if (
+ managed.state !== 'active' ||
+ managed.profile.kind !== 'remote' ||
+ managed.profile.transport.kind !== 'ssh' ||
+ managed.control.kind !== 'ssh_operator'
+ ) {
throw new Error('This Runtime Host profile is not available for managed service changes');
}
if (!managed.deployment.deploymentId) {
@@ -359,7 +392,7 @@ export function createDesktopRuntimeHostManagement(input: {
return {
profileId,
managed,
- transport,
+ transport: managed.profile.transport,
expectedTarget: {
serviceId: managed.deployment.id,
rootPath: managed.deployment.rootPath,
@@ -643,18 +676,57 @@ export function createDesktopRuntimeHostManagement(input: {
);
reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true);
} else {
- const { managed, transport, expectedTarget } = await managedMutationTarget(profileId);
+ const managed = await resolveManagedService(profileId);
+ if (managed.state !== 'active') {
+ throw new Error('This Runtime Host profile is not available for managed service changes');
+ }
+ if (!managed.deployment.deploymentId) {
+ throw new Error(
+ 'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation',
+ );
+ }
+ const expectedTarget = {
+ serviceId: managed.deployment.id,
+ rootPath: managed.deployment.rootPath,
+ rootId: managed.profile.rootId,
+ deploymentId: managed.deployment.deploymentId,
+ };
const previousHostEpoch = input.currentHostEpoch(profileId);
- execute = () => input.runServiceManagement({
- destination: transport.destination,
- ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }),
- operatorPath: managed.control.operatorPath,
- action: 'configure',
- expectedTarget,
- projectDirectoryRoots: roots,
- expectedConfigFingerprint: expectedConfigFingerprintValue,
- ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}),
- });
+ if (managed.control.kind === 'wsl_operator') {
+ const control = managed.control;
+ if (
+ managed.profile.kind !== 'environment' ||
+ managed.profile.provider.kind !== 'wsl' ||
+ managed.profile.provider.distribution !== control.distribution ||
+ managed.profile.operatorPath !== control.operatorPath
+ ) {
+ throw new Error('This Runtime Host profile has an invalid WSL management route');
+ }
+ execute = () => input.runWslManagement({
+ distribution: control.distribution,
+ operatorPath: control.operatorPath,
+ action: 'configure',
+ expectedTarget,
+ projectDirectoryRoots: roots,
+ expectedConfigFingerprint: expectedConfigFingerprintValue,
+ ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}),
+ });
+ } else {
+ if (managed.profile.kind !== 'remote' || managed.profile.transport.kind !== 'ssh') {
+ throw new Error('This Runtime Host profile has an invalid SSH management route');
+ }
+ const transport = managed.profile.transport;
+ execute = () => input.runServiceManagement({
+ destination: transport.destination,
+ ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }),
+ operatorPath: managed.control.operatorPath,
+ action: 'configure',
+ expectedTarget,
+ projectDirectoryRoots: roots,
+ expectedConfigFingerprint: expectedConfigFingerprintValue,
+ ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}),
+ });
+ }
reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch);
}
const response = requireManagementFrame(await execute(), 'configure');
diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts
index 0311ac536a..ab940f2af0 100644
--- a/apps/desktop/src/main/runtime-host-onboarding.ts
+++ b/apps/desktop/src/main/runtime-host-onboarding.ts
@@ -45,7 +45,10 @@ type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapsh
export function createDesktopRuntimeHostOnboarding(input: {
readonly ipcMain: Pick<IpcMain, 'handle' | 'removeHandler'>;
readonly clientInstanceId: string;
- readonly profiles: Pick<DesktopRuntimeHostProfileService, 'addAndEnable' | 'addAndEnableVerified'>;
+ readonly profiles: Pick<
+ DesktopRuntimeHostProfileService,
+ 'addManagedEnvironmentAndEnable' | 'addAndEnableVerified'
+ >;
readonly runSetup: (
input: DesktopRuntimeHostSshSetupInput,
onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void,
@@ -63,7 +66,13 @@ export function createDesktopRuntimeHostOnboarding(input: {
input: DesktopRuntimeHostWslSetupInput,
onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void,
onComplete: () => void,
- ) => Promise<{ readonly rootId: string; readonly operatorPath: string }>;
+ ) => Promise<{
+ readonly rootId: string;
+ readonly rootPath: string;
+ readonly serviceId: string;
+ readonly deploymentId: string;
+ readonly operatorPath: string;
+ }>;
readonly listWslDistributions: () => Promise<readonly string[]>;
readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void;
readonly setupPackageMode: 'published' | 'development';
@@ -261,14 +270,27 @@ export function createDesktopRuntimeHostOnboarding(input: {
);
beginCommit();
const profileId = `environment-${randomUUID()}`;
- const connected = await input.profiles.addAndEnable({
- profile: {
- id: profileId,
- name: request.name?.trim() || request.distribution,
- kind: 'environment',
- provider: { kind: 'wsl', distribution: request.distribution },
- rootId: complete.rootId,
- operatorPath: complete.operatorPath,
+ const profile = {
+ id: profileId,
+ name: request.name?.trim() || request.distribution,
+ kind: 'environment' as const,
+ provider: { kind: 'wsl' as const, distribution: request.distribution },
+ rootId: complete.rootId,
+ operatorPath: complete.operatorPath,
+ };
+ const connected = await input.profiles.addManagedEnvironmentAndEnable({
+ profile,
+ managedService: {
+ deployment: {
+ id: complete.serviceId,
+ rootPath: complete.rootPath,
+ deploymentId: complete.deploymentId,
+ },
+ control: {
+ kind: 'wsl_operator',
+ distribution: request.distribution,
+ operatorPath: complete.operatorPath,
+ },
},
});
if (connected.kind === 'unavailable') throw new Error(connected.message);
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 7c6a24f95f..200088d86f 100644
--- a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts
+++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts
@@ -133,7 +133,9 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: {
if (
!managed ||
managed.state !== 'active' ||
+ managed.profile.kind !== 'remote' ||
managed.profile.transport.kind !== 'ssh' ||
+ managed.control.kind !== 'ssh_operator' ||
!managed.deployment.deploymentId
) {
throw new Error('This Runtime Host does not have an active SSH management channel');
diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts
index c6b42484c1..64bc4b2a6f 100644
--- a/apps/desktop/src/main/runtime-host-profile-service.ts
+++ b/apps/desktop/src/main/runtime-host-profile-service.ts
@@ -23,6 +23,7 @@ import { dirname, join } from "node:path";
import {
createClientRuntimeHostCredentialStore,
createClientRuntimeHostProfileCatalog,
+ decodeEnvironmentRuntimeHostProfile,
decodeRuntimeHostOwnerConnectionCode,
LOCAL_RUNTIME_HOST_PROFILE,
RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES,
@@ -31,6 +32,7 @@ import {
RuntimeHostRemoteCompatibilityError,
sameRemoteRuntimeHostProfileTarget,
sameResolvedRuntimeHostProfileTarget,
+ type EnvironmentRuntimeHostProfile,
type PersistedRuntimeHostProfile,
type RemoteRuntimeHostProfile,
type ResolvedRuntimeHostProfile,
@@ -95,6 +97,10 @@ export interface DesktopRuntimeHostProfileService {
addAndEnable(
input: DesktopRuntimeHostProfileAddInput,
): Promise<DesktopRuntimeHostProfileAddResult>;
+ addManagedEnvironmentAndEnable(input: {
+ readonly profile: EnvironmentRuntimeHostProfile;
+ readonly managedService: DesktopRuntimeHostManagedServiceTarget;
+ }): Promise<DesktopRuntimeHostProfileAddResult>;
addAndEnableVerified(
input: {
readonly profile: RemoteRuntimeHostProfile;
@@ -366,7 +372,7 @@ export function createDesktopRuntimeHostProfileService(input: {
: unavailable.get(profile.id);
return {
profile,
- ...(profile.kind === "remote" &&
+ ...(profile.kind !== "local" &&
findDesktopRuntimeHostManagedServiceBinding(managedDocument, profile)
? { managedService: true as const }
: {}),
@@ -768,6 +774,35 @@ export function createDesktopRuntimeHostProfileService(input: {
: { kind: "connected", snapshot: await snapshot() };
});
},
+ addManagedEnvironmentAndEnable(value) {
+ const profile = decodeEnvironmentRuntimeHostProfile(value.profile);
+ return mutateProfiles(async () => {
+ await managedServices.save(profile, value.managedService);
+ try {
+ const document = await catalog.create(profile);
+ const persisted = document.profiles.find((candidate) => candidate.id === profile.id);
+ if (!persisted || persisted.kind !== 'environment') {
+ throw new Error("Runtime Host profile creation did not persist");
+ }
+ const target: ResolvedRuntimeHostProfile = { profile: persisted };
+ let error: Error | undefined;
+ try {
+ error = await enable(profile.id);
+ } catch (failure) {
+ await rollbackCreatedProfile(catalog, target, failure);
+ throw failure;
+ }
+ return error
+ ? { kind: "unavailable", snapshot: await snapshot(), message: error.message }
+ : { kind: "connected", snapshot: await snapshot() };
+ } catch (failure) {
+ await managedServices.removeForProfileIfCurrent(profile).catch((error) =>
+ console.error("[runtime-host] failed to roll back WSL deployment binding:", error),
+ );
+ throw failure;
+ }
+ });
+ },
addAndEnableVerified,
async importConnectionCode(code) {
let decoded;
@@ -884,7 +919,6 @@ export function createDesktopRuntimeHostProfileService(input: {
(candidate) => candidate.id === profileId,
);
if (!profile) return undefined;
- if (profile.kind !== 'remote') return undefined;
const binding = findDesktopRuntimeHostManagedServiceBinding(
await managedServices.read(),
profile,
@@ -1047,8 +1081,10 @@ export function createDesktopRuntimeHostProfileService(input: {
);
if (
!current ||
- current.kind !== 'remote' ||
- !sameRemoteRuntimeHostProfileTarget(current, expected.profile)
+ !sameResolvedRuntimeHostProfileTarget(
+ { profile: current },
+ { profile: expected.profile },
+ )
) {
throw new Error('Runtime Host managed service binding changed during uninstall');
}
@@ -1075,8 +1111,10 @@ export function createDesktopRuntimeHostProfileService(input: {
);
if (
!current ||
- current.kind !== 'remote' ||
- !sameRemoteRuntimeHostProfileTarget(current, expected.profile) ||
+ !sameResolvedRuntimeHostProfileTarget(
+ { profile: current },
+ { profile: expected.profile },
+ ) ||
!(await managedServices.markCleanupPendingIfCurrent(expected))
) {
throw new Error('Runtime Host managed service binding changed during uninstall');
@@ -1092,8 +1130,10 @@ export function createDesktopRuntimeHostProfileService(input: {
);
if (
!current ||
- current.kind !== 'remote' ||
- !sameRemoteRuntimeHostProfileTarget(current, expected.profile) ||
+ !sameResolvedRuntimeHostProfileTarget(
+ { profile: current },
+ { profile: expected.profile },
+ ) ||
!(await managedServices.removeCleanupPendingIfCurrent(expected))
) {
throw new Error('Runtime Host managed service binding changed during uninstall');
@@ -1252,9 +1292,10 @@ export function createDesktopRuntimeHostProfileService(input: {
) {
throw new Error('Disable and remove the Direct peer profile before removing its SSH profile');
}
- const managedBinding = profile.kind === 'remote'
- ? findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile)
- : undefined;
+ const managedBinding = findDesktopRuntimeHostManagedServiceBinding(
+ await managedServices.read(),
+ profile,
+ );
if (managedBinding && managedBinding.state !== 'active') {
throw new Error('Finish uninstalling this Runtime Host service before removing it');
}
diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts
index 80bc59a038..404eb8c093 100644
--- a/apps/desktop/src/main/runtime-host-wsl-controller.ts
+++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts
@@ -21,15 +21,20 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import type { Readable } from 'node:stream';
import {
normalizeRuntimeHostWslDistribution,
+ normalizeRuntimeHostWslOperatorPath,
resolveSystemRuntimeHostWslExecutable,
type RuntimeHostWslProcessFactory,
} from '@maka/runtime-host/client';
import {
decodeRuntimeHostSetupFrame,
+ decodeRuntimeHostServiceManagementFrame,
+ RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
+ RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
RUNTIME_HOST_SETUP_FRAME_PREFIX,
RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV,
type RuntimeHostSetupFrame,
type RuntimeHostSetupPhase,
+ type RuntimeHostServiceManagementFrame,
} from '@maka/runtime-host/operator';
import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js';
import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js';
@@ -39,6 +44,140 @@ const WSL_SETUP_OUTPUT_MAX_BYTES = 64 * 1024;
const WSL_SETUP_STDERR_MAX_BYTES = 8 * 1024;
type RuntimeHostSetupCompleteFrame = Extract<RuntimeHostSetupFrame, { kind: 'complete' }>;
+type RuntimeHostManagementTerminalFrame = Exclude<
+ RuntimeHostServiceManagementFrame,
+ { readonly kind: 'progress' }
+>;
+
+export interface DesktopRuntimeHostWslManagementInput {
+ readonly distribution: string;
+ readonly operatorPath: string;
+ readonly action: 'status' | 'configure';
+ readonly expectedTarget: {
+ readonly serviceId: string;
+ readonly rootPath: string;
+ readonly rootId: string;
+ readonly deploymentId?: string;
+ };
+ readonly projectDirectoryRoots?: readonly {
+ readonly label: string;
+ readonly path: string;
+ }[];
+ readonly expectedConfigFingerprint?: string;
+ readonly allowInterruptActiveTasks?: boolean;
+ readonly signal?: AbortSignal;
+}
+
+type RuntimeHostWslManagementProcessFactory = (
+ executable: string,
+ args: readonly string[],
+ environment: NodeJS.ProcessEnv,
+) => ChildProcessWithoutNullStreams;
+
+export async function runDesktopRuntimeHostWslManagement(
+ input: DesktopRuntimeHostWslManagementInput,
+ overrides: {
+ readonly processFactory?: RuntimeHostWslManagementProcessFactory;
+ readonly wslExecutable?: string;
+ } = {},
+): Promise<RuntimeHostManagementTerminalFrame> {
+ input.signal?.throwIfAborted();
+ const distribution = normalizeRuntimeHostWslDistribution(input.distribution);
+ const operatorPath = normalizeRuntimeHostWslOperatorPath(input.operatorPath);
+ const args = [
+ '--distribution',
+ distribution,
+ '--exec',
+ operatorPath,
+ input.action,
+ '--framed',
+ ...(input.projectDirectoryRoots === undefined
+ ? []
+ : input.projectDirectoryRoots.length === 0
+ ? ['--no-project-roots']
+ : input.projectDirectoryRoots.flatMap(({ label, path }) => [
+ '--project-root-json',
+ JSON.stringify({ label, path }),
+ ])),
+ ...(input.expectedConfigFingerprint
+ ? ['--expected-config-fingerprint', input.expectedConfigFingerprint]
+ : []),
+ ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
+ '--expected-service-id',
+ input.expectedTarget.serviceId,
+ '--expected-root-path',
+ input.expectedTarget.rootPath,
+ '--expected-root-id',
+ input.expectedTarget.rootId,
+ ...(input.expectedTarget.deploymentId
+ ? ['--expected-deployment-id', input.expectedTarget.deploymentId]
+ : []),
+ ];
+ const environment = passEnvironmentToWsl(
+ process.env,
+ RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
+ '1',
+ );
+ const child = (overrides.processFactory ?? spawnWslManagement)(
+ overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable(),
+ args,
+ environment,
+ );
+ const abort = () => child.kill();
+ input.signal?.addEventListener('abort', abort, { once: true });
+ if (input.signal?.aborted) abort();
+ child.stdin.end();
+ let result: RuntimeHostManagementTerminalFrame | undefined;
+ let failure: Error | undefined;
+ const filter = createRuntimeHostFramedOutputFilter({
+ prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
+ pendingMaxBytes: WSL_SETUP_OUTPUT_MAX_BYTES,
+ decode: decodeRuntimeHostServiceManagementFrame,
+ label: 'WSL Runtime Host management',
+ onFrame: (frame) => {
+ if (frame.kind === 'progress') {
+ failure = new Error('WSL Runtime Host management returned unexpected progress');
+ } else if (result) {
+ failure = new Error('WSL Runtime Host management returned multiple results');
+ } else {
+ result = frame;
+ }
+ },
+ onError: (error) => {
+ failure = error;
+ },
+ });
+ let outputBytes = 0;
+ child.stdout.on('data', (value: Buffer | string) => {
+ const chunk = typeof value === 'string' ? Buffer.from(value) : value;
+ outputBytes += chunk.byteLength;
+ if (outputBytes > WSL_SETUP_OUTPUT_MAX_BYTES) {
+ failure = new Error('WSL Runtime Host management output exceeded its byte limit');
+ child.kill();
+ return;
+ }
+ filter.push(chunk.toString('utf8'));
+ });
+ const stderr = collectBounded(child.stderr, WSL_SETUP_STDERR_MAX_BYTES);
+ const timeout = setTimeout(() => child.kill(), WSL_SETUP_TIMEOUT_MS);
+ const [exit, capturedStderr] = await Promise.all([childExit(child), stderr]).finally(() => {
+ clearTimeout(timeout);
+ input.signal?.removeEventListener('abort', abort);
+ });
+ filter.finish();
+ input.signal?.throwIfAborted();
+ if (failure) throw failure;
+ if (!result) {
+ const diagnostic = formatBoundedDiagnostic(capturedStderr);
+ throw new Error(
+ `WSL Runtime Host management exited with code ${String(exit.code)} without a result${diagnostic ? `: ${diagnostic}` : ''}`,
+ );
+ }
+ if (result.action !== input.action) {
+ throw new Error('WSL Runtime Host returned an unrelated management result');
+ }
+ return result;
+}
export interface DesktopRuntimeHostWslSetupInput {
readonly distribution: string;
@@ -207,6 +346,33 @@ function spawnWsl(executable: string, args: readonly string[]) {
});
}
+function spawnWslManagement(
+ executable: string,
+ args: readonly string[],
+ environment: NodeJS.ProcessEnv,
+) {
+ return spawn(executable, args, {
+ shell: false,
+ windowsHide: true,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: environment,
+ });
+}
+
+function passEnvironmentToWsl(
+ source: NodeJS.ProcessEnv,
+ name: string,
+ value: string,
+): NodeJS.ProcessEnv {
+ const existing = source.WSLENV?.split(':').filter(Boolean) ?? [];
+ const included = existing.some((entry) => entry.split('/')[0] === name);
+ return {
+ ...source,
+ [name]: value,
+ WSLENV: included ? existing.join(':') : [...existing, name].join(':'),
+ };
+}
+
function childExit(child: ChildProcessWithoutNullStreams): Promise<{
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx
index d1509582ec..d675b618e1 100644
--- a/apps/desktop/src/renderer/app-shell-overlays.tsx
+++ b/apps/desktop/src/renderer/app-shell-overlays.tsx
@@ -39,9 +39,7 @@ const SettingsModal = lazy(async () => {
}
).makaE2eLatch;
await e2eLatch?.wait('settings.chunk');
- return import('./settings/settings-modal').then((module) => ({
- default: module.SettingsModal,
- }));
+ return import('./settings/settings-modal');
});
type SearchModalProps = Parameters<typeof SearchModal>[0];
@@ -80,7 +78,7 @@ export function AppShellOverlays(props: {
* can disagree the moment anything else writes the setting.
*/
refreshChatDefaults(): void;
- settingsRequestedSection: SettingsSection | undefined;
+ settingsRequest: { readonly section?: SettingsSection; readonly profileId?: string };
settingsProviderCatalogOpen: boolean;
settingsConnectionDetailSlug: string | undefined;
settingsCreateProviderType: ProviderType | undefined;
@@ -113,7 +111,7 @@ export function AppShellOverlays(props: {
searchModalOnNavigate,
searchModalOpen,
settingsOpen,
- settingsRequestedSection,
+ settingsRequest,
settingsProviderCatalogOpen,
settingsConnectionDetailSlug,
settingsCreateProviderType,
@@ -161,12 +159,11 @@ export function AppShellOverlays(props: {
// #1045: base commands freeze per open/close; session rows stay live on
// visibleSessions/activeId. run() closures read latest options via ref.
const commands = useAppShellCommands(paletteOpen, commandOptions);
- const copyDiagnosticsCommand = commands.find((command) => command.id === 'diag:copy-diagnostics');
useHotkeys([
{
keys: 'mod+shift+d',
allowInInputs: true,
- onPress: () => void copyDiagnosticsCommand?.run(),
+ onPress: () => void commands.find((command) => command.id === 'diag:copy-diagnostics')?.run(),
},
]);
return (
@@ -183,7 +180,7 @@ export function AppShellOverlays(props: {
uiLocaleUpdateGate={uiLocaleUpdateGate}
onUserLabelChange={setUserLabel}
onDefaultPermissionModeChange={() => refreshChatDefaults()}
- requestedSection={settingsRequestedSection}
+ request={settingsRequest}
openProviderCatalog={settingsProviderCatalogOpen}
initialConnectionSlug={settingsConnectionDetailSlug}
initialCreateProviderType={settingsCreateProviderType}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index b899ce429f..986fc6c501 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -389,6 +389,23 @@ function AppShellContent({
epochs.set(sessionId, (epochs.get(sessionId) ?? 0) + 1);
}, []);
+ const {
+ settingsOpen,
+ settingsRequest,
+ settingsProviderCatalogOpen,
+ settingsConnectionDetailSlug,
+ settingsCreateProviderType,
+ setSettingsOpen,
+ setSettingsProviderCatalogOpen,
+ setSettingsProfileId,
+ openSettings,
+ openSettingsSection,
+ openProjectSettings,
+ openProviderCatalog,
+ openConnectionDetail,
+ openProviderCreate,
+ } = useSettingsModal();
+
const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot);
const reportTaskEntryError = useCallback(
({ title, description, profileId }: TaskEntryError) => {
@@ -398,6 +415,7 @@ function AppShellContent({
);
const taskEntry = useTaskEntryController({
reportError: reportTaskEntryError,
+ manageProjects: openProjectSettings,
});
// Named on its own because the rail depends on it: `taskEntry.commands` is a
// fresh object every render, so depending on the bag rather than the command
@@ -610,22 +628,6 @@ function AppShellContent({
onboarding.snapshot,
sessions.length > 0,
);
- const {
- settingsOpen,
- settingsRequestedSection,
- settingsProviderCatalogOpen,
- settingsConnectionDetailSlug,
- settingsCreateProviderType,
- setSettingsOpen,
- setSettingsProviderCatalogOpen,
- openSettings,
- openSettingsSection,
- openProviderCatalog,
- openConnectionDetail,
- openProviderCreate,
- } = useSettingsModal();
- const [settingsDiagnosticProfileId, setSettingsDiagnosticProfileId] =
- useState<string>();
const {
themePref,
setThemePref,
@@ -2670,7 +2672,7 @@ function AppShellContent({
messages,
newTaskProfileId: taskEntry.selectors.selectedProfileId,
settingsOpen,
- settingsProfileId: settingsDiagnosticProfileId,
+ settingsProfileId: settingsRequest.profileId,
sessions,
themePref,
visibleSessions,
@@ -3287,7 +3289,7 @@ function AppShellContent({
refreshChatDefaults={() => {
void taskEntry.commands.refresh().catch(() => undefined);
}}
- settingsRequestedSection={settingsRequestedSection}
+ settingsRequest={settingsRequest}
settingsProviderCatalogOpen={settingsProviderCatalogOpen}
settingsConnectionDetailSlug={settingsConnectionDetailSlug}
settingsCreateProviderType={settingsCreateProviderType}
@@ -3319,7 +3321,7 @@ function AppShellContent({
openNewTaskSurface();
void taskEntry.commands.chooseProjectForProfile(profileId).catch(() => undefined);
}}
- onSelectedRuntimeHostProfileIdChange={setSettingsDiagnosticProfileId}
+ onSelectedRuntimeHostProfileIdChange={setSettingsProfileId}
/>
</div>
</ComposerMentionsProvider>
diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
index 23602b0a60..820797be1b 100644
--- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
+++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
@@ -62,6 +62,7 @@ export interface TaskEntryError {
export interface UseTaskEntryControllerInput {
reportError(error: TaskEntryError): void;
+ manageProjects(profileId: string): void;
}
export interface TaskEntryControllerSelectors {
@@ -137,6 +138,7 @@ export function useTaskEntryController(
const copy = getShellCopy(locale).projectActions;
const conversationCopy = getConversationCopy(locale).workspace;
const reportError = input.reportError;
+ const manageProjects = input.manageProjects;
const { catalog: service } = useTaskEntryServices();
const [catalog, setCatalog] = useState<TaskEntryCatalog>(EMPTY_CATALOG);
const [selectedProfileId, setSelectedProfileId] = useState<string>();
@@ -453,6 +455,7 @@ export function useTaskEntryController(
...(host.capabilities.selectNoProject
? { onSelectNoProject: () => selectNoProject(host) }
: {}),
+ onManage: () => manageProjects(host.profile.id),
};
}),
...(catalogNeedsRetry
@@ -473,6 +476,7 @@ export function useTaskEntryController(
copy.runtimeHostReadiness,
currentProject?.name,
error,
+ manageProjects,
pending,
refreshing,
refresh,
diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
index f8fce1c2e7..b1bc2fc730 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -84,6 +84,7 @@ export interface RuntimeHostManagementTarget {
readonly id: string;
readonly name: string;
readonly subtitle?: string;
+ readonly scope: 'full' | 'project_directories';
readonly directPeerManagement: boolean;
}
@@ -153,7 +154,7 @@ export function RuntimeHostManagementDialog(props: {
} catch (failure) {
if (!disposed) setError(settingsActionErrorMessage(failure, locale));
}
- if (shouldLoadUpdatePolicy) {
+ if (shouldLoadUpdatePolicy && target.scope === 'full') {
try {
const policy = await window.maka.runtimeHostManagement.getUpdatePolicy(target.id);
if (!disposed) applyUpdatePolicy(policy);
@@ -164,7 +165,7 @@ export function RuntimeHostManagementDialog(props: {
}
}
}
- if (shouldLoadUpdatePolicy && target.directPeerManagement) {
+ if (shouldLoadUpdatePolicy && target.scope === 'full' && target.directPeerManagement) {
try {
const peer = await window.maka.runtimeHostManagement.getDirectPeer(target.id);
if (!disposed) applyDirectPeer(peer);
@@ -231,7 +232,9 @@ export function RuntimeHostManagementDialog(props: {
setResult(response);
reconcileDirectoryPolicy(response.service);
if (response.service.state === 'not_installed') setUpdatePolicy(undefined);
- else if (action !== 'logs') await reloadUpdatePolicy(target.id);
+ else if (target.scope === 'full' && action !== 'logs') {
+ await reloadUpdatePolicy(target.id);
+ }
} catch (failure) {
const message = settingsActionErrorMessage(failure, locale);
setUpdatePolicy(undefined);
@@ -582,6 +585,7 @@ export function RuntimeHostManagementDialog(props: {
directoryPolicyEdit !== undefined &&
JSON.stringify(normalizedDirectoryRoots) !== JSON.stringify(directoryPolicyEdit.baseline.roots);
const updateOutcome = lastUpdateOutcome;
+ const fullManagement = target?.scope === 'full';
return (
<Dialog
isOpen={target !== undefined}
@@ -715,7 +719,7 @@ export function RuntimeHostManagementDialog(props: {
<Fact label={copy.stateRoot} value={service.stateRoot} wide />
) : null}
</dl>
- {serviceInstalled && target?.directPeerManagement ? (
+ {serviceInstalled && fullManagement && target?.directPeerManagement ? (
<section className="settingsRuntimeHostDirectPeer">
<div className="settingsRuntimeHostUpdatePolicyHeading">
<div>
@@ -866,7 +870,7 @@ export function RuntimeHostManagementDialog(props: {
) : null}
</section>
) : null}
- {serviceInstalled ? (
+ {serviceInstalled && fullManagement ? (
<section className="settingsRuntimeHostUpdatePolicy">
<div className="settingsRuntimeHostUpdatePolicyHeading">
<div>
@@ -1036,7 +1040,7 @@ export function RuntimeHostManagementDialog(props: {
</Text>
)}
</div>
- {result.action === 'logs' ? (
+ {fullManagement && result.action === 'logs' ? (
<pre ref={logsRef} className="settingsRuntimeHostManagementLogs">
{result.logs || copy.noLogs}
</pre>
@@ -1253,7 +1257,7 @@ export function RuntimeHostManagementDialog(props: {
isDisabled={loading}
onClick={props.onClose}
/>
- {target && !uninstalled ? (
+ {target && fullManagement && !uninstalled ? (
<MoreMenu
label={copy.moreActions(target.name)}
size="sm"
@@ -1286,14 +1290,14 @@ export function RuntimeHostManagementDialog(props: {
isDisabled={loading}
onClick={() => void run('status')}
/>
- {serviceInstalled && supervised && serviceActive ? (
+ {fullManagement && serviceInstalled && supervised && serviceActive ? (
<Button
variant="primary"
label={copy.restartService}
isDisabled={loading}
onClick={() => void run('restart')}
/>
- ) : serviceInstalled && supervised ? (
+ ) : fullManagement && serviceInstalled && supervised ? (
<Button
variant="primary"
label={copy.startService}
diff --git a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
index b7f58fadb1..cf28cb4d8d 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
@@ -360,7 +360,12 @@ export function RuntimeHostProfilesSection(props: {
const connectedEntries = snapshot?.entries.filter((entry) => entry.profile.kind !== 'local') ?? [];
const localProfile = snapshot?.entries.find((entry) => entry.profile.kind === 'local')?.profile;
const localManagementTarget = localProfile
- ? { id: localProfile.id, name: localProfile.name, directPeerManagement: false }
+ ? {
+ id: localProfile.id,
+ name: localProfile.name,
+ scope: 'full' as const,
+ directPeerManagement: false,
+ }
: undefined;
const profileOptions = (snapshot?.entries ?? [])
.filter(
@@ -636,12 +641,25 @@ export function RuntimeHostProfilesSection(props: {
if (profile.kind === 'local') return null;
const isSharedAccess =
profile.kind === 'remote' && profile.access === 'session_guest';
- const managedSshDestination =
- !isSharedAccess &&
- profile.kind === 'remote' &&
- profile.transport.kind === 'ssh' &&
- entry.managedService
- ? profile.transport.destination
+ const managementTarget: RuntimeHostManagementTarget | undefined =
+ !isSharedAccess && entry.managedService
+ ? profile.kind === 'environment'
+ ? {
+ id: profile.id,
+ name: profile.name,
+ subtitle: profile.provider.distribution,
+ scope: 'project_directories',
+ directPeerManagement: false,
+ }
+ : profile.transport.kind === 'ssh'
+ ? {
+ id: profile.id,
+ name: profile.name,
+ subtitle: profile.transport.destination,
+ scope: 'full',
+ directPeerManagement: true,
+ }
+ : undefined
: undefined;
return (
<ListItem
@@ -708,24 +726,21 @@ export function RuntimeHostProfilesSection(props: {
onChanged={() => void reload()}
onWorkingChange={setSwitching}
items={[
- ...(managedSshDestination && !entry.pairingPending
+ ...(managementTarget && !entry.pairingPending
? [{
label: copy.manage,
isDisabled: switching,
- onClick: () => setManagedTarget({
- id: profile.id,
- name: profile.name,
- subtitle: managedSshDestination,
- directPeerManagement: true,
- }),
- }, {
- label: copy.managePeerMesh,
- isDisabled: switching,
- onClick: () => setPeerMeshTarget({
- target: { kind: 'managed_host', profileId: profile.id },
- name: profile.name,
- }),
- }]
+ onClick: () => setManagedTarget(managementTarget),
+ }, ...(managementTarget.directPeerManagement
+ ? [{
+ label: copy.managePeerMesh,
+ isDisabled: switching,
+ onClick: () => setPeerMeshTarget({
+ target: { kind: 'managed_host', profileId: profile.id },
+ name: profile.name,
+ }),
+ }]
+ : [])]
: []),
{
label: copy.remove,
diff --git a/apps/desktop/src/renderer/settings/settings-modal.tsx b/apps/desktop/src/renderer/settings/settings-modal.tsx
index 6c2b09a738..3cf5cce934 100644
--- a/apps/desktop/src/renderer/settings/settings-modal.tsx
+++ b/apps/desktop/src/renderer/settings/settings-modal.tsx
@@ -31,7 +31,7 @@ import type { UiLocaleUpdateGate } from './ui-locale-update-gate';
export { SETTINGS_NAV } from './settings-nav';
export type { SettingsNavGroup } from './settings-nav';
-export function SettingsModal(props: {
+export default function SettingsModal(props: {
onClose(): void;
themePref: ThemePreference;
onThemeChange(pref: ThemePreference): void;
@@ -49,11 +49,10 @@ export function SettingsModal(props: {
onUserLabelChange?(label: string): void;
onDefaultPermissionModeChange(mode: ChatDefaultPermissionMode): void;
/**
- * Force the modal to a specific section when it (re-)mounts or when the
- * value changes while already open. Used by the command palette so
- * ⌘K → "网络" jumps straight to the section without an extra click.
+ * Force the modal to a specific section and Runtime Host when it mounts.
+ * Section changes while already open remain live for command-palette jumps.
*/
- requestedSection?: SettingsSection;
+ request?: { readonly section?: SettingsSection; readonly profileId?: string };
openProviderCatalog?: boolean;
initialConnectionSlug?: string;
initialCreateProviderType?: ProviderType;
@@ -105,7 +104,7 @@ export function SettingsModal(props: {
uiLocaleUpdateGate={props.uiLocaleUpdateGate}
onUserLabelChange={props.onUserLabelChange}
onDefaultPermissionModeChange={props.onDefaultPermissionModeChange}
- requestedSection={props.requestedSection}
+ request={props.request}
openProviderCatalog={props.openProviderCatalog}
initialConnectionSlug={props.initialConnectionSlug}
initialCreateProviderType={props.initialCreateProviderType}
diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index 8addb78be9..f202d45521 100644
--- a/apps/desktop/src/renderer/settings/settings-surface.tsx
+++ b/apps/desktop/src/renderer/settings/settings-surface.tsx
@@ -154,7 +154,7 @@ export function SettingsSurface(props: {
uiLocaleUpdateGate: UiLocaleUpdateGate;
onUserLabelChange?(label: string): void;
onDefaultPermissionModeChange(mode: ChatDefaultPermissionMode): void;
- requestedSection?: SettingsSection;
+ request?: { readonly section?: SettingsSection; readonly profileId?: string };
openProviderCatalog?: boolean;
initialConnectionSlug?: string;
initialCreateProviderType?: ProviderType;
@@ -172,7 +172,7 @@ export function SettingsSurface(props: {
const copy = getSettingsSharedCopy(locale);
const localizedNav = groupedNav(locale);
const isNarrowSettings = useMediaQuery(NARROW_SETTINGS_QUERY);
- const [section, setSection] = useState<SettingsSection>(() => props.requestedSection ?? readLastSettingsSection());
+ const [section, setSection] = useState<SettingsSection>(() => props.request?.section ?? readLastSettingsSection());
const [providerCatalogRequested, setProviderCatalogRequested] = useState(props.openProviderCatalog === true);
// One-shot landing intent, mirroring providerCatalogRequested above: the
// request retires once ProvidersPanel consumes it, so remounting the panel
@@ -190,15 +190,15 @@ export function SettingsSurface(props: {
setCreateProviderRequest(props.initialCreateProviderType);
}, [props.initialCreateProviderType]);
- // When the parent updates requestedSection (e.g. the palette opens
+ // When the parent updates the navigation request (e.g. the palette opens
// Settings with a different section while it's already mounted), reflect
- // that into the local state.
+ // its section into the local state.
useEffect(() => {
- if (props.requestedSection && props.requestedSection !== section) {
- setSection(props.requestedSection);
+ if (props.request?.section && props.request.section !== section) {
+ setSection(props.request.section);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.requestedSection]);
+ }, [props.request?.section]);
// Focus follows the active section's nav button: on mount, and whenever
// `section` changes (nav click — a native-focus no-op — or a ⌘K palette
@@ -252,12 +252,14 @@ export function SettingsSurface(props: {
() => snapshotCache.readRuntimeHostCatalog(),
[snapshotCache],
);
+ const initialSelectedProfileId =
+ props.request?.profileId ?? initialRuntimeHostCatalog?.defaultProfileId;
const initialRuntimeHost = useMemo(
() => readyRuntimeHost(
initialRuntimeHostCatalog,
- initialRuntimeHostCatalog?.defaultProfileId,
+ initialSelectedProfileId,
),
- [initialRuntimeHostCatalog],
+ [initialRuntimeHostCatalog, initialSelectedProfileId],
);
const initialRuntimeHostKey = initialRuntimeHost
? runtimeHostSettingsKey(initialRuntimeHost)
@@ -288,7 +290,7 @@ export function SettingsSurface(props: {
initialRuntimeHostCatalog,
));
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>(
- initialRuntimeHostCatalog?.defaultProfileId,
+ initialSelectedProfileId,
);
const selectedProfileIdRef = useRef(selectedProfileId);
const defaultRuntimeHostProfileIdRef = useRef(
@@ -309,7 +311,9 @@ export function SettingsSurface(props: {
const usageReloadTicketRef = useRef(0);
const runtimeHostReloadTicketRef = useRef(0);
const runtimeHostCatalogHydratedRef = useRef(false);
- const selectedProfileChangedByUserRef = useRef(false);
+ const selectedProfileChangedByUserRef = useRef(
+ props.request?.profileId !== undefined,
+ );
const runtimeHostLifecycleByProfileRef = useRef(
new Map<string, DesktopRuntimeHostProfileChangedEvent>(),
);
diff --git a/apps/desktop/src/renderer/use-settings-modal.ts b/apps/desktop/src/renderer/use-settings-modal.ts
index 6335f361f2..7b6d7e9678 100644
--- a/apps/desktop/src/renderer/use-settings-modal.ts
+++ b/apps/desktop/src/renderer/use-settings-modal.ts
@@ -22,6 +22,11 @@ import type { ProviderType } from '@maka/core/llm-connections';
import type { SettingsSection } from '@maka/core/settings';
import { safeLocalStorageSet } from './browser-storage';
+interface SettingsNavigationRequest {
+ readonly section?: SettingsSection;
+ readonly profileId?: string;
+}
+
/**
* Owns the Settings modal surface state (issue #1043): the open flag, the
* requested section, and the provider-catalog sub-open flag, plus the openers
@@ -33,13 +38,15 @@ import { safeLocalStorageSet } from './browser-storage';
*/
export function useSettingsModal() {
const [settingsOpen, setSettingsOpen] = useState(false);
- const [settingsRequestedSection, setSettingsRequestedSection] = useState<SettingsSection | undefined>(
- undefined,
- );
+ const [settingsRequest, setSettingsRequest] = useState<SettingsNavigationRequest>({});
const [settingsProviderCatalogOpen, setSettingsProviderCatalogOpen] = useState(false);
const [settingsConnectionDetailSlug, setSettingsConnectionDetailSlug] = useState<string | undefined>(undefined);
const [settingsCreateProviderType, setSettingsCreateProviderType] = useState<ProviderType | undefined>(undefined);
+ function setSettingsProfileId(profileId: string | undefined) {
+ setSettingsRequest((current) => ({ ...current, profileId }));
+ }
+
function showSettings() {
// macOS menu commands do not move DOM focus before opening Settings.
// Settle blur-owned edits before the obscured shell unmounts them.
@@ -58,7 +65,16 @@ export function useSettingsModal() {
function openSettingsSection(section: SettingsSection) {
safeLocalStorageSet('maka-settings-section-v1', section);
- setSettingsRequestedSection(section);
+ setSettingsRequest((current) => ({ ...current, section }));
+ setSettingsProviderCatalogOpen(false);
+ setSettingsConnectionDetailSlug(undefined);
+ setSettingsCreateProviderType(undefined);
+ showSettings();
+ }
+
+ function openProjectSettings(profileId: string) {
+ safeLocalStorageSet('maka-settings-section-v1', 'projects');
+ setSettingsRequest({ section: 'projects', profileId });
setSettingsProviderCatalogOpen(false);
setSettingsConnectionDetailSlug(undefined);
setSettingsCreateProviderType(undefined);
@@ -67,7 +83,7 @@ export function useSettingsModal() {
function openProviderCatalog() {
safeLocalStorageSet('maka-settings-section-v1', 'models');
- setSettingsRequestedSection('models');
+ setSettingsRequest((current) => ({ ...current, section: 'models' }));
setSettingsProviderCatalogOpen(true);
setSettingsConnectionDetailSlug(undefined);
setSettingsCreateProviderType(undefined);
@@ -77,7 +93,7 @@ export function useSettingsModal() {
/** Open Settings → 模型 with a specific connection's detail sheet expanded. */
function openConnectionDetail(slug: string) {
safeLocalStorageSet('maka-settings-section-v1', 'models');
- setSettingsRequestedSection('models');
+ setSettingsRequest((current) => ({ ...current, section: 'models' }));
setSettingsProviderCatalogOpen(false);
setSettingsConnectionDetailSlug(slug);
setSettingsCreateProviderType(undefined);
@@ -87,7 +103,7 @@ export function useSettingsModal() {
/** Open Settings → 模型 with the create-connection dialog for this provider expanded. */
function openProviderCreate(providerType: ProviderType) {
safeLocalStorageSet('maka-settings-section-v1', 'models');
- setSettingsRequestedSection('models');
+ setSettingsRequest((current) => ({ ...current, section: 'models' }));
setSettingsProviderCatalogOpen(false);
setSettingsConnectionDetailSlug(undefined);
setSettingsCreateProviderType(providerType);
@@ -96,14 +112,16 @@ export function useSettingsModal() {
return {
settingsOpen,
- settingsRequestedSection,
+ settingsRequest,
settingsProviderCatalogOpen,
settingsConnectionDetailSlug,
settingsCreateProviderType,
setSettingsOpen,
setSettingsProviderCatalogOpen,
+ setSettingsProfileId,
openSettings,
openSettingsSection,
+ openProjectSettings,
openProviderCatalog,
openConnectionDetail,
openProviderCreate,
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index b1dbd489c4..0a93af857b 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -1613,7 +1613,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
onUiLocalePreferenceChange={noop}
uiLocaleUpdateGate={uiLocaleUpdateGate}
onDefaultPermissionModeChange={noop}
- requestedSection={props.section}
+ request={{ section: props.section }}
openProviderCatalog={props.openProviderCatalog}
initialConnectionSlug={props.initialConnectionSlug}
initialFocusRef={initialFocusRef}
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index d7a4d79cba..7c6773e24f 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -242,6 +242,7 @@ export interface ConversationCopy {
choose: string;
current: string;
addProject: string;
+ manageProjects: string;
noProject: string;
relink: string;
unavailable: string;
@@ -492,7 +493,7 @@ const CONVERSATION_COPY = {
questions: { other: '其他', otherDescription: '输入一个不同的答案。', otherAriaLabel: '其他答案', otherPlaceholder: '输入你的答案', stop: '停止', stopping: '停止中…', previous: '上一题', submitting: '正在提交…', submit: '提交答案', next: '下一题' },
mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' },
workspace: {
- choose: '选择项目', current: '当前项目', addProject: '添加项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
+ choose: '选择项目', current: '当前项目', addProject: '添加项目', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
chooseTitle: (branch) => branch ? `选择项目 · ${branch}` : '选择项目',
chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`,
},
@@ -640,7 +641,7 @@ const CONVERSATION_COPY = {
questions: { other: 'Other', otherDescription: 'Enter a different answer.', otherAriaLabel: 'Other answer', otherPlaceholder: 'Enter your answer', stop: 'Stop', stopping: 'Stopping…', previous: 'Previous', submitting: 'Submitting…', submit: 'Submit answers', next: 'Next' },
mentions: { noFiles: 'No files found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…' },
workspace: {
- choose: 'Choose project', current: 'Current project', addProject: 'Add project', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable',
+ choose: 'Choose project', current: 'Current project', addProject: 'Add project', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable',
chooseTitle: (branch) => branch ? `Choose project · ${branch}` : 'Choose project',
chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`,
},
diff --git a/packages/ui/src/workspace-picker.tsx b/packages/ui/src/workspace-picker.tsx
index 43f8eda746..9533837e0a 100644
--- a/packages/ui/src/workspace-picker.tsx
+++ b/packages/ui/src/workspace-picker.tsx
@@ -19,7 +19,7 @@
import type { ProjectRecord } from '@maka/core/project';
import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu';
-import { ICON_SIZE, AlertTriangle, Check, FolderOpen, Network, Plus, RefreshCcw, X } from './icons.js';
+import { ICON_SIZE, AlertTriangle, Check, FolderOpen, Network, Plus, RefreshCcw, Settings, X } from './icons.js';
import { useUiLocale } from './locale-context.js';
import { getConversationCopy } from './conversation-copy.js';
@@ -32,6 +32,7 @@ export interface WorkspacePickerGroup {
selectedProjectId?: string | null;
onSelectProject?(projectId: string): void;
onAdd?(): void;
+ onManage?(): void;
onRelink?(projectId: string): void;
onSelectNoProject?(): void;
}
@@ -136,6 +137,14 @@ export function WorkspacePicker({ workspacePicker: picker }: {
onClick={group.onAdd}
/>
) : null}
+ {group.onManage ? (
+ <DropdownMenuItem
+ icon={<Settings size={ICON_SIZE.meta} aria-hidden="true" />}
+ label={copy.manageProjects}
+ isDisabled={locked || group.disabled}
+ onClick={group.onManage}
+ />
+ ) : null}
{group.onSelectNoProject ? (
<DropdownMenuItem
icon={<X size={ICON_SIZE.meta} aria-hidden="true" />}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org