Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -3102,7 +3102,7 @@
"@maka/core/model-thinking": 1,
"@maka/core/settings": 3,
"@maka/core/settings/network-settings": 1,
"@maka/ui": 2,
"@maka/ui": 1,
"react": 1
}
},
Expand Down Expand Up @@ -4284,9 +4284,9 @@
"dependencyPaths": {
"../../preload/bridge-contract.js": 1,
"../../shared/settings-ownership.js": 1,
"../browser-storage": 1,
"../locales/settings-navigation-copy.js": 1,
"../locales/settings-shared-copy.js": 1,
"../platform/desktop/settings-surface-capabilities.js": 1,
"./about-settings-page": 1,
"./appearance-settings-page": 1,
"./bot-chat-settings-page": 1,
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/scripts/check-renderer-architecture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2232,7 +2232,17 @@ function allowsMigrationDependency({ base, current, dependency, desktopRoot, pat
const targetRelative = normalizePath(relative(desktopRoot, target));
const targetZone = zoneFor(targetRelative);
if (section === 'legacyAppShell' || section === 'legacyAppShellClosure') {
if (targetZone.kind === 'shell' || isPublicApplicationPath(targetRelative)) return true;
// `platform` counts for the same reason the root closure already accepts it:
// a Desktop adapter is the only zone allowed to own bridge and browser
// capabilities, so moving a capability out of legacy renderer code and
// behind an adapter is a debt-reducing move, not new debt.
if (
targetZone.kind === 'shell' ||
targetZone.kind === 'platform' ||
isPublicApplicationPath(targetRelative)
) {
return true;
}
const targetFeature = featureForAbsolutePath(desktopRoot, target);
return Boolean(targetFeature && isPublicFeaturePath(targetFeature.subpath));
}
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/scripts/check-renderer-architecture.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2298,6 +2298,52 @@ describe('renderer architecture checker fixtures', () => {
);
});

it('allows legacy AppShell dependency replacement with a Desktop adapter', async () => {
const appShellPath = 'src/renderer/app-shell.tsx';
const appShellSource = `
import { readSetting } from './platform/desktop/read-setting.js';
export const AppShell = readSetting;
`;
const currentDebt = debtForSource(appShellSource, appShellPath);
const baseDebt = {
...currentDebt,
dependencyPaths: { './legacy-setting-owner.js': 1 },
};
const ownership = [
{
capability: 'fixture-app-shell',
targetZone: 'shell',
legacyPaths: [appShellPath],
},
];
const currentConfig = architectureConfig({
legacyFiles: { [appShellPath]: currentDebt },
legacyRendererFiles: [appShellPath],
ownership,
});
const baseConfig = architectureConfig({
legacyFiles: { [appShellPath]: baseDebt },
legacyRendererFiles: [appShellPath],
ownership,
});

await withDesktopFixture(
{
[appShellPath]: appShellSource,
// A Desktop adapter is the only zone allowed to own the bridge, so
// handing a capability to one is how legacy renderer debt gets paid off.
'src/renderer/platform/desktop/read-setting.ts': `
export function readSetting(): string | undefined {
return window.maka.settings.getClient === undefined ? undefined : 'set';
}
`,
},
(desktopRoot) => {
assert.deepEqual(violationsFor(desktopRoot, currentConfig, baseConfig), []);
},
);
});

it('rejects replacing legacy AppShell debt with a feature private import', async () => {
const appShellPath = 'src/renderer/app-shell.tsx';
const appShellSource = `
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/main/__tests__/client-settings-ipc-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ test("client settings updates filter Host policy and return newly submitted secr
return settings;
},
} as never,
chooseDefaultWorkingDirectory: async () => undefined,
apply: async () => {
applied += 1;
},
Expand All @@ -68,3 +69,24 @@ test("client settings updates filter Host policy and return newly submitted secr
assert.equal(settings.chatDefaults.permissionMode, "ask");
assert.equal(applied, 1);
});

// The default working directory is client-owned (`projects` is a client-tier
// section), so its folder picker is registered on the client channel and stays
// reachable regardless of which Runtime Host is selected.
test("the default working directory picker answers on the client channel", async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
registerClientSettingsIpc({
ipcMain: {
handle(channel, listener) {
handlers.set(channel, listener as (...args: unknown[]) => unknown);
},
},
settingsStore: { get: async () => createDefaultSettings() } as never,
apply: async () => {},
chooseDefaultWorkingDirectory: async () => "/Users/example/agent",
});

const choose = handlers.get("settings:client:chooseDefaultWorkingDirectory");
assert.ok(choose);
assert.equal(await choose({}), "/Users/example/agent");
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* 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.
*/

/**
* The default working directory is a client-owned, local-only preference. A
* Host-backed Runtime Host cannot own it and its ProjectRootController never
* receives `defaultWorkingDirectory`, so offering the control there would let a
* user save a path the target is incapable of using.
*
* These tests pin two things: the capability gate agrees with the boundary the
* main process uses to build `setLocalDefault`, and the save lands in the
* client-owned (per-machine) `projects` section rather than anything
* Host-shared.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { UpdateAppSettingsInput } from '@maka/core/settings';
import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind';
import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind';
import {
canSetLocalDefaultWorkingDirectory,
resolveDefaultWorkingDirectoryPatch,
} from '../../renderer/platform/desktop/settings-surface-capabilities.js';

const CHOSEN_DIRECTORY = '/Users/example/picked';

const PROFILE_KINDS: readonly RuntimeHostProfileKind[] = ['local', 'environment', 'remote'];

test('only a client-owned workspace may set a local default directory', () => {
assert.equal(canSetLocalDefaultWorkingDirectory('local'), true);
assert.equal(canSetLocalDefaultWorkingDirectory('environment'), false);
assert.equal(canSetLocalDefaultWorkingDirectory('remote'), false);
});

test('the gate tracks the same boundary the main process derives setLocalDefault from', () => {
// `runtime-host-boot.ts` builds `setLocalDefault: !usesHostWorkspace`. If a new
// profile kind ever disagrees with that, the control would offer to save a
// path its target cannot use.
for (const kind of PROFILE_KINDS) {
assert.equal(
canSetLocalDefaultWorkingDirectory(kind),
!runtimeHostProfileUsesHostWorkspace(kind),
`gate disagrees with the main-process capability for ${kind}`,
);
}
});

test('an unknown target cannot set the directory', () => {
// No selected Runtime Host means no answer yet; staying hidden beats guessing.
assert.equal(canSetLocalDefaultWorkingDirectory(undefined), false);
});

test('choosing a folder patches the client-owned Project preferences', async () => {
const { patches, pickerCalls } = await runRowAction('choose', CHOSEN_DIRECTORY);

assert.deepEqual(patches, [{ projects: { defaultWorkingDirectory: CHOSEN_DIRECTORY } }]);
assert.equal(pickerCalls, 1);
});

test('a cancelled picker is not a request to clear the directory', async () => {
const { patches, pickerCalls } = await runRowAction('choose', undefined);

assert.equal(pickerCalls, 1);
assert.deepEqual(patches, []);
});

test('clearing sends an undefined directory and never opens a picker', async () => {
const { patches, pickerCalls } = await runRowAction('clear', CHOSEN_DIRECTORY);

assert.deepEqual(patches, [{ projects: { defaultWorkingDirectory: undefined } }]);
assert.equal(pickerCalls, 0);
});

/**
* Drives the row's real decision function.
*
* `GeneralDefaultsCard` renders the row inline and reaches the outside world only
* through the `onSaveWorkingDirectory` callback the Settings surface passes in.
* `resolveDefaultWorkingDirectoryPatch` is the decision behind that callback, so
* exercising it pins what the row actually owns: which patch it sends, and
* whether it opens a picker at all.
*/
async function runRowAction(
action: 'choose' | 'clear',
chosenDirectory: string | undefined,
): Promise<{ patches: UpdateAppSettingsInput[]; pickerCalls: number }> {
const patches: UpdateAppSettingsInput[] = [];
let pickerCalls = 0;

const patch = await resolveDefaultWorkingDirectoryPatch(action, async () => {
pickerCalls += 1;
return chosenDirectory;
});
if (patch) patches.push(patch);

return { patches, pickerCalls };
}
51 changes: 48 additions & 3 deletions apps/desktop/src/main/__tests__/project-management-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
Expand All @@ -31,6 +31,7 @@ import {
createProjectManagementService,
type ProjectManagementCatalog,
} from '../project-management-service.js';
import { createProjectRootController } from '../project-root-controller.js';

const LOCAL_CAPABILITIES = {
chooseClientDirectory: true,
Expand Down Expand Up @@ -213,6 +214,7 @@ test('keeps an explicit no-Project selection local to Desktop', async () => {

test('does not silently replace a stale Project preference with another Project', async () => {
const selections: Array<{ projectId: string | null; path: string }> = [];
let currentSelection = { projectId: 'missing' as string | null, path: '/last-known' };
const service = createProjectManagementService({
capabilities: LOCAL_CAPABILITIES,
catalog: {
Expand All @@ -225,15 +227,58 @@ test('does not silently replace a stale Project preference with another Project'
},
chooseDirectory: async () => undefined,
selection: {
currentSelection: async () => ({ projectId: 'missing', path: '/last-known' }),
setSelection: (projectId, path) => selections.push({ projectId, path }),
currentSelection: async () => currentSelection,
setSelection: (projectId, path) => {
currentSelection = { projectId, path };
selections.push({ projectId, path });
},
},
});

assert.deepEqual(await service.current(), { projectId: null, path: '/last-known' });
assert.deepEqual(selections, [{ projectId: null, path: '/last-known' }]);
});

test('a stale Project preference recovers through the configured default directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-stale-default-'));
const fallback = join(base, 'fallback');
const configuredDefault = join(base, 'configured-default');
const preferenceFile = join(base, 'project-preferences.json');
await Promise.all([mkdir(fallback), mkdir(configuredDefault)]);
await writeFile(
preferenceFile,
JSON.stringify({ version: 1, selections: { 'root-a': 'deleted-project' } }),
);
const selection = createProjectRootController({
rootId: 'root-a',
preferenceFile,
fallbackRoots: () => [fallback],
defaultWorkingDirectory: async () => configuredDefault,
});
const service = createProjectManagementService({
capabilities: LOCAL_CAPABILITIES,
catalog: {
list: async () => [],
register: unexpected,
relink: unexpected,
rename: unexpected,
archive: unexpected,
restore: unexpected,
},
chooseDirectory: async () => undefined,
selection,
});

try {
assert.deepEqual(await service.current(), {
projectId: null,
path: configuredDefault,
});
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('does not expose Client directory actions for a remote Host', async () => {
let pickerCalls = 0;
const directoryRequests: unknown[] = [];
Expand Down
Loading
Loading