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 };