Skip to content

Commit 0ab8f2a

Browse files
committed
feat(electron): G7 全局快捷键框架 + E2 录音持久化 IPC 白名单
1 parent 77064a4 commit 0ab8f2a

9 files changed

Lines changed: 298 additions & 0 deletions

File tree

‎client/electron/main.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { registerStorageIpcHandlers } from './storageIpcHandlers.js';
4444
import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js';
4545
import { loadPerformanceMode, registerPerformanceHandlers } from './performanceMode.js';
4646
import { registerShortcuts, unregisterShortcuts } from './shortcutManager.js';
47+
import { registerRecordingIpcHandlers } from './recordingStorage.js';
4748

4849
// ================================================================
4950
// 性能优化:启用 GPU 光栅化与零拷贝
@@ -295,6 +296,9 @@ if (!gotTheLock) {
295296
// (capture-clipboard 等),触发事件经 shortcut:triggered 推送给渲染层
296297
registerShortcuts(() => mainWindow);
297298

299+
// E2 费曼录音持久化 IPC({userData}/recordings 读写)
300+
registerRecordingIpcHandlers();
301+
298302
initAutoUpdater(mainWindow);
299303

300304
// ---- 窗口控制 IPC handlers ----

‎client/electron/preload.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ const ALLOWED_CHANNELS = [
140140
'sovereignty:import-world',
141141
// 知识星座 IPC channel(阶段 B:只读图谱聚合)
142142
'knowledge:get-graph',
143+
// E2 费曼录音持久化 IPC channel({userData}/recordings 读写)
144+
'recording:save',
145+
'recording:load',
146+
'recording:delete',
143147
] as const;
144148

145149
/** 允许渲染进程监听的事件 channel 白名单(主进程 → 渲染进程推送) */
@@ -315,6 +319,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
315319
getActivePath: () =>
316320
ipcRenderer.invoke('storage:get-active-path'),
317321
},
322+
// ---- E2: 费曼录音持久化 API ----
323+
recording: {
324+
save: (stem: string, base64: string) =>
325+
ipcRenderer.invoke('recording:save', { stem, base64 }),
326+
load: (stem: string) =>
327+
ipcRenderer.invoke('recording:load', { stem }),
328+
delete: (stem: string) =>
329+
ipcRenderer.invoke('recording:delete', { stem }),
330+
},
318331
// ---- Ollama 本地推理 API ----
319332
ollama: {
320333
getStatus: (forceRefresh?: boolean) =>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* 费曼录音持久化 IPC — {userData}/recordings 目录读写
3+
* Feynman recording persistence — IPC bridge for {userData}/recordings
4+
*
5+
* @ai-context: E2 跨会话回放——录音 WAV 本地落盘(本地优先,不上传)。
6+
* fileName 强制安全字符 + .wav 后缀(防路径穿越);保存前确保目录存在;
7+
* 读取不存在时返回 not_found 供调用方静默回退(可选增强原则)。
8+
* @ai-context: Local-first WAV persistence for cross-session playback.
9+
* File names are strictly validated to prevent path traversal; missing
10+
* files surface as not_found so the renderer can degrade silently.
11+
*/
12+
import { app } from 'electron';
13+
import * as path from 'path';
14+
import { mkdir, writeFile, readFile, rm } from 'fs/promises';
15+
import { safeHandle } from '../ipcUtils.js';
16+
import { logger } from '../logger.js';
17+
18+
/** 安全文件名:字母数字/下划线/连字符,1-80 字符(防路径穿越) */
19+
const SAFE_NAME_RE = /^[A-Za-z0-9_-]{1,80}$/;
20+
21+
/** 录音根目录:{userData}/recordings */
22+
function recordingsRoot(): string {
23+
return path.join(app.getPath('userData'), 'recordings');
24+
}
25+
26+
/** 校验 fileName(不含扩展名,主进程统一追加 .wav) */
27+
function isSafeStem(stem: unknown): stem is string {
28+
return typeof stem === 'string' && SAFE_NAME_RE.test(stem);
29+
}
30+
31+
/**
32+
* 注册 recording:* IPC handlers(app ready 后调用一次)。
33+
* - recording:save { stem, base64 } → 写 {userData}/recordings/{stem}.wav
34+
* - recording:load { stem } → 读文件返回 base64(不存在 → not_found)
35+
* - recording:delete { stem } → 删除文件(失败静默)
36+
*/
37+
export function registerRecordingIpcHandlers(): void {
38+
// ---- 保存录音 WAV(base64 → 文件)----
39+
safeHandle('recording:save', async (_event, args: { stem: string; base64: string }) => {
40+
const stem = args?.stem;
41+
if (!isSafeStem(stem)) {
42+
throw new Error('非法的录音文件名(仅允许字母数字/下划线/连字符)');
43+
}
44+
if (typeof args?.base64 !== 'string' || args.base64.length === 0) {
45+
throw new Error('录音数据不能为空');
46+
}
47+
const dir = recordingsRoot();
48+
await mkdir(dir, { recursive: true });
49+
await writeFile(path.join(dir, `${stem}.wav`), Buffer.from(args.base64, 'base64'));
50+
return { success: true, fileName: `${stem}.wav` };
51+
});
52+
53+
// ---- 读取录音 WAV(跨会话回放)----
54+
safeHandle('recording:load', async (_event, args: { stem: string }) => {
55+
const stem = args?.stem;
56+
if (!isSafeStem(stem)) {
57+
throw new Error('非法的录音文件名');
58+
}
59+
try {
60+
const data = await readFile(path.join(recordingsRoot(), `${stem}.wav`));
61+
return { success: true, base64: data.toString('base64') };
62+
} catch (err) {
63+
const code = (err as NodeJS.ErrnoException)?.code;
64+
if (code === 'ENOENT') return { success: false, notFound: true };
65+
logger.warn(`[Recording] load failed: ${String(err)}`);
66+
return { success: false, error: 'read_failed' };
67+
}
68+
});
69+
70+
// ---- 删除录音(本地缓存清理,失败静默)----
71+
safeHandle('recording:delete', async (_event, args: { stem: string }) => {
72+
const stem = args?.stem;
73+
if (!isSafeStem(stem)) {
74+
throw new Error('非法的录音文件名');
75+
}
76+
try {
77+
await rm(path.join(recordingsRoot(), `${stem}.wav`), { force: true });
78+
} catch {
79+
/* 删除失败静默——不阻塞 UI */
80+
}
81+
return { success: true };
82+
});
83+
}

‎client/electron/shortcutDefs.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* 全局快捷键静态定义表
3+
*
4+
* 所有系统级快捷键的唯一登记处:未来新增全局快捷键只需在
5+
* SHORTCUT_DEFS 中追加一行,注册/触发/释放逻辑由 shortcutManager.ts
6+
* 统一驱动,无需再触碰 main.ts / preload.ts。
7+
*
8+
* @ai-context: 全局快捷键声明表(id / accelerator / description),
9+
* shortcutManager 遍历本表执行 globalShortcut.register;id 同时作为
10+
* 'shortcut:triggered' 事件 payload 的分发键,渲染侧
11+
* shortcutDispatcher.ts 按 id 路由处理。
12+
* @ai-context: Single source of truth for system-level shortcut
13+
* declarations. Adding a new global shortcut = append one entry here;
14+
* registration, dispatch and disposal are fully driven by
15+
* shortcutManager.ts, so main.ts/preload.ts stay untouched.
16+
*/
17+
18+
/** 单条全局快捷键定义 */
19+
export interface ShortcutDef {
20+
/** 唯一标识,作为 'shortcut:triggered' payload.id 的分发键 */
21+
id: string;
22+
/** Electron Accelerator 字符串(如 'CommandOrControl+Shift+B') */
23+
accelerator: string;
24+
/** 人类可读描述(供设置页/命令面板展示) */
25+
description: string;
26+
/** 触发时由主进程附带剪贴板文本(capture-clipboard 等收集类快捷键) */
27+
withClipboardText?: boolean;
28+
}
29+
30+
/**
31+
* 首批快捷键清单
32+
*
33+
* capture-clipboard:收藏剪贴板文本到统一收件箱(inbox_items 表)。
34+
* 主进程 clipboard.readText() 在窗口失焦时同样可用,故注册在系统级。
35+
*/
36+
export const SHORTCUT_DEFS: ShortcutDef[] = [
37+
{
38+
id: 'capture-clipboard',
39+
accelerator: 'CommandOrControl+Shift+B',
40+
description: '收藏剪贴板内容到收件箱',
41+
withClipboardText: true,
42+
},
43+
];

‎client/electron/shortcutManager.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* 全局快捷键管理器 — 注册/触发分发/释放
3+
* Global shortcut manager — register, dispatch, dispose
4+
*
5+
* @ai-context: 遍历 SHORTCUT_DEFS 声明表注册 globalShortcut;触发时向主窗口
6+
* 推送 'shortcut:triggered' 事件(payload.id 为分发键,withClipboardText 的
7+
* 定义附带剪贴板文本)。注册失败仅告警不阻断启动;will-quit 前必须调用
8+
* unregisterShortcuts 释放(globalShortcut 不随进程自动回收)。渲染侧由
9+
* shortcutDispatcher 按 id 路由处理。
10+
* @ai-context: Registers every entry in SHORTCUT_DEFS; on trigger pushes
11+
* 'shortcut:triggered' to the main window (payload.id is the dispatch key).
12+
* Registration failures are non-fatal; always dispose before quit.
13+
*/
14+
import { globalShortcut, clipboard, type BrowserWindow } from 'electron';
15+
import { SHORTCUT_DEFS } from './shortcutDefs.js';
16+
import { logger } from './logger.js';
17+
18+
let registered = false;
19+
20+
/**
21+
* 注册全部全局快捷键(app ready 后、主窗口创建后调用一次)。
22+
* @param getMainWindow 主窗口获取器——窗口可能重建(macOS activate),
23+
* 每次触发时实时获取,避免持有失效引用
24+
*/
25+
export function registerShortcuts(getMainWindow: () => BrowserWindow | null): void {
26+
if (registered) return;
27+
28+
let okCount = 0;
29+
for (const def of SHORTCUT_DEFS) {
30+
const ok = globalShortcut.register(def.accelerator, () => {
31+
const win = getMainWindow();
32+
if (!win || win.isDestroyed()) return;
33+
const payload: Record<string, unknown> = { id: def.id };
34+
// capture-clipboard:剪贴板读取在主进程完成——窗口失焦时同样可用
35+
if (def.withClipboardText) {
36+
payload.text = clipboard.readText();
37+
}
38+
win.webContents.send('shortcut:triggered', payload);
39+
});
40+
if (ok) {
41+
okCount += 1;
42+
logger.info(`[Shortcut] registered "${def.id}" (${def.accelerator})`);
43+
} else {
44+
logger.warn(`[Shortcut] failed to register "${def.id}" (${def.accelerator}) — accelerator may be taken`);
45+
}
46+
}
47+
registered = true;
48+
logger.info(`[Shortcut] ${okCount}/${SHORTCUT_DEFS.length} global shortcuts registered`);
49+
}
50+
51+
/** 释放全部全局快捷键(window-all-closed / 退出路径调用,幂等) */
52+
export function unregisterShortcuts(): void {
53+
if (!registered) return;
54+
globalShortcut.unregisterAll();
55+
registered = false;
56+
logger.info('[Shortcut] all global shortcuts unregistered');
57+
}

‎client/src/App.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { soundPlayer } from '@/lib/audio/SoundPlayer';
2222
import { getAIConfig } from '@/lib/ai/config';
2323
import { OfflineAIQueueBootstrap } from '@/lib/ai/OfflineAIQueueBootstrap';
2424
import { AssistantRoot } from '@/features/assistant/AssistantRoot';
25+
import { ShortcutRoot } from '@/features/shortcut/ShortcutRoot';
2526
import { assistantEventBus } from '@/features/assistant/lib/eventBus';
2627
import { RETURN_THRESHOLD_MS } from '@/features/assistant/constants';
2728
// 留存机制:初始化 hook(store 加载)+ 深海发现弹窗(全局 portal)
@@ -157,6 +158,9 @@ function App() {
157158
<OfflineAIQueueBootstrap />
158159
{/* AI 学伴:全局浮层,路由之外、Provider 之内,偏好关闭时零开销 */}
159160
<AssistantRoot />
161+
{/* 全局快捷键:订阅主进程 shortcut:triggered 并按 id 分发
162+
(capture-clipboard 收藏剪贴板到收件箱) */}
163+
<ShortcutRoot />
160164
{/* 留存机制:深海发现揭示弹窗(createPortal 到 body,与 AchievementToast 同级)
161165
仅当 discoveryStore 有待展示发现时才渲染实际 DOM */}
162166
<DiscoveryReveal />

‎client/src/env.d.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ declare global {
5050
onMaximizedChanged: (callback: (isMaximized: boolean) => void) => () => void;
5151
onSyncBeforeQuit: (callback: () => void) => () => void;
5252
notifySyncComplete: () => void;
53+
/** 监听全局快捷键触发(payload: { id, text? },shortcutManager 驱动) */
54+
onShortcutTriggered: (callback: (payload: { id: string; text?: string }) => void) => () => void;
5355
setAutoUpdate: (enabled: boolean) => Promise<unknown>;
5456
/** FRONT2-M5: safeStorage 系统级加密(密钥材料落盘保护) */
5557
safeStorageEncrypt: (plain: string) => Promise<string>;
@@ -73,6 +75,12 @@ declare global {
7375
changePath: (newPath: string) => Promise<{ success: boolean; previousPath?: string; newPath?: string; error?: string }>;
7476
getActivePath: () => Promise<unknown>;
7577
};
78+
/** E2: 费曼录音持久化({userData}/recordings) */
79+
recording: {
80+
save: (stem: string, base64: string) => Promise<{ success: boolean; fileName?: string }>;
81+
load: (stem: string) => Promise<{ success: boolean; base64?: string; notFound?: boolean; error?: string }>;
82+
delete: (stem: string) => Promise<{ success: boolean }>;
83+
};
7684
/** Ollama 本地推理 API */
7785
ollama: {
7886
getStatus: (forceRefresh?: boolean) => Promise<{ status: OllamaStatus; config: OllamaConfig }>;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* 全局快捷键根组件 — 订阅主进程触发事件并接线各 handler
3+
* Shortcut root — subscribes to main-process triggers and wires handlers
4+
*
5+
* @ai-context: 在 App.tsx 挂载一次(ToastProvider 内)。capture-clipboard
6+
* 在此接线:剪贴板文本经 inboxRepository 入库(含去重),toast 反馈结果;
7+
* 失败静默降级(剪贴板为空/非 Electron 环境直接忽略)。组件卸载时
8+
* 注销订阅与 handler,避免重复监听。
9+
* @ai-context: Mounted once inside ToastProvider. Wires capture-clipboard to
10+
* the inbox repository with dedupe and toast feedback; degrades silently on
11+
* empty clipboard or missing IPC. Cleans up subscription on unmount.
12+
*/
13+
import { useEffect } from 'react';
14+
import { useToast } from '@/components/ui';
15+
import { registerShortcutHandler, unregisterShortcutHandler, dispatchShortcut } from './shortcutDispatcher';
16+
import { captureClipboardText } from '@/features/inbox/lib/inboxRepository';
17+
18+
export function ShortcutRoot() {
19+
const { toast } = useToast();
20+
21+
useEffect(() => {
22+
const api = window.electronAPI;
23+
24+
registerShortcutHandler('capture-clipboard', async (payload) => {
25+
const text = payload.text?.trim();
26+
if (!text) {
27+
toast({ type: 'info', message: '剪贴板为空,未收藏任何内容' });
28+
return;
29+
}
30+
const result = await captureClipboardText(text);
31+
if (!result) {
32+
toast({ type: 'error', message: '收藏失败,请重试' });
33+
return;
34+
}
35+
toast({
36+
type: result.duplicated ? 'info' : 'success',
37+
message: result.duplicated ? '该内容 24h 内已收藏过' : '已收藏到收件箱',
38+
});
39+
});
40+
41+
const unsubscribe = api?.onShortcutTriggered(dispatchShortcut);
42+
return () => {
43+
unsubscribe?.();
44+
unregisterShortcutHandler('capture-clipboard');
45+
};
46+
}, [toast]);
47+
48+
return null;
49+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* 全局快捷键分发器 — 按 id 路由到注册的 handler
3+
* Shortcut dispatcher — routes shortcut:triggered by id
4+
*
5+
* @ai-context: 渲染侧订阅 preload 的 onShortcutTriggered 后统一交由此分发。
6+
* handler 注册表模式:新快捷键只需 registerShortcutHandler(id, fn) 追加,
7+
* 与主进程 SHORTCUT_DEFS 声明一一对应。dispatchShortcut 无匹配 handler
8+
* 时静默忽略(主进程已登记但渲染层尚未接线的快捷键不报错)。
9+
* @ai-context: Renderer subscribes via preload then funnels events here.
10+
* Handlers register by id, mirroring SHORTCUT_DEFS; unknown ids are
11+
* silently ignored while the renderer wiring catches up.
12+
*/
13+
14+
/** 快捷键触发 payload(与 preload onShortcutTriggered 同构) */
15+
export interface ShortcutPayload {
16+
id: string;
17+
/** capture-clipboard 等收集类快捷键附带的主进程剪贴板文本 */
18+
text?: string;
19+
}
20+
21+
const handlers = new Map<string, (payload: ShortcutPayload) => void>();
22+
23+
/** 注册快捷键 handler(按 id,重复注册覆盖) */
24+
export function registerShortcutHandler(id: string, fn: (payload: ShortcutPayload) => void): void {
25+
handlers.set(id, fn);
26+
}
27+
28+
/** 注销快捷键 handler */
29+
export function unregisterShortcutHandler(id: string): void {
30+
handlers.delete(id);
31+
}
32+
33+
/** 分发一次触发事件 */
34+
export function dispatchShortcut(payload: ShortcutPayload): void {
35+
const fn = handlers.get(payload.id);
36+
if (fn) fn(payload);
37+
}

0 commit comments

Comments
 (0)