diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 85da16b..1918506 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -59,6 +59,18 @@ app.whenReady().then(async () => { const oldDataDir = nodePath.join(app.getPath('userData'), 'legacy-data'); const r = await migrator.migrate(oldDataDir, vault.fileService?.getRoot() ?? '').catch(() => null); if (r?.migrated) console.log('[MIGRATION] migrated', r.count, 'items'); + // FR-A5:GPU 探测——软渲染时自动禁用实时模糊 + let gpuStatus = { softwareRendering: false, vendor: '', renderer: '' }; + try { + const gi = await (app as any).getGPUInfo('full'); + gpuStatus = { + softwareRendering: gi.softwareRendering === true || /SwiftShader|llvmpipe|Software/.test(gi.gpuDevice?.[0]?.renderer ?? ''), + vendor: gi.gpuDevice?.[0]?.vendorString ?? '', + renderer: gi.gpuDevice?.[0]?.renderer ?? '', + }; + } catch { /* getGPUInfo 在某些环境不可用 */ } + ipcMain.handle(IPC.gpuInfo, () => gpuStatus); + registerIpc(vault); createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index d1a6389..54a4087 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -36,7 +36,7 @@ export function App() { const settings = useSettings(); const layout = useLayout(); - useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); }, []); + useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); useSettings.getState().loadGpu(); }, []); useEffect(() => { if (activeId && tabs.length > 0) { const t = tabs.find(x => x.id === activeId); if (t) openDoc(t.path).catch(() => {}); } }, []); useGlobalHotkeys({ onNewDoc: () => {}, onSave: () => useStore.getState().saveNow(), onFocusSearch: () => { const el = document.querySelector('input[placeholder*="搜索"]') as HTMLInputElement; el?.focus(); }, onToggleOutline: () => layout.toggleOutline(), onCheat: () => setShowCheat(true), onSettings: () => setShowSettings(true) }); @@ -44,7 +44,7 @@ export function App() { useEffect(() => { document.documentElement.style.setProperty('--editor-font-size', settings.fontSize + 'px'); document.documentElement.style.setProperty('--editor-font-family', settings.fontFamily); document.documentElement.style.setProperty('--editor-white-space', settings.wordWrap ? 'pre-wrap' : 'pre'); }, [settings.fontSize, settings.fontFamily, settings.wordWrap]); return (
- {appearance.bgImage &&
} + {appearance.bgImage &&
}
setView(v => v === 'editor' ? 'graph' : 'editor')} onSettings={() => setShowSettings(true)} onAppearance={() => setShowAppearance(true)} onToggleSidebar={() => layout.toggleSidebar()} onToggleMinimal={() => layout.toggleMinimal()} minimal={layout.minimal} /> diff --git a/apps/desktop/src/renderer/features/effects/EffectLayer.tsx b/apps/desktop/src/renderer/features/effects/EffectLayer.tsx index 9462e5a..9b6c35f 100644 --- a/apps/desktop/src/renderer/features/effects/EffectLayer.tsx +++ b/apps/desktop/src/renderer/features/effects/EffectLayer.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef } from 'react'; import { EffectEngine } from '../effects/effect-engine'; import { useAppearance } from '../appearance/appearance-store'; +import { useSettings } from '../settings/settings-store'; export function EffectLayer() { const ref = useRef(null); const engineRef = useRef(null); const effect = useAppearance(s => s.effect); const accent = useAppearance(s => s.draft.accent); + const perfMode = useSettings(s => s.perfMode); // 建引擎一次 useEffect(() => { @@ -22,8 +24,9 @@ export function EffectLayer() { const eng = engineRef.current; if (!eng) return; eng.setColor(accent); - eng.setType(effect); - }, [effect, accent]); + // FR-A5:性能优先时关闭特效 + eng.setType(perfMode ? 'off' : effect); + }, [effect, accent, perfMode]); return ; } diff --git a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx index 2e337cf..59d5483 100644 --- a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx @@ -57,7 +57,14 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
代码块显示行号
自动换行
} - {page === 'other' &&
删除前确认
} + {page === 'other' && <> +
删除前确认
+
+ 性能优先{s.gpuStatus?.softwareRendering ? '(检测到软渲染)' : ''} + +
+ {s.gpuStatus &&
GPU: {s.gpuStatus.renderer || '未知'} {s.gpuStatus.softwareRendering ? '⚠️软渲染' : '✓硬件加速'}
} + } {page === 'path' && } {page === 'keymap' && <>
点击「录入」后按下新组合键(需含 Ctrl/Meta/Alt);Esc 取消
diff --git a/apps/desktop/src/renderer/features/settings/settings-store.ts b/apps/desktop/src/renderer/features/settings/settings-store.ts index 5507dfa..7ae9b74 100644 --- a/apps/desktop/src/renderer/features/settings/settings-store.ts +++ b/apps/desktop/src/renderer/features/settings/settings-store.ts @@ -8,6 +8,8 @@ export interface Settings { showLineNumbers: boolean; // 代码块行号,真实生效 wordWrap: boolean; // 自动换行,真实生效 confirmDelete: boolean; // 删除前确认,真实生效 + perfMode: boolean; // FR-A5:性能优先——关闭实时模糊/特效 + gpuStatus: { softwareRendering: boolean; vendor: string; renderer: string } | null; // FR-A5:GPU 探测结果 } const DEFAULTS: Settings = { @@ -17,6 +19,8 @@ const DEFAULTS: Settings = { showLineNumbers: false, wordWrap: true, confirmDelete: true, + perfMode: false, + gpuStatus: null, }; const KEY = 'dsh-settings'; @@ -29,10 +33,19 @@ function load(): Settings { interface SettingsState extends Settings { set: (key: K, value: Settings[K]) => void; persist: () => void; + loadGpu: () => Promise; // FR-A5:从主进程拉取 GPU 状态 } export const useSettings = create((set, get) => ({ ...load(), set: (key, value) => { set({ [key]: value } as any); get().persist(); }, - persist: () => { try { localStorage.setItem(KEY, JSON.stringify({ fontSize: get().fontSize, fontFamily: get().fontFamily, autoSaveInterval: get().autoSaveInterval, showLineNumbers: get().showLineNumbers, wordWrap: get().wordWrap, confirmDelete: get().confirmDelete })); } catch {} }, + persist: () => { try { localStorage.setItem(KEY, JSON.stringify({ fontSize: get().fontSize, fontFamily: get().fontFamily, autoSaveInterval: get().autoSaveInterval, showLineNumbers: get().showLineNumbers, wordWrap: get().wordWrap, confirmDelete: get().confirmDelete, perfMode: get().perfMode })); } catch {} }, + loadGpu: async () => { + try { + const r = await (globalThis as any).dsh.invoke('gpu:info'); + set({ gpuStatus: r as any }); + // FR-A5:软渲染时自动开启性能优先 + if (r?.softwareRendering && !get().perfMode) set({ perfMode: true }); + } catch { /* IPC 不可用时忽略 */ } + }, })); diff --git a/apps/desktop/tests/e2e/perf-mode.spec.ts b/apps/desktop/tests/e2e/perf-mode.spec.ts new file mode 100644 index 0000000..08c28b6 --- /dev/null +++ b/apps/desktop/tests/e2e/perf-mode.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { _electron as electron } from 'playwright'; +import { promises as fs } from 'node:fs'; +import nodePath from 'node:path'; +import os from 'node:os'; + +const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js'); + +async function launch(userData: string) { + const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] }); + const page = await app.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); + return { app, page, invoke }; +} + +// FR-A5:GPU 探测 + 性能优先开关 +test('FR-A5:GPU 探测 + 性能优先开关持久化', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-a5-')); + const userData = nodePath.join(tmp, 'userdata'); + const vault = nodePath.join(tmp, 'vault'); + await fs.mkdir(userData, { recursive: true }); + await fs.mkdir(vault, { recursive: true }); + + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + + // GPU info IPC 应可用 + const gpu = await a.invoke('gpu:info', null as any); + expect(gpu).toBeTruthy(); + expect(typeof gpu.softwareRendering).toBe('boolean'); + + // 打开设置 -> 其他页 + await a.page.getByTitle('设置').click(); + await expect(a.page.getByText('设置', { exact: true })).toBeVisible({ timeout: 5000 }); + await a.page.getByRole('button', { name: '其他' }).click(); + + // 性能优先开关应可见 + await expect(a.page.getByText('性能优先')).toBeVisible({ timeout: 5000 }); + + // GPU 信息应显示 + await expect(a.page.getByText(/GPU:/)).toBeVisible({ timeout: 5000 }); + + // 开启性能优先——通过 data-testid 定位 toggle + const perfToggle = a.page.getByTestId('perf-toggle'); + await perfToggle.click(); + + // 关闭设置面板 + await a.page.keyboard.press('Escape'); + await a.page.waitForTimeout(500); + + // 重开应用验证持久化 + await a.app.close(); + const a2 = await launch(userData); + await a2.invoke('vault:switch', { root: vault }); + await a2.page.getByTitle('设置').click(); + await a2.page.getByRole('button', { name: '其他' }).click(); + + // perfMode 应已持久化(toggle 为开启状态) + const toggleBg = await a2.page.getByTestId('perf-toggle').evaluate((el: HTMLButtonElement) => el.style.background); + // 开启时 background 应为 var(--accent) 或对应值 + expect(toggleBg).toContain('accent'); + + await a2.app.close(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index 266ccc8..9f76813 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -28,6 +28,7 @@ export const IPC = { winMinimize: 'win:minimize', winMaximize: 'win:maximize', winClose: 'win:close', + gpuInfo: 'gpu:info', copyMd: 'doc:copy-md', printPdf: 'doc:print-pdf', appearanceLoad: 'appearance:load', @@ -62,6 +63,7 @@ export interface IpcMap { [IPC.winMinimize]: { args: void; result: void }; [IPC.winMaximize]: { args: void; result: void }; [IPC.winClose]: { args: void; result: void }; + [IPC.gpuInfo]: { args: void; result: { softwareRendering: boolean; vendor: string; renderer: string } }; [IPC.copyMd]: { args: { path: string }; result: string }; [IPC.printPdf]: { args: void; result: void }; [IPC.appearanceLoad]: { args: void; result: { themeId: string; params: Record; effect: string; bgImage: string | null } | null };