Skip to content

Commit b63e275

Browse files
committed
fix(desktop): isolate Settings state by Runtime Host
Bind asynchronous Host settings and connection snapshots to their selected target so late or failed loads cannot surface data from another Host. Keep Desktop-owned preferences available independently, preserve every-Host connection events, and make profile catalog failures recoverable. Generated-by: Codex
1 parent c54a209 commit b63e275

13 files changed

Lines changed: 550 additions & 184 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from 'node:assert/strict';
2+
import { afterEach, test } from 'node:test';
3+
import { act, createElement } from 'react';
4+
import { createDefaultSettings } from '@maka/core/settings';
5+
import {
6+
useKeepSystemAwake,
7+
type KeepSystemAwakeController,
8+
} from '../../renderer/use-keep-system-awake.js';
9+
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
10+
11+
test('keeps the Desktop preference available when no Runtime Host settings bridge is usable', async () => {
12+
const { root } = installReactRenderer();
13+
let persisted = {
14+
...createDefaultSettings(),
15+
system: { keepSystemAwake: true },
16+
};
17+
const updates: boolean[] = [];
18+
(globalThis.window as unknown as { maka: unknown }).maka = {
19+
settings: {
20+
getClient: async () => persisted,
21+
updateClient: async (patch: { system?: { keepSystemAwake?: boolean } }) => {
22+
const keepSystemAwake = patch.system?.keepSystemAwake ?? persisted.system.keepSystemAwake;
23+
updates.push(keepSystemAwake);
24+
persisted = { ...persisted, system: { keepSystemAwake } };
25+
return { settings: persisted };
26+
},
27+
subscribeClientChanged: () => () => undefined,
28+
},
29+
};
30+
31+
let current: KeepSystemAwakeController | undefined;
32+
function Probe() {
33+
current = useKeepSystemAwake();
34+
return null;
35+
}
36+
37+
await act(async () => {
38+
root.render(createElement(Probe));
39+
});
40+
assert.equal(current?.keepSystemAwake, true);
41+
42+
await act(async () => {
43+
await current?.setKeepSystemAwake(false);
44+
});
45+
assert.deepEqual(updates, [false]);
46+
assert.equal(current?.keepSystemAwake, false);
47+
});
48+
49+
afterEach(() => {
50+
cleanupFakeDom();
51+
delete (globalThis as { window?: unknown }).window;
52+
});

