Skip to content

Commit c00c087

Browse files
committed
fix(client): resolve frontend medium-risk issues FRONT2-M1~M8
- M1: feynman setExplanation no longer auto-advances step (double advance skipped step2 view); progression owned by advanceStep - M2: pomodoro wall-clock calibration completion branch now plays sounds and sends notifications like the normal path - M3: coral streak/check-in/progress use local dates consistently (UTC+8 midnight planting was counted as previous day) - M4: plantCoral re-reads DB before set, no longer overwrites concurrent plant with stale snapshot - M5: CryptoManager init failure now explicit (hasInitFailed, throws); device key material encrypted via Electron safeStorage IPC (previously plaintext in localStorage); fix missing logger import in SyncEngine (phase 1 regression) - M6: useAudioPlayer.play clears running fadeOut interval - M7: convertWeakPointsToFlashcards batches card creation before marking mastered, avoiding duplicate cards on retry - M8: useVoiceInput serializes stop/start (pending flag + re-check), old stop can no longer kill a newly started capture
1 parent 12a6e68 commit c00c087

15 files changed

Lines changed: 244 additions & 37 deletions

File tree

‎client/electron/main.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* 应用无法启动,任何修改需完整回归启动/退出/托盘/更新流程。
1818
*/
1919

20-
import { app, BrowserWindow, Menu, session, clipboard } from 'electron';
20+
import { app, BrowserWindow, Menu, session, clipboard, safeStorage } from 'electron';
2121
import { safeHandle, setMainWindowId, requireText } from './ipcUtils.js';
2222
import { logger } from './logger.js';
2323
import { registerAIHandlers, initAIModule } from './ai/index.js';
@@ -43,6 +43,7 @@ import { registerKnowledgeIpcHandlers } from './db/knowledgeQueries.js';
4343
import { registerStorageIpcHandlers } from './storageIpcHandlers.js';
4444
import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js';
4545
import { loadPerformanceMode, registerPerformanceHandlers } from './performanceMode.js';
46+
import { registerShortcuts, unregisterShortcuts } from './shortcutManager.js';
4647

