blob: 4708c1a5fdf88d40dd7b39b9ad06bba71bb9edca [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js';
import type { OnboardingMilestone } from './onboarding.js';
import { sanitizeOnboardingMilestones } from './onboarding.js';
import type { WebSearchSettingsPatch, WebSearchSettings } from './web-search.js';
import type { BotChatSettings, BotChatSettingsPatch } from './bot-chat-settings.js';
import {
createDefaultBotChatSettings,
mergeBotChatSettings,
normalizeBotChatSettings,
} from './bot-chat-settings.js';
import type { LocalMemorySettings } from './local-memory.js';
import {
defaultWebSearchSettings,
mergeWebSearchSettings,
normalizeWebSearchSettings,
} from './web-search.js';
import { defaultLocalMemorySettings, normalizeLocalMemorySettings } from './local-memory.js';
import type { PermissionMode } from './permission.js';
import { decodePersistedPermissionMode } from './permission.js';
import type { UsageProvenance } from './usage-ledger-merge.js';
import {
UI_LOCALE_PREFERENCES,
isUiLocalePreference,
normalizeUiLocalePreference,
type UiLocalePreference,
} from './ui-locale.js';
import { normalizeSubagentSettings, type SubagentSettings } from './subagent-settings.js';
import { isPetPackId } from './pet.js';
export { UI_LOCALE_PREFERENCES, isUiLocalePreference } from './ui-locale.js';
export type { UiLocalePreference } from './ui-locale.js';
export type {
BotChannelSettings,
BotChatSettings,
BotDeliveryProvider,
BotProvider,
BotReadinessState,
} from './bot-chat-settings.js';
export {
BOT_DELIVERY_PROVIDERS,
BOT_PROVIDERS,
BOT_READINESS_STATES,
MAX_ALLOWED_USER_IDS,
createDefaultBotChannel,
hasBotChannelCredentials,
isBotDeliveryProvider,
isBotReadinessState,
normalizeAllowedUserIds,
parseAllowedUserIdsFromText,
} from './bot-chat-settings.js';
export const SETTINGS_SECTIONS = [
'general',
'appearance',
'projects',
'memory',
'daily-review',
'models',
'subagents',
'external-agents',
'usage',
// `maka://settings/<section>` is a public deep link, so the id names what
// the page is rather than the noun it lives under.
'archived-tasks',
'import-tasks',
'bot-chat',
'search',
'data',
'permissions',
'health',
'about',
] as const;
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
export type ProxyProtocol = 'http' | 'https' | 'socks5';
export interface NetworkProxySettings {
enabled: boolean;
protocol: ProxyProtocol;
host: string;
port: number;
authEnabled: boolean;
username: string;
bypassList: string[];
autoBypassDomains: string[];
}
export interface NetworkProxyCredentialTarget {
readonly protocol: ProxyProtocol;
readonly host: string;
readonly port: number;
readonly username: string;
}
export function networkProxyCredentialTarget(
proxy: Pick<NetworkProxySettings, 'protocol' | 'host' | 'port' | 'username'>,
): NetworkProxyCredentialTarget {
return {
protocol: proxy.protocol,
host: proxy.host.trim().toLowerCase(),
port: proxy.port,
username: proxy.username,
};
}
export type NetworkProxyCredentialOperation =
| {
kind: 'replace';
secret: string;
expectedTarget?: NetworkProxyCredentialTarget;
}
| { kind: 'delete' };
/** A write-only proxy patch. Credential operations are never persisted. */
export type NetworkProxySettingsPatch = Partial<NetworkProxySettings> & {
credential?: NetworkProxyCredentialOperation;
};
/** Runtime Host read projection; the saved secret itself never crosses IPC. */
export interface RuntimeHostNetworkProxySettings extends NetworkProxySettings {
readonly passwordConfigured: boolean;
}
/**
* Persisted application network settings. Runtime proxy execution uses the
* separate contract in `settings/network-settings.ts`.
*/
export interface AppNetworkSettings {
proxy: NetworkProxySettings;
}
export type UsageRange = '24h' | '7d' | '30d' | 'all';
export type UsageStatus = 'all' | 'success' | 'error' | 'aborted';
export type UsageTab = 'requests' | 'providers' | 'models' | 'tools' | 'pricing';
export interface UsageSettings {
range: UsageRange;
status: UsageStatus;
modelFilter: string;
showDetails: boolean;
activeTab: UsageTab;
}
export type ThemePreference = 'light' | 'dark' | 'auto';
/** Palette ids shared with `[data-maka-theme]` CSS selectors. */
export const THEME_PALETTES = [
'default',
'onedark',
'catppuccin-mocha',
'tokyo-night',
'nord',
// Product accent palettes named by color family. `coral` warm pink,
// `azure` cool blue; `forest` deep moss, `dusk` violet twilight,
// `sand` warm amber on cream, `mono` distraction-free grayscale.
'coral',
'azure',
'forest',
'dusk',
'sand',
'mono',
] as const;
export type ThemePalette = (typeof THEME_PALETTES)[number];
export function isThemePalette(value: unknown): value is ThemePalette {
return typeof value === 'string' && (THEME_PALETTES as readonly string[]).includes(value);
}
/**
* Which artwork the OS shows for Maka: the dock tile on macOS, the window
* and taskbar icon on Windows/Linux. Every id maps to one PNG shipped with
* the desktop app (see `resolveAppIconPath` in apps/desktop); `default` is
* the brand mark in `apps/desktop/assets/icon.png`.
*
* A closed enum rather than a path: the renderer never names a file, so a
* settings file edited by hand can only ever select artwork that ships with
* the build.
*/
export const APP_ICONS = [
// The brand mark and its grayscale companion.
'default',
'mono',
// The geometric M set: one drawing, recoloured. Ids name the colourway, not
// the artwork, so a repaint never invalidates a settings file already on
// disk. Ordered by family, following the order the icon discussion used —
// but not one-to-one with its numbering: three near-duplicate blues that
// were cut from the set before it shipped are still absent, so match a
// number from that thread to a tile by id, not by position.
// Blue
'sky',
'cyan',
'ice',
'pale-inverted',
// Monochrome
'ink',
'paper',
'graphite',
// Pencil
'pencil-kraft',
'pencil-sky',
'pencil-navy',
// Alpine
'alpine',
'dusk',
'night',
'forest',
// Dark — sized for a dark dock, where a mid-tone tile glows like a light leak
'midnight',
'carbon',
'slate',
'obsidian',
// Neon / terminal
'neon-cyan',
'matrix',
'magenta',
'amber-crt',
// Muted
'clay',
'sage',
'dust',
'fog',
// Warm
'sunset',
'amber',
'terracotta',
// Nature
'ocean',
'moss',
'desert',
'glacier',
// Metal
'gold',
'chrome',
// High contrast — one-colour printing and 7:1
'mono-black',
'mono-white',
'hazard',
] as const;
export type AppIcon = (typeof APP_ICONS)[number];
export function isAppIcon(value: unknown): value is AppIcon {
return typeof value === 'string' && (APP_ICONS as readonly string[]).includes(value);
}
/**
* User-imported artwork is referenced as `custom:<id>` rather than by path.
*
* The id is generated by the main process and is the *whole* file name it will
* resolve under the icon directory it owns, so the charset is what keeps a
* hand-edited settings file from naming `../../…`. Nothing else about a custom
* icon is persisted: the artwork itself is a normalized copy the app already
* holds, so a settings file remains portable in the only sense that matters —
* an unknown id degrades to the brand mark rather than to a broken tile.
*/
export const CUSTOM_APP_ICON_PREFIX = 'custom:';
const CUSTOM_APP_ICON_ID = /^[0-9a-f]{32}$/;
export type CustomAppIcon = `${typeof CUSTOM_APP_ICON_PREFIX}${string}`;
/** Either a shipped id or a reference to imported artwork. */
export type AppIconChoice = AppIcon | CustomAppIcon;
export function isCustomAppIcon(value: unknown): value is CustomAppIcon {
return (
typeof value === 'string' &&
value.startsWith(CUSTOM_APP_ICON_PREFIX) &&
CUSTOM_APP_ICON_ID.test(value.slice(CUSTOM_APP_ICON_PREFIX.length))
);
}
export function isAppIconChoice(value: unknown): value is AppIconChoice {
return isAppIcon(value) || isCustomAppIcon(value);
}
/**
* Coerce anything to a usable choice.
*
* `normalizeSettings` runs when settings are READ from disk; an in-process
* update returns the merged object without passing through it, so a patch that
* carried an arbitrary string reaches the main process as-is. The main process
* turns this value into a file path, so every runtime ingress coerces here
* rather than trusting the declared type.
*/
export function toAppIconChoice(value: unknown): AppIconChoice {
return isAppIconChoice(value) ? value : 'default';
}
/** The bare id of an imported icon, or undefined for the shipped set. */
export function customAppIconId(choice: AppIconChoice): string | undefined {
return isCustomAppIcon(choice) ? choice.slice(CUSTOM_APP_ICON_PREFIX.length) : undefined;
}
/**
* Which appearance a selection is for.
*
* `both` is not "write the same id twice": it CLEARS the dark slot, which is
* the only way back to one-icon-everywhere. A settings file with no dark slot
* and one whose slots happen to match look identical in the dock but not in
* the picker, and only the first keeps following the light choice when the
* user later changes it.
*/
export type AppIconTarget = 'both' | 'light' | 'dark';
export function isAppIconTarget(value: unknown): value is AppIconTarget {
return value === 'both' || value === 'light' || value === 'dark';
}
/**
* What a fresh install shows.
*
* The split starts OFF: `appIcon` alone serves both appearances, so an install
* nobody has touched shows one tile everywhere. `DEFAULT_APP_ICON_DARK` is
* what the dark slot is seeded with when someone turns the split ON — a
* recommendation offered at that moment, not something applied behind their
* back.
*/
export const DEFAULT_APP_ICON: AppIcon = 'sky';
export const DEFAULT_APP_ICON_DARK: AppIcon = 'ink';
/** The icon half of a fresh install's appearance, for resolving startup state. */
const DEFAULT_APP_ICON_APPEARANCE: Pick<AppearanceSettings, 'appIcon' | 'appIconDark'> = {
appIcon: DEFAULT_APP_ICON,
};
/**
* The dark slot as it should be stored, given what the settings file said.
*
* Three cases, and they are genuinely different:
* - no appearance block at all — a fresh install, which gets the shipped pair
* - an appearance block with no dark key — a file written before this option
* existed, which means "one icon for both" and must stay that way
* - a dark key present — validated, falling back only if it is malformed
*/
function normalizedDarkAppIcon(
raw: Partial<AppearanceSettings> | undefined,
fresh: AppIconChoice | undefined,
): { appIconDark?: AppIconChoice } {
if (raw === undefined) return fresh === undefined ? {} : { appIconDark: fresh };
if (!('appIconDark' in raw)) return {};
if (raw.appIconDark === undefined) return {};
return {
appIconDark: isAppIconChoice(raw.appIconDark) ? raw.appIconDark : DEFAULT_APP_ICON_DARK,
};
}
/**
* The icon the app puts up before it has read any settings.
*
* Two callers must agree on this exactly: the startup path applies it to the
* dock synchronously, and the settings effect seeds its "already applied"
* state with it. If they disagreed, every launch would either re-decode a
* 1024px PNG for nothing or leave the seeded value showing.
*/
export function startupAppIcon(systemPrefersDark: boolean): AppIconChoice {
// Derived rather than restated: this must agree with what a fresh install
// resolves to, and writing the pair out again here is exactly how the two
// drift apart the next time a default changes.
return appIconForTheme(DEFAULT_APP_ICON_APPEARANCE, systemPrefersDark);
}
/**
* The icon for one appearance.
*
* `appIconDark` left unset means "use one icon everywhere", which is what
* every settings file written before this option existed says — so an upgrade
* keeps showing the tile the user picked instead of silently gaining a second
* one they never chose.
*/
export function appIconForTheme(
appearance: Pick<AppearanceSettings, 'appIcon' | 'appIconDark'>,
isDark: boolean,
): AppIconChoice {
const light = toAppIconChoice(appearance.appIcon);
if (!isDark) return light;
return appearance.appIconDark === undefined ? light : toAppIconChoice(appearance.appIconDark);
}
/**
* UI base font size in px, exposed as a numeric stepper like Codex's
* "UI font size". The renderer's type scale is generated from base 14
* (`makaTheme.ts`), and every `--font-size-*` token is `rem`, so the applied
* document-root font-size scales proportionally as `16 * uiFontSize / 14`.
* This scales what is rem-derived — text and Astryx's rem-based icon atoms —
* while px-literal spacing and control widths stay fixed, which is why the
* range is clamped tightly around the base rather than offered as a free
* zoom. It is NOT the density hack removed in `makaTheme.ts`.
*
* Continuous within a clamped range: a wrong-typed value fails closed to the
* default, an out-of-range number clamps to the nearest bound (a valid intent,
* just bounded — so an extreme persisted value can't make the UI unusable).
*/
export const UI_FONT_SIZE_MIN = 11;
export const UI_FONT_SIZE_MAX = 22;
export const DEFAULT_UI_FONT_SIZE = 14;
/** Terminal (xterm) font size in px, same numeric-stepper treatment. */
export const TERMINAL_FONT_SIZE_MIN = 9;
export const TERMINAL_FONT_SIZE_MAX = 24;
export const DEFAULT_TERMINAL_FONT_SIZE = 12;
function clampFontSize(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value)));
}
export function normalizeUiFontSize(value: unknown): number {
return clampFontSize(value, UI_FONT_SIZE_MIN, UI_FONT_SIZE_MAX, DEFAULT_UI_FONT_SIZE);
}
export function normalizeTerminalFontSize(value: unknown): number {
return clampFontSize(
value,
TERMINAL_FONT_SIZE_MIN,
TERMINAL_FONT_SIZE_MAX,
DEFAULT_TERMINAL_FONT_SIZE,
);
}
export interface AppearanceSettings {
theme: ThemePreference;
/** Optional palette override; missing values normalize to `default`. */
palette?: ThemePalette;
/** Optional app-icon override; missing values normalize to the default. */
appIcon?: AppIconChoice;
/**
* Optional separate icon for dark appearance. Absent means the one in
* `appIcon` is used in both.
*/
appIconDark?: AppIconChoice;
/** Optional UI base font size in px. Missing normalizes to the default. */
uiFontSize?: number;
/** Optional terminal font size in px. Missing normalizes to the default. */
terminalFontSize?: number;
}
export interface PersonalizationSettings {
/** How the assistant addresses the user. Empty falls back to "你". */
displayName: string;
/** Inline tone preference shown to the model in its system prompt. */
assistantTone: string;
/** UI locale preference; defaults to `auto`. */
uiLocale: UiLocalePreference;
/** User-selected custom PetPack. `null` keeps the pet surface disabled. */
selectedPetId: string | null;
}
/** Persisted onboarding milestones; derived onboarding state is not stored. */
export interface OnboardingSettings {
milestones: OnboardingMilestone[];
}
export interface WorkspaceInstructionsSettings {
enabled: boolean;
}
/** Default project identity for new conversations. */
export interface ProjectPreferencesSettings {
defaultProjectId?: string;
}
export interface PrivacySettings {
incognitoActive: boolean;
}
/**
* `explore` is excluded — it's reserved for Deep Research sessions and
* Bot-incoming guards and is never a mode the user picks, in the composer
* dropdown or here. Derived from the canonical PERMISSION_MODES (not a
* hand-copied literal) so adding a future mode updates every consumer —
* the Settings picker, the composer picker (@maka/ui re-exports this
* list as PERMISSION_MODE_ORDER), and the settings validation — in one
* place.
*/
export type ChatDefaultPermissionMode = Extract<PermissionMode, 'ask' | 'bypass'>;
export const CHAT_DEFAULT_PERMISSION_MODES: readonly ChatDefaultPermissionMode[] = [
'ask',
'bypass',
];
export function isChatDefaultPermissionMode(value: unknown): value is ChatDefaultPermissionMode {
return (
typeof value === 'string' &&
(CHAT_DEFAULT_PERMISSION_MODES as readonly string[]).includes(value)
);
}
/** Seeds new sessions' starting permission mode (Settings → 通用 → 默认权限模式). */
export interface ChatDefaultsSettings {
permissionMode: ChatDefaultPermissionMode;
/** Applies only when a new task is created. */
codeModeEnabled?: boolean;
/**
* Seeds new sessions' thinking level. `undefined` means "whatever the model
* does on its own" — the absence of a preference, not a level.
*
* A chosen level is a wish, not a guarantee: models expose different ladders,
* so one that does not offer the chosen rung falls back to its own default
* for that session rather than being forced to the nearest neighbour. The
* composer already resolves it that way for the per-session picker.
*/
thinkingLevel?: ThinkingLevel;
}
/**
* Desktop OS notifications (Settings → 通用 → 通知). The runtime only
* knows a turn ended from the renderer; the main process owns the focus
* gate + native `Notification`, so this is a pure product on/off toggle.
*/
export interface NotificationSettings {
/**
* When enabled, the desktop app raises a native notification once an
* agent turn finishes (completed or errored) **while its window is not
* focused**. Focus + OS-permission gating live in the main process.
*/
runComplete: boolean;
}
/** Client-owned opt-in for the cross-Session WorkHub router. */
export interface WorkHubSettings {
enabled: boolean;
}
/**
* System-level power behavior (Settings surface: the 定时任务 page's
* capability row). Scheduled tasks are driven by an in-process timer; when
* the machine sleeps, that timer is frozen and reminders silently never
* fire. `keepSystemAwake` lets the user hold a power-save blocker so
* background scheduled work keeps running.
*
* The main process owns the actual Electron `powerSaveBlocker`
* (`prevent-app-suspension`, which keeps the system awake WITHOUT forcing
* the display on). This flag is the pure product on/off toggle, mirroring
* `notifications.runComplete`.
*/
export interface SystemSettings {
keepSystemAwake: boolean;
}
/** Host-machine shell preference used by Bash tools and interactive PTYs. */
export type ShellPreference = 'auto' | 'git_bash';
export interface ShellSettings {
/** `auto` preserves the platform default; `git_bash` is an explicit Windows override. */
preference: ShellPreference;
/** Absolute executable selected by the user. Retained while `auto` is active for easy reuse. */
executable: string;
}
export interface AppSettings {
schemaVersion: 1;
network: AppNetworkSettings;
botChat: BotChatSettings;
usage: UsageSettings;
appearance: AppearanceSettings;
personalization: PersonalizationSettings;
onboarding: OnboardingSettings;
webSearch: WebSearchSettings;
localMemory: LocalMemorySettings;
workspaceInstructions: WorkspaceInstructionsSettings;
privacy: PrivacySettings;
chatDefaults: ChatDefaultsSettings;
projects: ProjectPreferencesSettings;
notifications: NotificationSettings;
workHub: WorkHubSettings;
system: SystemSettings;
externalAgents: { antigravity: { executable: string } };
shell: ShellSettings;
subagents: SubagentSettings;
}
export interface RuntimeHostAppSettings extends Omit<AppSettings, 'network'> {
network: {
proxy: RuntimeHostNetworkProxySettings;
};
}
export interface UsageRequestLog {
id: string;
ts: number;
kind: 'model' | 'tool';
sessionId?: string;
/** Human-readable session title (SessionHeader.name); may be empty for untitled sessions. */
sessionName?: string;
turnId?: string;
provider: string;
model: string;
toolName?: string;
inputTokens: number;
outputTokens: number;
cacheMiss?: number;
cacheRead?: number;
cacheCreation?: number;
reasoning?: number;
costUsd?: number;
latencyMs?: number;
status: 'success' | 'error' | 'aborted';
}
export interface UsageSummary {
totalRequests: number;
totalCostUsd: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
cacheMiss: number;
cacheRead: number;
cacheCreation: number;
reasoning: number;
}
export interface UsageStats {
summary: UsageSummary;
logs: UsageRequestLog[];
byProvider: Array<{
provider: string;
requests: number;
tokens: number;
costUsd: number;
}>;
byModel: Array<{
model: string;
requests: number;
tokens: number;
costUsd: number;
}>;
byTool: Array<{
tool: string;
calls: number;
success: number;
errors: number;
avgDurationMs: number;
}>;
pricing: Array<{
provider: string;
model: string;
inputPerMTokUsd: number;
outputPerMTokUsd: number;
}>;
/**
* Coverage/legacy/unreadable/pending accounting behind these totals, so the
* page can qualify a cost that reads low (unpriced/unreadable/pending) rather
* than presenting it as authoritative. Same provenance the summary IPC and
* Session Inspector already carry.
*/
provenance: UsageProvenance;
/**
* True when the activity log was capped at MAX_ACTIVITY_RECORDS, so the page
* can say the list (and the log-derived breakdowns) are incomplete instead of
* silently showing a short list.
*/
logsTruncated?: boolean;
}
export interface SettingsTestResult {
ok: boolean;
code?: SettingsTestResultCode;
message: string;
latencyMs?: number;
details?: Record<string, unknown>;
}
export type SettingsTestResultCode =
| 'proxy_reachable'
| 'proxy_disabled'
| 'proxy_configuration_missing'
| 'proxy_credential_missing'
| 'proxy_timeout'
| 'proxy_http_error'
| 'proxy_unreachable'
| 'bot_credentials_valid'
| 'bot_token_missing'
| 'bot_token_invalid'
| 'bot_app_credentials_missing'
| 'slack_tokens_missing'
| 'wecom_credentials_missing'
| 'dingtalk_credentials_missing'
| 'dingtalk_no_access_token'
| 'qq_credentials_missing'
| 'qq_no_access_token'
| 'wechat_bridge_url_invalid'
| 'wechat_ilink_credentials_incomplete'
| 'bot_connection_failed';
export type UpdateAppSettingsInput = Partial<{
network: Partial<{
proxy: NetworkProxySettingsPatch;
}>;
botChat: BotChatSettingsPatch;
usage: Partial<UsageSettings>;
appearance: Partial<AppearanceSettings>;
personalization: Partial<PersonalizationSettings>;
localMemory: Partial<LocalMemorySettings>;
workspaceInstructions: Partial<WorkspaceInstructionsSettings>;
privacy: Partial<PrivacySettings>;
chatDefaults: Partial<ChatDefaultsSettings>;
projects: Partial<ProjectPreferencesSettings>;
notifications: Partial<NotificationSettings>;
workHub: Partial<WorkHubSettings>;
system: Partial<SystemSettings>;
externalAgents: AppSettings['externalAgents'];
shell: Partial<ShellSettings>;
webSearch: WebSearchSettingsPatch;
subagents: SubagentSettings;
}>;
/** Preconditions for a Host-owned Settings write that must not be retried past a semantic change. */
export interface RuntimeHostSettingsUpdateGuard {
readonly expectedExternalAgentExecutable?: string;
}
export type PersonalizationSettingsWarning =
| 'override-attempt'
| 'sensitive-pattern'
| 'control-chars';
export interface UpdateAppSettingsWarnings {
personalization?: PersonalizationSettingsWarning[];
}
export interface UpdateAppSettingsResult<TSettings extends AppSettings = AppSettings> {
settings: TSettings;
warnings?: UpdateAppSettingsWarnings;
}
export const DEFAULT_PROXY_BYPASS_DOMAINS = [
'localhost',
'127.0.0.1',
'::1',
'192.168.*',
'10.*',
'*.local',
];
export function createDefaultSettings(): AppSettings {
return {
schemaVersion: 1,
network: {
proxy: {
enabled: false,
protocol: 'http',
host: '127.0.0.1',
port: 7890,
authEnabled: false,
username: '',
bypassList: ['metaso.cn', 'baidu.com'],
autoBypassDomains: DEFAULT_PROXY_BYPASS_DOMAINS,
},
},
botChat: createDefaultBotChatSettings(),
usage: {
range: '24h',
status: 'all',
modelFilter: '',
showDetails: false,
activeTab: 'requests',
},
appearance: {
theme: 'auto',
palette: 'default',
appIcon: DEFAULT_APP_ICON,
uiFontSize: DEFAULT_UI_FONT_SIZE,
terminalFontSize: DEFAULT_TERMINAL_FONT_SIZE,
},
personalization: {
displayName: '',
assistantTone: '',
uiLocale: 'auto',
selectedPetId: null,
},
onboarding: {
milestones: [],
},
webSearch: defaultWebSearchSettings(),
localMemory: defaultLocalMemorySettings(),
workspaceInstructions: {
enabled: true,
},
privacy: defaultPrivacySettings(),
projects: defaultProjectPreferencesSettings(),
chatDefaults: defaultChatDefaultsSettings(),
notifications: {
runComplete: true,
},
workHub: {
enabled: false,
},
system: {
// Off by default: holding a power-save blocker is an explicit,
// battery-affecting opt-in, not a silent default.
keepSystemAwake: false,
},
externalAgents: { antigravity: { executable: '' } },
shell: {
preference: 'auto',
executable: '',
},
subagents: { presets: [] },
};
}
export function mergeSettings(current: AppSettings, patch: UpdateAppSettingsInput): AppSettings {
const {
credential: _credential,
password: _legacyPassword,
passwordConfigured: _derivedStatus,
...proxyPatch
} = (patch.network?.proxy ?? {}) as NetworkProxySettingsPatch & {
password?: unknown;
passwordConfigured?: unknown;
};
return {
...current,
network: {
...current.network,
...(patch.network ?? {}),
proxy: {
...current.network.proxy,
...proxyPatch,
},
},
botChat: mergeBotChatSettings(current.botChat, patch.botChat),
usage: {
...current.usage,
...(patch.usage ?? {}),
},
appearance: {
...current.appearance,
...(patch.appearance ?? {}),
},
personalization: {
...current.personalization,
...(patch.personalization ?? {}),
selectedPetId: normalizeSelectedPetId(
patch.personalization?.selectedPetId === undefined
? current.personalization.selectedPetId
: patch.personalization.selectedPetId,
),
},
onboarding: {
...current.onboarding,
// PR110b: milestones flow through a dedicated setMilestone IPC
// rather than the generic UpdateAppSettingsInput patch surface.
// Keep the existing list intact when callers patch other sections.
},
localMemory: patch.localMemory
? normalizeLocalMemorySettings({
...current.localMemory,
...patch.localMemory,
})
: current.localMemory,
workspaceInstructions: patch.workspaceInstructions
? normalizeWorkspaceInstructionsSettings({
...current.workspaceInstructions,
...patch.workspaceInstructions,
})
: current.workspaceInstructions,
privacy: patch.privacy
? normalizePrivacySettings({ ...current.privacy, ...patch.privacy })
: current.privacy,
projects: patch.projects
? normalizeProjectPreferencesSettings({ ...current.projects, ...patch.projects })
: current.projects,
chatDefaults: patch.chatDefaults
? normalizeChatDefaultsSettings({
...current.chatDefaults,
...patch.chatDefaults,
})
: current.chatDefaults,
notifications: {
...current.notifications,
...(patch.notifications ?? {}),
},
workHub: {
...current.workHub,
...(patch.workHub ?? {}),
},
system: {
...current.system,
...(patch.system ?? {}),
},
externalAgents: patch.externalAgents ?? current.externalAgents,
shell: {
...current.shell,
...(patch.shell ?? {}),
},
webSearch: mergeWebSearchSettings(current.webSearch, patch.webSearch),
subagents:
patch.subagents === undefined
? current.subagents
: normalizeSubagentSettings(patch.subagents),
};
}
export function normalizeSettings(input: unknown): AppSettings {
const defaults = createDefaultSettings();
if (!input || typeof input !== 'object') return defaults;
const value = input as Partial<AppSettings>;
const base = mergeSettings(defaults, {
network: value.network,
botChat: value.botChat,
usage: value.usage,
appearance: value.appearance,
personalization: value.personalization,
webSearch: value.webSearch,
localMemory: value.localMemory,
workspaceInstructions: value.workspaceInstructions,
privacy: value.privacy,
chatDefaults: value.chatDefaults,
projects: value.projects,
notifications: value.notifications,
workHub: value.workHub,
system: value.system,
externalAgents: value.externalAgents,
shell: value.shell,
subagents: value.subagents,
});
// PR110b: milestones bypass the generic patch surface so we can
// sanitize them with the closed-enum + at-most-one validator on
// every read. The settings → onboarding dependency is one-way; there
// is no cycle.
const rawOnboarding = (value as { onboarding?: unknown }).onboarding;
const rawMilestones =
rawOnboarding && typeof rawOnboarding === 'object'
? (rawOnboarding as { milestones?: unknown }).milestones
: undefined;
const {
toastPosition: _legacyToastPosition,
density: _legacyDensity,
...appearanceWithoutLegacyFields
} = base.appearance as AppearanceSettings & Record<string, unknown>;
return {
...base,
// PR-UI-D1 (@kenji msg 68bf2b13): closed-enum fail-closed for
// appearance.palette. mergeSettings spreads the raw user value
// straight in, so an unknown/garbage palette string would
// otherwise survive the normalize pass and end up driving
// `[data-maka-theme="evil-unknown"]` on the renderer with no
// matching CSS block. Validate against the closed `THEME_PALETTES`
// allowlist and fall back to `'default'` on any miss (undefined,
// non-string, unknown string).
//
// Critical: this MUST NOT silently reset other appearance fields
// (theme). We only override palette when it fails the type guard;
// everything else keeps mergeSettings's behavior.
// Legacy `appearance.toastPosition` and `appearance.density` are
// intentionally stripped here. Toasts are fixed to one app-wide
// position; UI density is no longer a product setting.
appearance: {
...appearanceWithoutLegacyFields,
palette: isThemePalette(base.appearance.palette) ? base.appearance.palette : 'default',
// Same fail-closed rule as `palette` above, for the same reason: an
// unknown id would otherwise reach the main process and resolve to a
// PNG path that does not exist, leaving the dock with a blank tile.
// A `custom:` reference passes the same gate: the id shape is checked
// here, and the main process is the only thing that turns it into a path.
// An id whose file was deleted behind the app's back still normalizes
// through, and fails over to the brand mark when the artwork is read.
appIcon: isAppIconChoice(base.appearance.appIcon)
? base.appearance.appIcon
: DEFAULT_APP_ICON,
// Wrong-typed → default; out-of-range number → clamped to bounds, so an
// extreme persisted value can't drive an unusable root/terminal size.
uiFontSize: normalizeUiFontSize(base.appearance.uiFontSize),
terminalFontSize: normalizeTerminalFontSize(base.appearance.terminalFontSize),
// Cleared first, then re-set from the RAW input rather than from `base`:
// `base` has already been merged over the defaults, which carry a dark
// icon, so an existing settings file that predates this option would
// come out of the merge looking like it had asked for one. Absent must
// stay absent — that is what makes an upgrade keep showing the icon the
// user actually picked in BOTH appearances, instead of silently gaining
// a second one they never chose.
appIconDark: undefined,
...normalizedDarkAppIcon(value.appearance, defaults.appearance.appIconDark),
},
// PR-LANG-PREF-0: closed-enum fail-closed for the new
// `personalization.uiLocale` preference. mergeSettings spreads
// raw user values, so an unknown value would otherwise reach the
// renderer outside the closed reactive-locale contract. Preserve the
// former generic `zh` preference as Simplified Chinese, then fall back to
// 'auto' on any other miss.
personalization: {
...base.personalization,
uiLocale: normalizeUiLocalePreference(base.personalization.uiLocale),
selectedPetId: normalizeSelectedPetId(base.personalization.selectedPetId),
},
botChat: normalizeBotChatSettings(base.botChat, value.botChat),
onboarding: {
milestones: sanitizeOnboardingMilestones(rawMilestones),
},
webSearch: normalizeWebSearchSettings(base.webSearch),
localMemory: normalizeLocalMemorySettings(base.localMemory),
workspaceInstructions: normalizeWorkspaceInstructionsSettings(base.workspaceInstructions),
privacy: normalizePrivacySettings(base.privacy),
projects: normalizeProjectPreferencesSettings(base.projects),
chatDefaults: normalizeChatDefaultsSettings(base.chatDefaults),
// Fail-closed boolean coercion: mergeSettings spreads the raw user
// value, so a non-boolean `runComplete` (from a hand-edited or
// legacy settings.json) would otherwise reach the main-process gate
// as a truthy/falsy non-boolean. Default a missing/garbage value to
// the enabled default rather than silently disabling notifications.
notifications: {
runComplete:
typeof base.notifications.runComplete === 'boolean' ? base.notifications.runComplete : true,
},
workHub: {
enabled: typeof base.workHub.enabled === 'boolean' ? base.workHub.enabled : false,
},
// Fail-closed boolean coercion, same reasoning as
// `notifications.runComplete`: a non-boolean `keepSystemAwake` (from a
// hand-edited or legacy settings.json) must not reach the main-process
// power-save-blocker gate as a truthy/falsy non-boolean. Default a
// missing/garbage value to `false` — never silently hold a power
// blocker the user did not opt into.
system: {
keepSystemAwake:
typeof base.system.keepSystemAwake === 'boolean' ? base.system.keepSystemAwake : false,
},
externalAgents: {
antigravity: {
executable:
typeof base.externalAgents?.antigravity?.executable === 'string'
? base.externalAgents.antigravity.executable
: '',
},
},
shell: normalizeShellSettings(base.shell),
subagents: normalizeSubagentSettings(base.subagents),
};
}
function normalizeSelectedPetId(value: unknown): string | null {
return isPetPackId(value) ? value : null;
}
function normalizeShellSettings(settings: ShellSettings): ShellSettings {
return {
preference: settings.preference === 'git_bash' ? 'git_bash' : 'auto',
executable:
typeof settings.executable === 'string'
? settings.executable.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()
: '',
};
}
function normalizeWorkspaceInstructionsSettings(
settings: WorkspaceInstructionsSettings,
): WorkspaceInstructionsSettings {
return {
enabled: settings.enabled !== false,
};
}
function defaultPrivacySettings(): PrivacySettings {
return { incognitoActive: false };
}
function defaultProjectPreferencesSettings(): ProjectPreferencesSettings {
return {};
}
function defaultChatDefaultsSettings(): ChatDefaultsSettings {
return { permissionMode: 'ask' };
}
// Closed-enum fail-closed, same reasoning as appearance.palette /
// personalization.uiLocale above: an unknown/garbage persisted value
// (corrupted settings.json, a downgraded build reading a newer schema)
// must not reach session-creation code as a `PermissionMode` the picker
// doesn't recognize -- fall back to the safest default instead.
function normalizeChatDefaultsSettings(settings: ChatDefaultsSettings): ChatDefaultsSettings {
return {
...(settings.codeModeEnabled === true ? { codeModeEnabled: true } : {}),
// Same fail-closed reasoning as the mode below: a garbage persisted level
// drops to "no preference" (the model's own default) rather than reaching
// session creation as a rung no picker recognizes.
thinkingLevel: isThinkingLevel(settings.thinkingLevel) ? settings.thinkingLevel : undefined,
// A retired mode is decoded (not rejected) so an existing settings file
// keeps working; knowing which modes are retired lives in one place.
// Anything that decodes to a mode outside the pickable set — including
// `explore`, which only a product mode confers — still falls back.
permissionMode: (() => {
const mode = decodePersistedPermissionMode(settings.permissionMode);
return mode !== undefined && isChatDefaultPermissionMode(mode) ? mode : 'ask';
})(),
};
}
function normalizePrivacySettings(settings: PrivacySettings): PrivacySettings {
return {
incognitoActive: settings.incognitoActive === true,
};
}
// A blank or non-string id is dropped rather than carried: it cannot name a
// project, and letting it through would make "has a default" true while
// nothing resolves. Whether the id still names a LIVE project is decided at
// read time by the catalog, not here -- a project can be archived or its folder
// removed long after this value was written, so normalization is the wrong
// place to answer that.
function normalizeProjectPreferencesSettings(
settings: ProjectPreferencesSettings | undefined,
): ProjectPreferencesSettings {
const id = settings?.defaultProjectId;
return typeof id === 'string' && id.trim() !== '' ? { defaultProjectId: id } : {};
}