feat: 工作空间切换校验 + 初始化检测 + 继承基础设置

新增功能:
- workspace:check IPC — 校验路径有效性 + 检测 4 个必需文件状态
- workspace:inheritFiles IPC — 从旧工作空间继承 SOUL/AGENTS/USERS 到新工作空间
- app:restart IPC — 渲染进程触发应用重启(app.relaunch + app.exit)

前端 WorkspaceSettings 改造:
- 选择文件夹后立即校验路径(是否存在、是否目录、4 个必需文件状态)
- 显示工作空间状态:
  · 新工作空间 → 提示将自动创建 4 个文件
  · 部分文件缺失 → 提示缺失文件将自动创建
  · 已有工作空间 → 提示将直接加载现有配置
- 继承选项:当切换到新工作空间时,可勾选继承 SOUL/AGENTS/USERS.md
  · MEMORY.md 不继承(记忆与工作空间项目上下文绑定)
  · 继承白名单:仅允许 SOUL.md、AGENTS.md、USERS.md(防路径遍历)
- 确认切换按钮 + 取消按钮
- 切换完成后弹出重启确认对话框(立即重启 / 稍后手动重启)

安全考虑:
- workspace:inheritFiles 使用文件名白名单,防止路径遍历攻击
- workspace:check 返回 resolvedPath,前端只展示不直接用于文件操作
- app:restart 延迟 200ms 让 IPC 响应先返回
This commit is contained in:
thzxx
2026-07-12 13:59:31 +08:00
parent aa13a98f63
commit 0363176215
4 changed files with 315 additions and 2 deletions
+112
View File
@@ -10,6 +10,7 @@
*/ */
import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron'; import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron';
import { join } from 'path';
import type { SessionService } from '../services/session.service'; import type { SessionService } from '../services/session.service';
import type { ConfigService } from '../services/config.service'; import type { ConfigService } from '../services/config.service';
import type { WorkspaceService } from '../services/workspace.service'; import type { WorkspaceService } from '../services/workspace.service';
@@ -551,6 +552,117 @@ export function registerAllIPCHandlers(
return { canceled: false, path: result.filePaths[0] }; return { canceled: false, path: result.filePaths[0] };
}); });
// 重启应用(工作空间切换后调用)
ipcMain.handle('app:restart', async () => {
log.info('[APP] Restart requested');
// 延迟 200ms 让 IPC 响应先返回
setTimeout(() => {
app.relaunch();
app.exit(0);
}, 200);
return { success: true };
});
// ===== 工作空间校验与继承 =====
// 校验工作空间路径:检测路径有效性 + 4 个必需文件状态
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, reason: '路径不能为空' };
}
const { existsSync, statSync } = await import('fs');
const { resolve } = await import('path');
const resolvedPath = resolve(targetPath);
// 校验 1: 路径是否存在
if (!existsSync(resolvedPath)) {
return {
valid: true,
path: resolvedPath,
exists: false,
missingFiles: ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'],
isNewWorkspace: true,
reason: '目录不存在,将在切换后自动创建',
};
}
// 校验 2: 是否为目录
try {
const stat = statSync(resolvedPath);
if (!stat.isDirectory()) {
return { valid: false, reason: '路径不是目录' };
}
} catch {
return { valid: false, reason: '无法访问路径' };
}
// 校验 3: 检测 4 个必需文件状态
const requiredFiles = ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'];
const missingFiles: string[] = [];
for (const f of requiredFiles) {
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
}
return {
valid: true,
path: resolvedPath,
exists: true,
missingFiles,
isNewWorkspace: missingFiles.length === requiredFiles.length,
};
});
// 从旧工作空间继承文件到新工作空间
ipcMain.handle('workspace:inheritFiles', async (_event, params: {
targetPath: string;
sourcePath: string;
files: string[];
}) => {
const { targetPath, sourcePath, files } = params;
if (!targetPath || !sourcePath || !Array.isArray(files)) {
return { success: false, error: '参数无效' };
}
const { existsSync, mkdirSync, copyFileSync } = await import('fs');
const { resolve } = await import('path');
const resolvedTarget = resolve(targetPath);
const resolvedSource = resolve(sourcePath);
// 确保目标目录存在
if (!existsSync(resolvedTarget)) {
mkdirSync(resolvedTarget, { recursive: true });
}
const inherited: string[] = [];
const failed: Array<{ file: string; error: string }> = [];
for (const fileName of files) {
// 仅允许继承白名单文件(防止路径遍历)
if (!['SOUL.md', 'AGENTS.md', 'USERS.md'].includes(fileName)) {
failed.push({ file: fileName, error: '不在继承白名单中' });
continue;
}
const srcFile = join(resolvedSource, fileName);
const dstFile = join(resolvedTarget, fileName);
try {
if (existsSync(srcFile)) {
copyFileSync(srcFile, dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource}${resolvedTarget}`);
} else {
failed.push({ file: fileName, error: '源文件不存在' });
}
} catch (err) {
failed.push({ file: fileName, error: (err as Error).message });
}
}
return { success: true, inherited, failed };
});
// ===== 工具管理 ===== // ===== 工具管理 =====
ipcMain.handle('tools:list', async () => { ipcMain.handle('tools:list', async () => {
+8
View File
@@ -114,6 +114,14 @@ const metonaAPI = {
openExternal: (url: string) => ipcRenderer.invoke('app:openExternal', url), openExternal: (url: string) => ipcRenderer.invoke('app:openExternal', url),
showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path), showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path),
selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath), selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath),
restart: () => ipcRenderer.invoke('app:restart'),
},
// ===== 工作空间管理 =====
workspace: {
check: (targetPath: string) => ipcRenderer.invoke('workspace:check', targetPath),
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
ipcRenderer.invoke('workspace:inheritFiles', params),
}, },
// ===== 工具管理 ===== // ===== 工具管理 =====
+169 -2
View File
@@ -72,11 +72,77 @@ const PROVIDER_URLS: Record<string, string> = { deepseek: 'https://api.deepseek.
function WorkspaceSettings() { function WorkspaceSettings() {
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', ''); const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
// 切换工作空间的中间状态
const [pendingPath, setPendingPath] = useState<string | null>(null);
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(null);
const [checking, setChecking] = useState(false);
const [inheritSoul, setInheritSoul] = useState(true);
const [inheritAgents, setInheritAgents] = useState(true);
const [inheritUsers, setInheritUsers] = useState(true);
const [applying, setApplying] = useState(false);
const [showRestartDialog, setShowRestartDialog] = useState(false);
const currentPath = workspacePath;
const handleSelect = async () => { const handleSelect = async () => {
if (!window.metona?.app?.selectFolder) return; if (!window.metona?.app?.selectFolder) return;
const r = await window.metona.app.selectFolder(workspacePath || undefined); const r = await window.metona.app.selectFolder(currentPath || undefined);
if (!r.canceled && r.path) setWorkspacePath(r.path); if (!r.canceled && r.path) {
// 立即校验新路径
setPendingPath(r.path);
setChecking(true);
setCheckResult(null);
try {
const result = await window.metona.workspace.check(r.path);
setCheckResult(result);
} catch (err) {
console.error('[SettingsModal]', err);
setCheckResult({ valid: false, reason: (err as Error).message });
} finally {
setChecking(false);
}
}
};
const handleApply = async () => {
if (!pendingPath || !checkResult?.valid) return;
setApplying(true);
try {
// 如果勾选了继承文件,从旧工作空间复制到新工作空间
const filesToInherit: string[] = [];
if (inheritSoul) filesToInherit.push('SOUL.md');
if (inheritAgents) filesToInherit.push('AGENTS.md');
if (inheritUsers) filesToInherit.push('USERS.md');
if (filesToInherit.length > 0 && currentPath) {
try {
await window.metona.workspace.inheritFiles({
targetPath: pendingPath,
sourcePath: currentPath,
files: filesToInherit,
});
} catch (err) {
console.error('[SettingsModal] Inherit failed:', err);
}
}
// 保存新路径到配置
setWorkspacePath(pendingPath);
// 弹出重启确认对话框
setShowRestartDialog(true);
// 清理中间状态
setPendingPath(null);
setCheckResult(null);
} catch (err) {
console.error('[SettingsModal]', err);
} finally {
setApplying(false);
}
};
const handleCancel = () => {
setPendingPath(null);
setCheckResult(null);
}; };
const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); }; const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); };
@@ -94,7 +160,108 @@ function WorkspaceSettings() {
📂 📂
</Button> </Button>
)} )}
{/* 校验中状态 */}
{checking && (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', py: 1 }}>
<CircularProgress size={14} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>...</Typography>
</Stack>
)}
{/* 校验失败 */}
{pendingPath && checkResult && !checkResult.valid && (
<Alert severity="error" sx={{ py: 0.5 }}>
<Typography variant="caption">{checkResult.reason}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button size="small" onClick={handleCancel}></Button>
</Stack>
</Alert>
)}
{/* 校验成功 — 显示工作空间状态 + 继承选项 */}
{pendingPath && checkResult?.valid && (
<Stack spacing={1.5} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.primary' }}>
{checkResult.path}
</Typography>
{checkResult.isNewWorkspace ? (
<Alert severity="info" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
4 SOUL.mdAGENTS.mdMEMORY.mdUSERS.md
</Alert>
) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? (
<Alert severity="warning" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
{checkResult.missingFiles.length} {checkResult.missingFiles.join(', ')}
</Alert>
) : (
<Alert severity="success" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
4
</Alert>
)}
{/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */}
{currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && (
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography>
<FormControlLabel
control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />}
label={<Typography variant="caption">SOUL.md</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={inheritAgents} onChange={(e) => setInheritAgents(e.target.checked)} />}
label={<Typography variant="caption">AGENTS.md</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={inheritUsers} onChange={(e) => setInheritUsers(e.target.checked)} />}
label={<Typography variant="caption">USERS.md</Typography>}
/>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
MEMORY.md
</Typography>
</Stack>
)}
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
variant="contained"
size="small"
onClick={handleApply}
disabled={applying}
startIcon={applying ? <CircularProgress size={12} /> : undefined}
>
{applying ? '应用中...' : '确认切换'}
</Button>
<Button variant="outlined" size="small" onClick={handleCancel} disabled={applying}>
</Button>
</Stack>
</Stack>
)}
<Typography variant="caption" sx={{ color: 'text.disabled' }}></Typography> <Typography variant="caption" sx={{ color: 'text.disabled' }}></Typography>
{/* 重启确认对话框 */}
<Dialog open={showRestartDialog} onClose={() => setShowRestartDialog(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12, mt: 0.5, p: 1, borderRadius: 1, bgcolor: 'background.default' }}>
{workspacePath}
</Typography>
<Typography variant="body2" sx={{ mt: 1.5, color: 'text.secondary' }}>
</Typography>
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setShowRestartDialog(false)}></Button>
<Button size="small" variant="contained" color="primary" onClick={() => window.metona?.app?.restart()}></Button>
</DialogActions>
</Dialog>
</Stack> </Stack>
); );
} }
+26
View File
@@ -172,6 +172,31 @@ interface MetonaAppAPI {
openExternal: (url: string) => Promise<void>; openExternal: (url: string) => Promise<void>;
showItemInFolder: (path: string) => Promise<void>; showItemInFolder: (path: string) => Promise<void>;
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>; selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
restart: () => Promise<{ success: boolean }>;
}
// ===== Workspace API =====
interface MetonaWorkspaceCheckResult {
valid: boolean;
path?: string;
exists?: boolean;
missingFiles?: string[];
isNewWorkspace?: boolean;
reason?: string;
}
interface MetonaWorkspaceInheritResult {
success: boolean;
inherited?: string[];
failed?: Array<{ file: string; error: string }>;
error?: string;
}
interface MetonaWorkspaceAPI {
check: (targetPath: string) => Promise<MetonaWorkspaceCheckResult>;
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
Promise<MetonaWorkspaceInheritResult>;
} }
// ===== Toast API ===== // ===== Toast API =====
@@ -312,6 +337,7 @@ interface MetonaBridge {
memory: MetonaMemoryAPI; memory: MetonaMemoryAPI;
config: MetonaConfigAPI; config: MetonaConfigAPI;
app: MetonaAppAPI; app: MetonaAppAPI;
workspace: MetonaWorkspaceAPI;
toast: MetonaToastAPI; toast: MetonaToastAPI;
tools: MetonaToolsAPI; tools: MetonaToolsAPI;
data: MetonaDataAPI; data: MetonaDataAPI;