4748
// ================================================================
4849
// 性能优化:启用 GPU 光栅化与零拷贝
@@ -202,6 +203,24 @@ if (!gotTheLock) {
202203
// v1.0.0: 注册数据迁移 IPC handlers(IndexedDB → SQLite)
203204
registerMigrationHandlers(safeHandle);
204205

206+
// FRONT2-M5: 密钥材料安全存储 IPC——safeStorage 为 OS 级加密(Windows
207+
// DPAPI/macOS Keychain),渲染进程设备密钥经此加密后才落 localStorage,
208+
// 杜绝 XSS 直读明文密钥解密全部数据(CryptoManager 消费)
209+
safeHandle('crypto:safe-storage-encrypt', async (_event, plain: unknown) => {
210+
const text = requireText(plain, 'plain');
211+
if (!safeStorage.isEncryptionAvailable()) {
212+
throw new Error('safeStorage 在当前平台不可用');
213+
}
214+
return safeStorage.encryptString(text).toString('base64');
215+
});
216+
safeHandle('crypto:safe-storage-decrypt', async (_event, encoded: unknown) => {
217+
const b64 = requireText(encoded, 'encoded');
218+
if (!safeStorage.isEncryptionAvailable()) {
219+
throw new Error('safeStorage 在当前平台不可用');
220+
}
221+
return safeStorage.decryptString(Buffer.from(b64, 'base64'));
222+
});
223+
205224
// 数据访问与存储/备份 IPC(详见 db/dbIpcHandlers.ts、storageIpcHandlers.ts)
206225
registerDbIpcHandlers();
207226
registerStorageIpcHandlers();
@@ -272,6 +291,10 @@ if (!gotTheLock) {
272291
// SEC-005: 设置主窗口 ID 以启用 IPC sender 验证
273292
setMainWindowId(mainWindow.webContents.id);
274293

294+
// G7 全局快捷键框架:注册 SHORTCUT_DEFS 声明的系统级快捷键
295+
// (capture-clipboard 等),触发事件经 shortcut:triggered 推送给渲染层
296+
registerShortcuts(() => mainWindow);
297+
275298
initAutoUpdater(mainWindow);
276299

277300
// ---- 窗口控制 IPC handlers ----
@@ -363,6 +386,8 @@ if (!gotTheLock) {
363386
destroyAutoUpdater();
364387
destroyTray();
365388
closeDb();
389+
// G7: 释放全局快捷键(globalShortcut 不随进程自动回收)
390+
unregisterShortcuts();
366391
mainWindow = null;
367392

368393
// 关闭 MCP Bridge 子进程(防止孤儿进程阻塞退出)

‎client/electron/preload.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ const ALLOWED_EVENT_CHANNELS = [
169169
// 本地 ASR 真流式转写结果推送(partial 实时 / final 断句)
170170
'asr_stream_partial',
171171
'asr_stream_final',
172+
// 全局快捷键触发推送(payload: { id, text? },shortcutManager 驱动)
173+
'shortcut:triggered',
172174
] as const;
173175

174176
/** 允许渲染进程单向发送的 channel 白名单(渲染进程 → 主进程,fire-and-forget) */
@@ -256,13 +258,26 @@ contextBridge.exposeInMainWorld('electronAPI', {
256258
ipcRenderer.on('sync:before-quit', handler);
257259
return () => ipcRenderer.removeListener('sync:before-quit', handler);
258260
},
261+
/** 监听全局快捷键触发(payload: { id, text? },shortcutManager 驱动) */
262+
onShortcutTriggered: (callback: (payload: { id: string; text?: string }) => void) => {
263+
if (!(ALLOWED_EVENT_CHANNELS as readonly string[]).includes('shortcut:triggered')) {
264+
console.warn('[preload] 不允许的事件 channel: shortcut:triggered');
265+
return () => {};
266+
}
267+
const handler = (_event: unknown, payload: { id: string; text?: string }) => callback(payload);
268+
ipcRenderer.on('shortcut:triggered', handler);
269+
return () => ipcRenderer.removeListener('shortcut:triggered', handler);
270+
},
259271
/** 通知主进程同步已完成 */
260272
notifySyncComplete: () => {
261273
ipcRenderer.invoke('sync:quit-complete');
262274
},
263275
// ---- 自动更新 API ----
264276
/** 设置是否自动检查更新 */
265277
setAutoUpdate: (enabled: boolean) => ipcRenderer.invoke('update:set-auto-check', enabled),
278+
// FRONT2-M5: safeStorage 系统级加密(密钥材料落盘保护,CryptoManager 消费)
279+
safeStorageEncrypt: (plain: string) => ipcRenderer.invoke('crypto:safe-storage-encrypt', plain),
280+
safeStorageDecrypt: (encoded: string) => ipcRenderer.invoke('crypto:safe-storage-decrypt', encoded),
266281
// ---- 阶段 A:知识入籍拖拽 API(Electron 35 移除 File.path,必须经 webUtils 转换) ----
267282
/** 拖拽文件 → 绝对路径(供 import:parse-pdf 使用) */
268283
getPathForFile: (file: File) => webUtils.getPathForFile(file),

‎client/src/env.d.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ declare global {
5151
onSyncBeforeQuit: (callback: () => void) => () => void;
5252
notifySyncComplete: () => void;
5353
setAutoUpdate: (enabled: boolean) => Promise<unknown>;
54+
/** FRONT2-M5: safeStorage 系统级加密(密钥材料落盘保护) */
55+
safeStorageEncrypt: (plain: string) => Promise<string>;
56+
safeStorageDecrypt: (encoded: string) => Promise<string>;
5457
backupSave: (data: string, defaultName?: string) => Promise<unknown>;
5558
backupOpen: () => Promise<unknown>;
5659
db: {

‎client/src/features/assistant/hooks/useVoiceInput.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export function useVoiceInput(options: UseVoiceInputOptions = {}) {
3434
const unsubFinalRef = useRef<(() => void) | null>(null);
3535
const watchdogRef = useRef<number | null>(null);
3636
const listeningRef = useRef(false);
37+
// FRONT2-M8: stop 进行中标志——stop 的 await IPC 窗口内新 start 会启动新
38+
// 采集,随后旧 stop 的 audio_capture_stop 把新采集停掉(静音 watchdog 与
39+
// 用户 toggle 并发时必现)。start 等待此标志清除后再继续。
40+
const stopPendingRef = useRef(false);
3741
const onFinalTextRef = useRef(options.onFinalText);
3842
onFinalTextRef.current = options.onFinalText;
3943

@@ -52,15 +56,22 @@ export function useVoiceInput(options: UseVoiceInputOptions = {}) {
5256
const stop = useCallback(async () => {
5357
if (!listeningRef.current) return;
5458
listeningRef.current = false;
59+
// FRONT2-M8: 置位 stop 进行中标志,阻止并发 start 启动新采集
60+
stopPendingRef.current = true;
5561
cleanupIpc();
5662

5763
const api = window.electronAPI;
5864
if (api) {
5965
try {
6066
await api.invoke('local_asr_stream_stop');
61-
await api.invoke('audio_capture_stop');
67+
// 二次校验:stop 等待期间用户已重新 start(listeningRef 已为 true)
68+
// 时不再停新采集——原实现无条件 audio_capture_stop 停掉新采集
69+
if (!listeningRef.current) {
70+
await api.invoke('audio_capture_stop');
71+
}
6272
} catch { /* 静默降级:停止失败不影响对话流 */ }
6373
}
74+
stopPendingRef.current = false;
6475

6576
setListening(false);
6677
setPartialText('');
@@ -76,6 +87,15 @@ export function useVoiceInput(options: UseVoiceInputOptions = {}) {
7687
const start = useCallback(async () => {
7788
const api = window.electronAPI;
7889
if (!api || listeningRef.current) return;
90+
// FRONT2-M8: 序列化 stop/start——stop 进行中时等待其完成(最多 5s),
91+
// 否则新采集启动后会被旧 stop 的 audio_capture_stop 停掉
92+
if (stopPendingRef.current) {
93+
const waitStart = Date.now();
94+
while (stopPendingRef.current && Date.now() - waitStart < 5000) {
95+
await new Promise((r) => setTimeout(r, 50));
96+
}
97+
if (stopPendingRef.current) return; // 超时放弃本次启动
98+
}
7999
setError(null);
80100

81101
try {

‎client/src/features/feynman/store/feynmanStepSlice.ts‎

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,9 @@ export const createStepSlice: FeynmanSlice<StepSlice> = (set, get) => ({
2828

2929
const updated: FeynmanNote = { ...note, explanation, updatedAt: new Date() };
3030

31-
// 如果当前在 step 1,自动推进到 step 2
32-
if (note.currentStep === 1) {
33-
updated.currentStep = 2;
34-
updated.status = 'in_progress';
35-
}
31+
// FRONT2-M1: 不再在 step1 分支自动推进 currentStep——原实现此处置
32+
// currentStep=2,而 useFeynmanSession.handleNext 随后无条件 advanceStep
33+
// 再 +1(2→3),跳过 step2 讲解视图;step 推进统一由 advanceStep 完成一次
3634

3735
await updateWithLog(feynmanNoteStore, 'feynmanNotes', noteId, updated);
3836
set((state) => ({ notes: patchNote(state.notes, updated) }));
@@ -194,18 +192,46 @@ export const createStepSlice: FeynmanSlice<StepSlice> = (set, get) => ({
194192

195193
const { createCard } = useFlashcardStore.getState();
196194

195+
// FRONT2-M7: 先批量创建卡片,全部成功后再统一标记 mastered——
196+
// 原实现逐条"建卡+标记",中途失败后重试会对已建卡的薄弱点重复建卡
197+
const createdWpIds: string[] = [];
198+
try {
199+
for (const wp of toConvert) {
200+
await createCard({
201+
deckId: targetDeckId,
202+
front: wp.text,
203+
back: note.concept,
204+
type: 'basic',
205+
sourceNoteId: noteId,
206+
});
207+
createdWpIds.push(wp.id!);
208+
}
209+
} catch (err) {
210+
// 部分建卡成功时先把已成功的标记 mastered(避免重试重复建卡),再抛出
211+
for (const wpId of createdWpIds) {
212+
const wp = toConvert.find((w) => w.id === wpId);
213+
if (!wp) continue;
214+
try {
215+
await updateWithLog(feynmanWeakPointStore, 'feynmanWeakPoints', wpId, { ...wp, mastered: true });
216+
} catch {
217+
// 标记失败可接受:下次转换最多重复建该张卡
218+
}
219+
}
220+
throw err;
221+
}
222+
223+
// 全部创建成功:统一标记已掌握(逐条容错,失败汇总提示)
224+
const failedMarks: string[] = [];
197225
for (const wp of toConvert) {
198-
await createCard({
199-
deckId: targetDeckId,
200-
front: wp.text,
201-
back: note.concept,
202-
type: 'basic',
203-
sourceNoteId: noteId,
204-
});
205-
206-
// 标记为已掌握
207226
const updated = { ...wp, mastered: true };
208-
await updateWithLog(feynmanWeakPointStore, 'feynmanWeakPoints', wp.id!, updated);
227+
try {
228+
await updateWithLog(feynmanWeakPointStore, 'feynmanWeakPoints', wp.id!, updated);
229+
} catch {
230+
failedMarks.push(wp.id!);
231+
}
232+
}
233+
if (failedMarks.length > 0) {
234+
throw new Error(`部分薄弱点标记失败(${failedMarks.length} 个),请重试`);
209235
}
210236

211237
// 更新本地 store 状态

‎client/src/features/feynman/store/useFeynmanStore.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,17 @@ describe('Feynman Store - Pure Business Logic', () => {
204204
});
205205
});
206206

207-
// ── setExplanation auto-advance ───────────────────────────
207+
// ── setExplanation(FRONT2-M1: 不再自动推进,由 advanceStep 统一推进)──
208208

209209
describe('setExplanation', () => {
210-
it('should auto-advance from step 1 to step 2', async () => {
210+
it('should save explanation without advancing step (advanceStep owns progression)', async () => {
211211
const note = makeNote({ id: 'n10', currentStep: 1, status: 'not_started' });
212212
useFeynmanStore.setState({ notes: [note] });
213213

214214
await useFeynmanStore.getState().setExplanation('n10', 'My explanation');
215215
const updated = useFeynmanStore.getState().notes.find((n) => n.id === 'n10');
216-
expect(updated!.currentStep).toBe(2);
217-
expect(updated!.status).toBe('in_progress');
216+
expect(updated!.currentStep).toBe(1); // 不再自动推进
217+
expect(updated!.status).toBe('not_started');
218218
expect(updated!.explanation).toBe('My explanation');
219219
});
220220

‎client/src/features/pomodoro/store/usePomodoroStore.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,32 @@ totalPausedMs: 0,
601601
});
602602
}
603603

604+
// FRONT2-M2: 墙钟校准完成分支补齐音效与通知——原实现"直接复用上面的
605+
// 完成逻辑"漏掉了这两块(正常分支 L482-506 有),休眠唤醒后的阶段
606+
// 完成无任何提示,用户错过阶段转换
607+
if (!isSilent) {
608+
if (settings.soundEnabled) {
609+
playCompletionSound();
610+
}
611+
if (phase === 'work') {
612+
soundPlayer.play('pomodoro_work_complete');
613+
} else {
614+
soundPlayer.play('pomodoro_break_end');
615+
// 长休结束 = 一整轮完成
616+
if (phase === 'long_break') {
617+
soundPlayer.play('pomodoro_complete');
618+
}
619+
}
620+
}
621+
// 发送浏览器通知
622+
if (settings.notificationEnabled) {
623+
if (phase === 'work') {
624+
sendNotification('又添了一段暖意', '继续深潜吧 ☕').catch(() => {});
625+
} else {
626+
sendNotification('休息结束!', '开始下一个番茄 🍅').catch(() => {});
627+
}
628+
}
629+
604630
set((s) => ({
605631
phase: nextPhase,
606632
remainingSeconds: shouldAutoStart ? duration : 0,

‎client/src/features/retention/lib/streakEngine.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ import type { StreakState } from '../types';
1212
const toISO = (d: Date) =>
1313
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
1414

15+
/**
16+
* 将本地日期字符串(YYYY-MM-DD)解析为本地午夜 Date
17+
* FRONT2-M3: new Date("YYYY-MM-DD") 会按 UTC 午夜解析,UTC+8 时
18+
* lastDate 实际落在前一天的 08:00,与本地日期的 today 间隔计算差一天
19+
*/
20+
function parseLocalDate(dateStr: string): Date {
21+
const [y, m, d] = dateStr.split('-').map(Number);
22+
return new Date(y, m - 1, d);
23+
}
24+
1525
/** 断裂后保留百分比 / Retained percent after break */
1626
export const RETAINED_PERCENT = 50;
1727

@@ -58,7 +68,7 @@ export function updateStreak(
5868
return state;
5969
}
6070

61-
const lastDate = new Date(state.lastActiveDate);
71+
const lastDate = parseLocalDate(state.lastActiveDate);
6272
const gap = daysBetween(lastDate, today);
6373
const restDayPref = state.restDayPreference;
6474

‎client/src/features/retention/lib/worldState.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ export interface WorldSignals {
5959

6060
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
6161

62+
/**
63+
* 本地日期字符串(YYYY-MM-DD)
64+
* FRONT2-M3: 不用 toISOString().split('T')[0](UTC 日期)——UTC+8 用户
65+
* 凌晨 00:00-08:00 的种植会被判入前一天,连击断裂
66+
*/
67+
function toLocalDateStr(d: Date): string {
68+
const y = d.getFullYear();
69+
const m = String(d.getMonth() + 1).padStart(2, '0');
70+
const day = String(d.getDate()).padStart(2, '0');
71+
return `${y}-${m}-${day}`;
72+
}
73+
6274
/**
6375
* 从珊瑚种植日期派生当前连击天数 / Derive current streak from coral plant dates
6476
*
@@ -69,7 +81,7 @@ const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
6981
export function computeCurrentStreakFromCorals(corals: CoralRecord[]): number {
7082
if (corals.length === 0) return 0;
7183
const uniqueDays = [...new Set(
72-
corals.map((c) => new Date(c.plantedAt).toISOString().split('T')[0]),
84+
corals.map((c) => toLocalDateStr(new Date(c.plantedAt))),
7385
)].sort().reverse();
7486

7587
let streak = 1;

‎client/src/features/retention/store/useEcosystemStore.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,12 @@ export const useEcosystemStore = create<EcosystemState>((set, get) => ({
100100
}
101101
}
102102

103-
const totalDepth = restored.reduce((sum, c) => sum + c.depth, 0);
104-
set({ corals: restored, totalDepth });
103+
// FRONT2-M4: 写库完成后重新读库再 set——原实现用旧快照整体覆盖,
104+
// 两个会话并发完成(如番茄钟与闪卡同时触发)时后写者覆盖前者的
105+
// 珊瑚,store 中珊瑚丢失、totalDepth 少算(DB 有但 UI 态丢)
106+
const latest = await db.coralEcosystem.toArray();
107+
const totalDepth = latest.reduce((sum, c) => sum + c.depth, 0);
108+
set({ corals: latest, totalDepth });
105109
} catch { /* 静默失败 */ }
106110
},
107111

0 commit comments

Comments
 (0)