feat: v0.3.17 修复多项问题(sandbox/全局配置层/继承选项/配置刷新/附件提示/content_filter 错误处理)

- fix: sandbox: true → false(ESM preload 不兼容 sandbox 导致 window.metona 不可用,选择文件夹按钮无反应)
- feat: 全局配置层(GlobalConfigService)— LLM/Agent 等配置跨工作空间共享,切换工作空间后不再丢失
  - 读取时工作空间 DB 空值或默认值回退到全局 JSON
  - 写入时双写(DB + 全局 JSON)
  - 首次启动迁移旧配置到全局层
- fix: 工作空间继承选项显示条件放宽 + push 前校验避免覆盖目标已有文件(SOUL.md/agent.db)
- fix: config:changed IPC 广播 + 前端监听,修改 Agent 配置后详情栏 maxIterations 实时刷新
- feat: 上传图片/文件时在 System Prompt 注入附件提示,避免 AI 在工作空间查找用户上传的文件
- feat: content_filter 错误识别(ContentFilterError)+ 友好提示,替代原始 JSON 错误体透传
- fix: JSDoc 注释中 llm.* / agent.* / onboarding.completed 中的 */ 被解析为注释结束符
This commit is contained in:
2026-07-22 13:12:15 +08:00
parent 9af9984418
commit 3e5ddea72d
12 changed files with 506 additions and 32 deletions
+53
View File
@@ -18,6 +18,24 @@ import type {
import { MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
/**
* 内容审核错误 — Provider 的安全过滤策略触发的错误
*
* 当 Provider(如 MiMo)的 API 返回 content_filter 错误码时抛出。
* Engine 识别此错误后映射为 MetonaErrorCode.CONTENT_FILTERED
* 前端显示友好提示而非原始 JSON 错误体。
*/
export class ContentFilterError extends Error {
/** Provider 返回的原始错误消息(如 "The request was rejected because it was considered high risk" */
readonly providerMessage: string;
constructor(providerMessage: string, context: string) {
super(`${context}: 内容被 Provider 安全审核拦截 - ${providerMessage}`);
this.name = 'ContentFilterError';
this.providerMessage = providerMessage;
}
}
export abstract class BaseAdapter implements IMetonaProviderAdapter {
// H-2 修复: provider → providerId(规范要求)
abstract readonly providerId: string;
@@ -157,10 +175,35 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
/**
* P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出
* engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx
*
* v0.3.17: 解析 Provider 返回的 JSON 错误体,识别 content_filter 错误码,
* 抛出 ContentFilterError 让 Engine 映射为 CONTENT_FILTERED 错误码。
*/
protected async throwHttpError(response: Response, context: string): Promise<never> {
let errorBody = '';
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
// v0.3.17: 解析 JSON 错误体,识别 content_filter
if (errorBody) {
try {
const parsed = JSON.parse(errorBody);
// 兼容 OpenAI 格式 {error: {code, message}} 和 MiMo/其他格式
const errObj = parsed?.error ?? parsed;
const errorCode = errObj?.code ?? errObj?.type ?? '';
const errorMessage = errObj?.message ?? '';
if (typeof errorCode === 'string' && errorCode.toLowerCase().includes('content_filter')) {
const providerMsg = errorMessage || '内容触发安全过滤策略';
const cfError = new ContentFilterError(providerMsg, context);
(cfError as ContentFilterError & { status: number }).status = response.status;
throw cfError;
}
} catch (parseErr) {
// JSON 解析失败(非 JSON 响应体),走原逻辑
if (parseErr instanceof ContentFilterError) throw parseErr;
}
}
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
(error as Error & { status: number }).status = response.status;
throw error;
@@ -171,6 +214,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
*/
protected mapError(error: unknown): MetonaError {
if (error instanceof Error) {
// v0.3.17: 优先识别 ContentFilterError
if (error instanceof ContentFilterError) {
return {
code: MetonaErrorCode.CONTENT_FILTERED,
message: '内容被 Provider 安全审核拦截,请修改图片或文本后重试',
provider: this.providerId,
retryable: false,
};
}
const msg = error.message.toLowerCase();
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
+7 -2
View File
@@ -36,6 +36,7 @@ import type {
} from '../types';
import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator';
import { ContentFilterError } from '../adapters/base-adapter';
import log from 'electron-log';
/**
@@ -1149,6 +1150,8 @@ export class AgentLoopEngine extends EventEmitter {
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) {
// v0.3.17: 识别 ContentFilterError,映射为 CONTENT_FILTERED 错误码 + 友好消息
const isContentFilter = error instanceof ContentFilterError;
this.emit('streamEvent', {
type: MetonaStreamEventType.ERROR,
requestId: this.currentRequestId,
@@ -1158,8 +1161,10 @@ export class AgentLoopEngine extends EventEmitter {
timestamp: Date.now(),
runId: this.runId,
error: {
code: MetonaErrorCode.UNKNOWN,
message: error.message,
code: isContentFilter ? MetonaErrorCode.CONTENT_FILTERED : MetonaErrorCode.UNKNOWN,
message: isContentFilter
? '内容被 Provider 安全审核拦截,请修改图片或文本后重试'
: error.message,
retryable: false,
},
});
+41 -1
View File
@@ -163,6 +163,29 @@ export function registerAllIPCHandlers(
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
}
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
// 解决场景:用户上传图片后,AI 先在工作空间找图片,找不到才意识到是用户上传的
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
if (Array.isArray(attachments) && attachments.length > 0) {
const attachmentList = attachments.map((att, i) => {
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
const note = att.type === 'image'
? 'visible via vision capability, do NOT search in workspace'
: att.type === 'text'
? 'content already inlined in the user message, do NOT search in workspace'
: 'uploaded directly by user, do NOT search in workspace';
return `${i + 1}. [${typeLabel}] ${att.name}${note}`;
}).join('\n');
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
: attachmentBlock;
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
}
// 监听 Agent Loop 事件
// F8: 主进程 text_delta 节流合并
// 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
@@ -817,6 +840,11 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Log level updated to ${level}`);
}
// 广播配置变更事件,前端监听后更新 store(如 maxIterations 显示)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('config:changed', { key, value });
}
// 工作空间路径变更时写入独立文件(下次启动生效)
if (key === 'workspace.path') {
try {
@@ -978,6 +1006,13 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Log level updated to ${logLevelValue}`);
}
// 第四步半:广播所有配置变更事件,前端监听后更新 store
if (!mainWindow.isDestroyed()) {
for (const { key, value } of entries) {
mainWindow.webContents.send('config:changed', { key, value });
}
}
// 第五步:工作空间路径写入独立文件(下次启动生效)
if (workspacePathValue) {
try {
@@ -1061,7 +1096,7 @@ export function registerAllIPCHandlers(
// ===== 工作空间校验与继承 =====
// 校验工作空间路径:检测路径有效性 + 4 个必需文件状态
// 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, reason: '路径不能为空' };
@@ -1080,6 +1115,7 @@ export function registerAllIPCHandlers(
exists: false,
missingFiles: ['SOUL.md', 'MEMORY.md'],
isNewWorkspace: true,
dbExists: false,
reason: '目录不存在,将在切换后自动创建',
};
}
@@ -1101,12 +1137,16 @@ export function registerAllIPCHandlers(
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
}
// 校验 4: 检测 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项)
const dbExists = existsSync(join(resolvedPath, '.metona', 'agent.db'));
return {
valid: true,
path: resolvedPath,
exists: true,
missingFiles,
isNewWorkspace: missingFiles.length === requiredFiles.length,
dbExists,
};
});
+26
View File
@@ -22,6 +22,7 @@ import log from 'electron-log';
import { DatabaseService } from './services/database.service';
import { SessionService } from './services/session.service';
import { ConfigService } from './services/config.service';
import { GlobalConfigService } from './services/global-config.service';
import { WorkspaceService } from './services/workspace.service';
import { AuditService } from './services/audit.service';
import { SessionRecorder } from './services/session-recorder.service';
@@ -131,8 +132,33 @@ async function initialize(): Promise<void> {
databaseService.initialize();
const db = databaseService.getDB();
// v0.3.17: 初始化全局配置层(跨工作空间共享 LLM/Agent/onboarding 等机器级配置)
// 必须在 ConfigService 创建前初始化,以便注入
const globalConfigService = new GlobalConfigService();
globalConfigService.initialize();
const sessionService = new SessionService(() => db);
const configService = new ConfigService(() => db);
// 注入全局配置层:读取时回退到全局 JSON,写入时双写
configService.setGlobalConfig(globalConfigService);
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key,重复执行无副作用
// 解决场景:老用户从 v0.3.16 升级,LLM 配置在工作空间 DB 里,需要迁移到全局层
// 注意: 此处直接查 DB 而不通过 configService.getAll(),因为后者会合并全局层,导致迁移逻辑循环
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ key: string; value: string }>;
const workspaceConfig: Record<string, unknown> = {};
for (const row of configRows) {
try {
workspaceConfig[row.key] = JSON.parse(row.value);
} catch {
workspaceConfig[row.key] = row.value;
}
}
const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
if (migratedCount > 0) {
log.info(`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`);
}
// ===== 日志服务 =====
const auditService = new AuditService(() => db);
+7
View File
@@ -130,6 +130,13 @@ const metonaAPI = {
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) =>
ipcRenderer.invoke('config:setBatch', entries),
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
onChanged: (callback: (data: { key: string; value: unknown }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { key: string; value: unknown }) =>
callback(data);
ipcRenderer.on('config:changed', listener);
return () => ipcRenderer.removeListener('config:changed', listener);
},
},
// ===== 应用工具 =====
+70 -6
View File
@@ -4,11 +4,17 @@
* 管理 app_config 表的读写操作。
* 所有配置值以 JSON 格式存储。
*
* v0.3.17: 引入全局配置层(GlobalConfigService)。
* - 全局 keyllm.* / agent.* / onboarding.completed 等)读取时优先工作空间 DB,
* miss 时回退到全局 JSON,确保切换工作空间后 LLM 凭据和引导状态不丢失。
* - 写入时同时写工作空间 DB(兼容)和全局 JSON(跨工作空间共享)。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
*/
import type Database from 'better-sqlite3';
import log from 'electron-log';
import { GlobalConfigService, isGlobalKey, isUnconfiguredGlobalKey } from './global-config.service';
export interface ConfigRow {
key: string;
@@ -18,26 +24,63 @@ export interface ConfigRow {
}
export class ConfigService {
private globalConfig: GlobalConfigService | null = null;
constructor(private getDB: () => Database.Database) {}
/**
* 注入全局配置服务(可选,由 main.ts 在启动时调用)
* 注入后,全局 key 的读取会回退到全局层,写入会双写。
*/
setGlobalConfig(globalConfig: GlobalConfigService): void {
this.globalConfig = globalConfig;
}
/**
* 获取配置值
* 读取顺序:工作空间 DB → 全局 JSON(仅全局 key
*
* 特殊处理:对于"空值视为未配置"的全局 key(如 llm.apiKey=''、onboarding.completed=false),
* 当工作空间 DB 的值为空/默认值时,回退到全局层读取真实值。
* 这解决了新工作空间 DB 被 seedDefaults 灌入空值导致配置丢失的问题。
*/
get<T = unknown>(key: string): T | null {
const db = this.getDB();
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
if (!row) return null;
if (row) {
let parsed: unknown;
try {
parsed = JSON.parse(row.value);
} catch {
parsed = row.value;
}
try {
return JSON.parse(row.value) as T;
} catch {
return row.value as T;
// 全局 key 的空值回退:若工作空间 DB 存在该 key 但值为"未配置"(如 llm.apiKey=''),
// 且全局层有真实值,则返回全局层的值。
// 解决场景:新工作空间 DB 被 seedDefaults 灌入 llm.apiKey='' / onboarding.completed=false
// 但用户在全局层已配置过,应回退到全局层。
if (this.globalConfig && isGlobalKey(key) && isUnconfiguredGlobalKey(key, parsed)) {
const globalValue = this.globalConfig.get<T>(key);
if (globalValue !== null && !isUnconfiguredGlobalKey(key, globalValue)) {
return globalValue;
}
}
return parsed as T;
}
// 工作空间 DB miss,若为全局 key 则回退到全局层
if (this.globalConfig && isGlobalKey(key)) {
return this.globalConfig.get<T>(key);
}
return null;
}
/**
* 设置配置值(保留已有 category)
* 全局 key 同时写入工作空间 DB 和全局 JSON
*/
set(key: string, value: unknown): void {
const db = this.getDB();
@@ -52,11 +95,17 @@ export class ConfigService {
VALUES (?, ?, ?, ?)
`).run(key, jsonValue, category, Date.now());
// 全局 key 同步写入全局层(跨工作空间共享)
if (this.globalConfig && isGlobalKey(key)) {
this.globalConfig.set(key, value);
}
log.debug(`Config set: ${key}`);
}
/**
* 获取所有配置
* 获取所有配置(工作空间 DB + 全局层合并)
* 合并规则与 get() 一致:全局 key 在 DB 中为"未配置"时回退到全局层
*/
getAll(): Record<string, unknown> {
const db = this.getDB();
@@ -71,6 +120,21 @@ export class ConfigService {
}
}
// 全局层合并:与 get() 的回退逻辑保持一致
if (this.globalConfig) {
const globalAll = this.globalConfig.getAll();
for (const [key, globalValue] of Object.entries(globalAll)) {
if (!(key in config)) {
// DB miss → 用全局层
config[key] = globalValue;
} else if (isGlobalKey(key) && isUnconfiguredGlobalKey(key, config[key])
&& !isUnconfiguredGlobalKey(key, globalValue)) {
// DB 有值但为"未配置"seedDefaults 默认值/空值),全局层有真实值 → 用全局层
config[key] = globalValue;
}
}
}
return config;
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Global Config Service — 全局配置层(跨工作空间共享)
*
* 设计原因:
* 原实现中所有配置存在工作空间 DB{workspacePath}/.metona/agent.db)的 app_config 表里。
* 切换到新工作空间时,新 DB 被 seedDefaults 灌入空值,导致 LLM 凭据、onboarding.completed
* 等本应"机器级"的配置全部丢失,用户被迫重新配置。
*
* 方案:
* 将"机器级全局配置"抽到 userData/global-config.json,与工作空间解耦。
* 工作空间 DB 仍保留会话/消息/记忆/审计等"工作空间级数据"。
*
* 全局 key 规则:
* - llm.* / agent.* / ollama.* / {provider}.contextWindow / security.* / ui.* / logging.*
* - onboarding.completed
* 写入时同时写工作空间 DB(兼容旧逻辑)和全局 JSON;
* 读取时优先工作空间 DB,miss 时回退到全局 JSON。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 配置存储
*/
import { app } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import log from 'electron-log';
/** 全局配置文件路径(userData 下,与工作空间无关) */
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
/** 全局配置 key 前缀清单(匹配这些前缀的 key 视为全局配置) */
const GLOBAL_KEY_PREFIXES = [
'llm.',
'agent.',
'ollama.',
'security.',
'ui.',
'logging.',
'deepseek.',
'agnes.',
'mimo.',
'onboarding.',
];
/** 判断 key 是否属于全局配置 */
export function isGlobalKey(key: string): boolean {
return GLOBAL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
/**
* seedDefaults 灌入的默认值映射表(用于判断"DB 值等于默认值时回退全局层")
* 必须与 database.service.ts 的 seedDefaults 保持同步
*/
export const SEED_DEFAULTS: Record<string, unknown> = {
'llm.provider': '',
'llm.model': '',
'llm.apiKey': '',
'llm.baseURL': '',
'llm.temperature': 0,
'llm.maxTokens': 63488,
'llm.fallbackProvider': '',
'llm.fallbackModel': '',
'agent.maxIterations': 20,
'agent.totalTimeoutMs': 600000,
'agent.enableThinking': true,
'agent.thinkingEffort': 'high',
'agent.enableReflection': false,
'agent.confirmationTimeoutMs': 120000,
'agent.toolExecutionTimeoutMs': 120000,
'security.requireWriteConfirmation': true,
'security.maxFileWriteSizeKB': 1024,
'security.promptInjectionDefense': true,
'ui.theme': 'auto',
'ui.animationMode': 'auto',
'ui.fontSize': 'medium',
'logging.level': 'info',
'logging.auditEnabled': true,
'logging.traceEnabled': true,
'ollama.numCtx': null,
'deepseek.contextWindow': 1000000,
'agnes.contextWindow': 1000000,
'mimo.contextWindow': 131072,
'onboarding.completed': false,
};
/**
* 判断全局 key 在工作空间 DB 中的值是否为"未配置"(应回退到全局层)
*
* 判断规则:
* - 值为空字符串/null/undefined → 未配置
* - 值等于 seedDefaults 默认值 → 未配置(新工作空间 DB 被灌入的默认值)
* - 其他 → 已配置(用户主动设置过,不回退)
*/
export function isUnconfiguredGlobalKey(key: string, value: unknown): boolean {
// 空值一律视为未配置
if (value === '' || value === null || value === undefined) {
return true;
}
// 值等于 seedDefaults 默认值 → 视为未配置(被 seedDefaults 灌入的)
const defaultValue = SEED_DEFAULTS[key];
if (defaultValue !== undefined && value === defaultValue) {
return true;
}
return false;
}
interface GlobalConfigData {
/** key → value(已 JSON.parse */
[key: string]: unknown;
}
export class GlobalConfigService {
private data: GlobalConfigData = {};
private initialized = false;
/**
* 初始化:读取全局配置文件
* 若文件不存在则创建空文件(首次启动)
*/
initialize(): void {
if (this.initialized) {
log.warn('[GlobalConfig] Already initialized');
return;
}
try {
if (existsSync(GLOBAL_CONFIG_FILE)) {
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
this.data = JSON.parse(raw) as GlobalConfigData;
log.info(`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`);
} else {
// 确保父目录存在
const dir = join(GLOBAL_CONFIG_FILE, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
this.data = {};
this.flush();
log.info(`[GlobalConfig] Created new global config file: ${GLOBAL_CONFIG_FILE}`);
}
} catch (err) {
log.error('[GlobalConfig] Failed to load, starting with empty config:', err);
this.data = {};
}
this.initialized = true;
}
/**
* 读取全局配置
*/
get<T = unknown>(key: string): T | null {
if (!this.initialized) {
log.warn('[GlobalConfig] get() called before initialize()');
return null;
}
if (key in this.data) {
return this.data[key] as T;
}
return null;
}
/**
* 写入全局配置(内存 + 立即落盘)
*/
set(key: string, value: unknown): void {
if (!this.initialized) {
log.warn('[GlobalConfig] set() called before initialize()');
return;
}
this.data[key] = value;
this.flush();
log.debug(`[GlobalConfig] set: ${key}`);
}
/**
* 批量写入(仅落盘一次,避免多次 IO)
*/
setBatch(entries: Array<{ key: string; value: unknown }>): void {
if (!this.initialized) {
log.warn('[GlobalConfig] setBatch() called before initialize()');
return;
}
for (const { key, value } of entries) {
this.data[key] = value;
}
this.flush();
log.debug(`[GlobalConfig] setBatch: ${entries.length} keys`);
}
/**
* 获取所有全局配置(用于迁移和调试)
*/
getAll(): GlobalConfigData {
return { ...this.data };
}
/**
* 从工作空间 DB 迁移配置到全局层(首次启用全局配置层时调用)
*
* 迁移规则:
* - 仅迁移 isGlobalKey 匹配的 key
* - 仅当全局层该 key 不存在时才写入(不覆盖用户已保存的全局配置)
* - 仅迁移"已配置"的值(空值/seedDefaults 默认值不迁移)
* - false 不一律跳过:若 seedDefaults 默认值不是 false,则 false 是用户主动设置,应迁移
*
* @param workspaceConfig 工作空间 DB 的全部 app_config
*/
migrateFromWorkspaceDB(workspaceConfig: Record<string, unknown>): number {
if (!this.initialized) {
log.warn('[GlobalConfig] migrateFromWorkspaceDB() called before initialize()');
return 0;
}
let migrated = 0;
for (const [key, value] of Object.entries(workspaceConfig)) {
if (!isGlobalKey(key)) continue;
// 全局层已有该 key,不覆盖
if (key in this.data) continue;
// 空值不迁移(seedDefaults 灌入的空值)
if (value === '' || value === null || value === undefined) continue;
// 值等于 seedDefaults 默认值不迁移(避免把默认值同步到全局层)
// 注意:isUnconfiguredGlobalKey 已包含"值等于默认值"判断,此处复用
if (isUnconfiguredGlobalKey(key, value)) continue;
this.data[key] = value;
migrated++;
}
if (migrated > 0) {
this.flush();
log.info(`[GlobalConfig] Migrated ${migrated} keys from workspace DB to global config`);
}
return migrated;
}
/**
* 落盘到 JSON 文件
*/
private flush(): void {
try {
writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(this.data, null, 2), 'utf-8');
} catch (err) {
log.error('[GlobalConfig] Failed to write config file:', err);
}
}
}
+6 -6
View File
@@ -54,12 +54,12 @@ export class WindowManager {
title: options.title ?? 'MetonaAI Desktop',
webPreferences: {
preload: join(__dirname, '../preload/preload.mjs'),
// #37 修复: 启用沙箱,强化渲染进程隔离
// preload.ts 仅使用 contextBridge/ipcRenderersandbox 下可用)和
// @electron-toolkit/preload(兼容 sandbox),不依赖 fs/child_process 等 Node API
// 因此可安全启用 sandbox: true。启用后渲染进程无法直接访问 Node API,
// 缩小恶意网页通过 preload 漏洞访问 fs/child_process 的攻击面
sandbox: true,
// sandbox: false — preload 使用 ESM 格式(.mjs + import 语法),
// Electron sandbox 不支持 ESM preload(官方 ESM 支持矩阵: Sandboxed = Unsupported)。
// 若启用 sandbox: truepreload.mjs 的 import 语句无法解析,contextBridge 不执行
// window.metona 为 undefined,所有 IPC 调用静默失败。
// 要启用 sandbox,需先将 preload 改为 CJS 格式 + require 语法(工程改动较大)
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
},