apps/desktop/src/preload/preload.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1920,7 +1920,12 @@ const makaBridge = {
19201920
return invokeSelectedRuntimeHost(host, 'connections:setRequestHeaders', slug, headers);
19211921
},
19221922
subscribeEvents(handler: (event: ConnectionEvent) => void, host?: DesktopRuntimeHostRef): () => void {
1923-
return subscribeSelectedRuntimeHostEvent('connections:event', host, handler);
1923+
return host
1924+
? subscribeSelectedRuntimeHostEvent('connections:event', host, handler)
1925+
: subscribeEveryRuntimeHostEvent(
1926+
'connections:event',
1927+
(_scope, event: ConnectionEvent) => handler(event),
1928+
);
19241929
},
19251930
},
19261931
mcp: {

apps/desktop/src/renderer/app-shell-effects.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,10 +404,16 @@ export function useAppShellBootstrapSubscriptions(options: {
404404
const unsubscribeConnections = window.maka.connections.subscribeEvents(handleConnectionSubscriptionEvent);
405405
const unsubscribeRuntimeHostChanges =
406406
window.maka.runtimeHostProfiles.subscribeChanges(handleRuntimeHostChange);
407-
const unsubscribeSettingsExternal = window.maka.settings.subscribeExternalChanged(() => {
407+
const refreshSettingsMirrors = () => {
408408
void options.refreshShellSettings();
409409
void options.refreshConnections();
410-
});
410+
};
411+
const unsubscribeSettingsExternal = window.maka.settings.subscribeExternalChanged(
412+
refreshSettingsMirrors,
413+
);
414+
const unsubscribeClientSettings = window.maka.settings.subscribeClientChanged(
415+
refreshSettingsMirrors,
416+
);
411417
const unsubscribeSessionChanges = window.maka.sessions.subscribeChanges(handleSessionChange);
412418
const unsubscribeScheduledTaskChanges = window.maka.scheduledTasks.subscribeChanges(handleScheduledTaskChange);
413419
const unsubscribeScheduledTaskDue = window.maka.scheduledTasks.subscribeDue(handleScheduledTaskDue);
@@ -419,6 +425,7 @@ export function useAppShellBootstrapSubscriptions(options: {
419425
unsubscribeConnections();
420426
unsubscribeRuntimeHostChanges();
421427
unsubscribeSettingsExternal();
428+
unsubscribeClientSettings();
422429
unsubscribeSessionChanges();
423430
unsubscribeScheduledTaskChanges();
424431
unsubscribeScheduledTaskDue();

apps/desktop/src/renderer/settings/general-settings-page.tsx

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,36 +44,61 @@ import { getSettingsPreferencesCopy } from "../locales/settings-preferences-copy
4444
import { settingsTestResultMessage } from "../locales/settings-test-result-copy.js";
4545
import { getShellCopy } from "../locales/shell-copy.js";
4646
import type { RuntimeHostSettingsConnectionsBridge } from './runtime-host-settings-bridge.js';
47+
import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
4748

4849
export function GeneralSettingsPage(props: {
4950
settings: AppSettings;
5051
connections: readonly LlmConnection[];
5152
defaultSlug: string | null;
52-
connectionsBridge: Pick<RuntimeHostSettingsConnectionsBridge, 'setDefaultModel'>;
53-
testNetworkProxy(input: TestProxyInput): Promise<import('@maka/core/settings').SettingsTestResult>;
53+
connectionsBridge: Pick<RuntimeHostSettingsConnectionsBridge, 'setDefaultModel'> | undefined;
54+
runtimeHostStatus: 'loading' | 'ready' | 'unavailable' | 'error';
55+
testNetworkProxy?(input: TestProxyInput): Promise<import('@maka/core/settings').SettingsTestResult>;
5456
onUpdate(
5557
patch: Parameters<typeof window.maka.settings.update>[0],
5658
): Promise<UpdateAppSettingsResult>;
5759
onRefreshConnections(): Promise<void>;
60+
onRetryRuntimeHost(): Promise<void>;
5861
}) {
5962
const locale = useUiLocale();
6063
const copy = getSettingsPreferencesCopy(locale).general;
6164
const sections = getSettingsPreferencesCopy(locale).sections;
65+
const sharedCopy = getSettingsSharedCopy(locale);
6266
const toast = useToast();
67+
const runtimeHostAvailable =
68+
props.runtimeHostStatus === 'ready' &&
69+
props.connectionsBridge !== undefined &&
70+
props.testNetworkProxy !== undefined;
6371
return (
6472
<SettingsPage>
73+
{!runtimeHostAvailable ? (
74+
<Banner
75+
status={props.runtimeHostStatus === 'error' ? 'error' : 'warning'}
76+
title={props.runtimeHostStatus === 'loading'
77+
? sharedCopy.loading
78+
: sharedCopy.runtimeHostUnavailable}
79+
endContent={props.runtimeHostStatus === 'error' ? (
80+
<Button
81+
variant="secondary"
82+
size="sm"
83+
label={sharedCopy.retry}
84+
onClick={() => void props.onRetryRuntimeHost()}
85+
/>
86+
) : undefined}
87+
/>
88+
) : null}
6589
{/* Designer audit P2-13: identity fields (显示名称/界面语言/语气偏好)
6690
moved here from the 外观 page — they configure who you are to the
6791
app, not how the app looks. The component keeps its save flow. */}
6892
<PersonalizationSettingsSection
6993
settings={props.settings}
94+
runtimeHostAvailable={runtimeHostAvailable}
7095
onUpdate={props.onUpdate}
7196
/>
7297
<SettingsSection
7398
title={sections.privacy}
7499
description={sections.privacyHelp}
75100
>
76-
<SettingsRow
101+
{runtimeHostAvailable ? <SettingsRow
77102
label={copy.incognito}
78103
description={copy.incognitoHelp}
79104
end={
@@ -93,7 +118,7 @@ export function GeneralSettingsPage(props: {
93118
}}
94119
/>
95120
}
96-
/>
121+
/> : null}
97122
<SettingsRow
98123
label={copy.notifications}
99124
description={copy.notificationsHelp}
@@ -115,7 +140,7 @@ export function GeneralSettingsPage(props: {
115140
/>
116141
}
117142
/>
118-
<SettingsRow
143+
{runtimeHostAvailable ? <SettingsRow
119144
label={copy.workspaceInstructions}
120145
description={copy.workspaceInstructionsHelp}
121146
end={
@@ -135,27 +160,31 @@ export function GeneralSettingsPage(props: {
135160
}}
136161
/>
137162
}
138-
/>
139-
</SettingsSection>
140-
<GeneralDefaultsCard
141-
connections={props.connections}
142-
defaultSlug={props.defaultSlug}
143-
connectionsBridge={props.connectionsBridge}
144-
onRefresh={props.onRefreshConnections}
145-
permissionMode={props.settings.chatDefaults.permissionMode}
146-
thinkingLevel={props.settings.chatDefaults.thinkingLevel}
147-
onUpdate={props.onUpdate}
148-
/>
149-
<SettingsSection
150-
title={sections.network}
151-
description={sections.networkHelp}
152-
>
153-
<NetworkProxySection
154-
settings={props.settings}
155-
onUpdate={props.onUpdate}
156-
testNetworkProxy={props.testNetworkProxy}
157-
/>
163+
/> : null}
158164
</SettingsSection>
165+
{runtimeHostAvailable ? (
166+
<>
167+
<GeneralDefaultsCard
168+
connections={props.connections}
169+
defaultSlug={props.defaultSlug}
170+
connectionsBridge={props.connectionsBridge!}
171+
onRefresh={props.onRefreshConnections}
172+
permissionMode={props.settings.chatDefaults.permissionMode}
173+
thinkingLevel={props.settings.chatDefaults.thinkingLevel}
174+
onUpdate={props.onUpdate}
175+
/>
176+
<SettingsSection
177+
title={sections.network}
178+
description={sections.networkHelp}
179+
>
180+
<NetworkProxySection
181+
settings={props.settings}
182+
onUpdate={props.onUpdate}
183+
testNetworkProxy={props.testNetworkProxy!}
184+
/>
185+
</SettingsSection>
186+
</>
187+
) : null}
159188
</SettingsPage>
160189
);
161190
}

apps/desktop/src/renderer/settings/personalization-settings-section.tsx

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy
3030

3131
export function PersonalizationSettingsSection(props: {
3232
settings: AppSettings;
33+
runtimeHostAvailable: boolean;
3334
onUpdate(patch: Parameters<typeof window.maka.settings.update>[0]): Promise<UpdateAppSettingsResult>;
3435
}) {
3536
const locale = useUiLocale();
@@ -152,42 +153,41 @@ export function PersonalizationSettingsSection(props: {
152153
the user to fill in something already filled in, and its blur-save
153154
gave them no way to back out of a change. The row reports the
154155
settled value and opens on demand; Cancel puts the draft back. */}
155-
<SettingsExpandableRow
156-
label={copy.displayName}
157-
value={value.displayName || copy.displayNameUnset}
158-
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
159-
isEditing={expandedRow === 'displayName'}
160-
canSave={displayName.trim() !== value.displayName}
161-
saveLabel={sharedCopy.save}
162-
cancelLabel={sharedCopy.cancel}
163-
onEdit={() => {
164-
setDisplayName(value.displayName);
165-
setExpandedRow('displayName');
166-
}}
167-
onCancel={() => {
168-
setDisplayName(value.displayName);
169-
setExpandedRow(null);
170-
}}
171-
onSave={async () => {
172-
// Only close on a write that landed: a failed save leaves the row
173-
// open with the draft intact, which is the promise explicit saving
174-
// makes and blur-autosave could not keep.
175-
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
176-
setExpandedRow(null);
177-
}
178-
}}
179-
>
180-
<TextInput
181-
type="text"
182-
value={displayName}
183-
onChange={(value) => setDisplayName(value.slice(0, 60))}
184-
placeholder={copy.displayNamePlaceholder}
156+
{props.runtimeHostAvailable ? (
157+
<SettingsExpandableRow
185158
label={copy.displayName}
186-
description={copy.displayNameHelp}
187-
isLabelHidden
188-
width="100%"
189-
/>
190-
</SettingsExpandableRow>
159+
value={value.displayName || copy.displayNameUnset}
160+
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
161+
isEditing={expandedRow === 'displayName'}
162+
canSave={displayName.trim() !== value.displayName}
163+
saveLabel={sharedCopy.save}
164+
cancelLabel={sharedCopy.cancel}
165+
onEdit={() => {
166+
setDisplayName(value.displayName);
167+
setExpandedRow('displayName');
168+
}}
169+
onCancel={() => {
170+
setDisplayName(value.displayName);
171+
setExpandedRow(null);
172+
}}
173+
onSave={async () => {
174+
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
175+
setExpandedRow(null);
176+
}
177+
}}
178+
>
179+
<TextInput
180+
type="text"
181+
value={displayName}
182+
onChange={(value) => setDisplayName(value.slice(0, 60))}
183+
placeholder={copy.displayNamePlaceholder}
184+
label={copy.displayName}
185+
description={copy.displayNameHelp}
186+
isLabelHidden
187+
width="100%"
188+
/>
189+
</SettingsExpandableRow>
190+
) : null}
191191
{/*
192192
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
193193
acceptance criteria): 自动 / 中文 / English. User explicit
@@ -207,7 +207,7 @@ export function PersonalizationSettingsSection(props: {
207207
))}
208208
</SegmentedControl>}
209209
/>
210-
<SettingsField>
210+
{props.runtimeHostAvailable ? <SettingsField>
211211
{/* No fixed height style: it lands as inline style on the wrapper,
212212
which pins the visible box while the inner textarea keeps its
213213
native `resize: vertical` — dragging then moves only the grip.
@@ -228,7 +228,7 @@ export function PersonalizationSettingsSection(props: {
228228
description={copy.assistantToneHelp}
229229
width="100%"
230230
/>
231-
</SettingsField>
231+
</SettingsField> : null}
232232
</SettingsSection>
233233
);
234234
}

0 commit comments

Comments
 (0)