From d2322be4a697b7d9b69a82056368baeb7eedae18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Nov 2026 08:36:29 +0000 Subject: [PATCH 1/2] Sandbox renderers and encrypt dictation history. The preload is CommonJS so sandbox stays on. History is encrypted with safeStorage, can be turned off, and file drops are still left untouched because Electron cannot round-trip CF_HDROP. Co-authored-by: Thana --- README.md | 7 +- architecture/current-architecture.md | 2 +- architecture/system-design.md | 13 +-- electron/config.test.ts | 8 ++ electron/config.ts | 3 + electron/elevenlabs.test.ts | 2 +- electron/history.ts | 62 +++++++++--- electron/historyRepository.test.ts | 78 +++++++++++++-- electron/historyRepository.ts | 138 ++++++++++++++++++++++++--- electron/main.test.ts | 54 +++++++++-- electron/main.ts | 67 ++++++++----- electron/openrouterUsage.test.ts | 2 +- electron/pasteText.test.ts | 9 +- electron/pasteText.ts | 5 + electron/preloadOutput.ts | 3 + electron/providerRequest.test.ts | 2 +- electron/windowGuard.test.ts | 37 +++++++ electron/windowGuard.ts | 20 ++++ src/pages/history.tsx | 7 +- src/pages/home.tsx | 2 +- src/pages/settings.tsx | 9 ++ src/types.d.ts | 2 + vite.config.ts | 10 ++ 23 files changed, 460 insertions(+), 82 deletions(-) create mode 100644 electron/preloadOutput.ts create mode 100644 electron/windowGuard.test.ts create mode 100644 electron/windowGuard.ts diff --git a/README.md b/README.md index 53ad6ab..45037b9 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ - **Dictionary** — คำเฉพาะ / ชื่อเฉพาะ / ศัพท์เทคนิค ช่วย bias การถอดเสียง (ยังไม่ส่ง Dictionary words ไป ElevenLabs) - **Correction rules** — แทนที่คำที่ถอดผิดซ้ำๆ แบบตรงตัวหลังถอดเสียง - **AI polish / Translate** — เก็บประโยค หรือแปลจากเสียง (ใช้ chat model) -- **History** — ประวัติการถอดเสียงสูงสุด 500 รายการ คัดลอกซ้ำได้ +- **History** — ประวัติการถอดเสียงสูงสุด 500 รายการ เข้ารหัสในเครื่อง คัดลอกซ้ำได้ และปิดการบันทึกหรือล้างได้ - **ตั้งค่าปุ่มลัดแบบกดจริง** — กดคีย์ที่ต้องการ ระบบจับ accelerator ให้เอง - **เปิดตอน Windows เริ่ม** — เปิด/ปิดได้ใน Settings @@ -162,7 +162,8 @@ OpenRouter / OpenAI ใช้คีย์เดียวเรียกได้ ## ความเป็นส่วนตัวของข้อมูล -- Settings, History และ Dictionary เก็บเป็น JSON **ในเครื่องเท่านั้น** (`electron-store`) ไม่ซิงก์ขึ้นคลาวด์ของแอป +- Settings และ Dictionary เก็บเป็น JSON **ในเครื่องเท่านั้น** ไม่ซิงก์ขึ้นคลาวด์ของแอป +- History ถูกเข้ารหัสด้วย Windows DPAPI ผ่าน Electron `safeStorage` สูงสุด 500 รายการ / 4 MB ปิดการบันทึกได้ใน Settings และล้างได้ในหน้าประวัติ - API key เข้ารหัสด้วย Windows DPAPI ผ่าน Electron `safeStorage` - มีแค่เสียงพูด / ข้อความที่ส่งไปถอดเสียงหรือ polish เท่านั้นที่ออกนอกเครื่อง และส่งไปยัง endpoint ที่คุณตั้งไว้เท่านั้น @@ -221,7 +222,7 @@ git push origin "v$version" ### สถาปัตยกรรมโดยย่อ -Electron แยก 2 โปรเซส คุยกันผ่าน `contextBridge` (`electron/preload.ts`) เท่านั้น — `contextIsolation: true`, `nodeIntegration: false` +Electron แยก 2 โปรเซส คุยกันผ่าน `contextBridge` (`electron/preload.ts`, ถูก build เป็น `preload.cjs`) เท่านั้น — `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` - **`electron/`** — main process: หน้าต่าง widget + dashboard, global hotkey, tray, ถอดเสียง, วางข้อความ, persistence - **`src/`** — renderer: React dashboard shell, floating pill, อัดเสียง, dashboard pages และ onboarding diff --git a/architecture/current-architecture.md b/architecture/current-architecture.md index 19a02ab..6e63ade 100644 --- a/architecture/current-architecture.md +++ b/architecture/current-architecture.md @@ -59,7 +59,7 @@ flowchart LR ## Security and verification limits -- Keep renderer access behind the preload bridge: `contextIsolation: true`, `nodeIntegration: false`. +- Keep renderer access behind the preload bridge: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`, with the preload emitted as CommonJS. - API keys are redacted from returned settings; `safeStorage` encrypts them when available. Current code falls back to plaintext if encryption is unavailable. - A known changed HWND skips paste; paste failure leaves text on the clipboard. Missing captured target or failed foreground lookup still permits a paste attempt in current code. - There is no application backend or server database. Audio/text leaves the device when sent to the configured AI provider. diff --git a/architecture/system-design.md b/architecture/system-design.md index 57fd930..4a83be2 100644 --- a/architecture/system-design.md +++ b/architecture/system-design.md @@ -35,7 +35,7 @@ Electron main process - The dashboard shell, navigation, Home, History, Dictionary, Settings, and onboarding use React. The recording widget remains DOM-based; microphone capture is handled by `src/runtime/recorder.ts`. - The widget is transparent, always on top, mouse-transparent, and non-focusable. - The dashboard is a single normal window opened from the tray. Reopening it focuses the existing instance and sends a navigation event. -- `electron/preload.ts` is the only renderer-to-main bridge. Keep its API synchronized with `src/types.d.ts`. +- `electron/preload.ts` is the only renderer-to-main bridge. It is emitted as CommonJS `preload.cjs` so both windows can set `sandbox: true`. Keep its API synchronized with `src/types.d.ts`. ## Lifecycle @@ -96,21 +96,22 @@ Global hotkey ## Persistence and secrets -- Settings and dictionary use separate `electron-store` files. History retains the migrated `history.json` format, initializes through `electron-store`, then uses a memory cache and serialized asynchronous atomic file replacement. +- Settings and dictionary use separate `electron-store` files. History keeps the `history.json` path, then uses a memory cache and serialized asynchronous atomic file replacement. The file body is `safeStorage` ciphertext. An existing plaintext `{entries}` file is migrated after encryption is available and is left untouched if encryption is not. New history is not written in plaintext. `saveHistory` can turn off new entries; History can clear what is already stored. The 500-entry and 4 MB caps still apply to the plaintext payload. - Stores use `%APPDATA%/OpenPood`. Before any store initializes, `storage.ts` copies the known legacy OpenPud JSON stores byte-for-byte, validating JSON first, retaining the source, and never overwriting existing destination stores. A completion marker prevents later remigration. Migration failure blocks startup with a generic native error; no secret data is logged. - The NSIS GUID remains the original upgrade identity while the application ID, product name, and executable name become OpenPood. Installer upgrade behavior still requires a packaged Windows check. - A single-instance lock is acquired before migration/store creation. A repeated launch opens the existing dashboard. Packaged startup registration removes the former OpenPud entry before applying the saved setting. - The API key is encrypted with Electron `safeStorage` and decrypted only in main. Key saves fail when encryption is unavailable; ciphertext is never treated as plaintext. Legacy plaintext-key migration runs only after encryption is ready and removes the old field. - Renderer settings redact the key and expose only `hasApiKey`. -- History is capped at 500 entries and 4 MB of serialized data, evicting oldest entries without truncating retained transcripts. History UI renders 30 entries per page. A failed history write retains the new transcript temporarily in memory and warns the user; a later successful write retries persistence. Quit waits for pending history writes. +- History is capped at 500 entries and 4 MB of serialized data, evicting oldest entries without truncating retained transcripts. History UI renders 30 entries per page. A failed disk write retains the new transcript temporarily in memory and warns the user; a later successful write retries persistence. If encryption is unavailable, the new transcript is not written to disk and is not treated as saved. Quit waits for pending history writes. - Settings are cached in main; a save validates/encrypts the complete patch before one atomic store write. Failed hotkey registration restores the previous hotkeys and reports the conflict. -- History text, dictionary words, correction rules, and non-secret settings remain local JSON data. +- Dictionary words, correction rules, and non-secret settings remain local JSON data. History text is encrypted at rest. - Never log API keys, authorization headers, audio payloads, or full provider error bodies. ## Security boundaries -- Browser windows keep `contextIsolation: true` and `nodeIntegration: false`. -- New windows and external navigation are denied. +- Browser windows keep `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: true`. +- A session permission handler allows `media` only for the widget and denies every other permission. +- New windows are denied. `will-navigate` allows only the app page built with `pathToFileURL`, plus the dev server origin while developing. - Dashboard routes are allowlisted before crossing IPC. - IPC inputs are treated as untrusted: settings keys/types, collections, routes, audio shape, and audio size are validated in main. - IPC handlers also authorize the sender by capability: dashboard-only data APIs reject the widget, transcription rejects the dashboard, and unknown renderer senders are rejected. diff --git a/electron/config.test.ts b/electron/config.test.ts index 2e3a927..8284cb5 100644 --- a/electron/config.test.ts +++ b/electron/config.test.ts @@ -62,6 +62,14 @@ describe('settings security and atomic saves', () => { expect(() => config.setSettings({ maxDurationSec })).toThrow(); expect(state.writes).toBe(0); }); + it('saves history by default and can turn it off', async () => { + const config = await import('./config'); + expect(config.getSettings().saveHistory).toBe(true); + expect(config.getRendererSettings().saveHistory).toBe(true); + config.setSettings({ saveHistory: false }); + expect(config.getSettings().saveHistory).toBe(false); + expect(config.getRendererSettings().saveHistory).toBe(false); + }); it('does not update cached settings if the disk write fails', async () => { const config = await import('./config'); state.failWrite = true; diff --git a/electron/config.ts b/electron/config.ts index 3349750..b9e8b37 100644 --- a/electron/config.ts +++ b/electron/config.ts @@ -19,6 +19,7 @@ export interface AppSettings { chatModel: string; translateHotkey: string; translateTargetLang: string; + saveHistory: boolean; } // On disk, apiKey is kept as a base64-encoded blob encrypted via Electron's @@ -44,6 +45,7 @@ const defaults: StoredSettings = { chatModel: 'google/gemini-3.5-flash-lite', translateHotkey: 'Control+Alt+T', translateTargetLang: 'en', + saveHistory: true, }; // Shape sent to the renderer over `settings:get` — the plaintext key never @@ -128,6 +130,7 @@ export function getSettings(): AppSettings { const duration = rest.maxDurationSec; return { ...rest, maxDurationSec: Number.isFinite(duration) && duration > 0 ? Math.min(duration, RUNTIME.maxRecordingSec) : defaults.maxDurationSec, + saveHistory: rest.saveHistory !== false, apiKey: decryptApiKey(apiKeyEncrypted) }; } diff --git a/electron/elevenlabs.test.ts b/electron/elevenlabs.test.ts index f464407..d92d5dd 100644 --- a/electron/elevenlabs.test.ts +++ b/electron/elevenlabs.test.ts @@ -9,7 +9,7 @@ const settings: AppSettings = { language: '', hotkey: 'Control+Space', micDeviceId: '', launchAtStartup: false, playSound: false, maxDurationSec: 120, onboardingCompleted: true, stripFillersEnabled: true, aiPolishEnabled: false, chatModel: '', - translateHotkey: 'Control+Alt+T', translateTargetLang: 'en', + translateHotkey: 'Control+Alt+T', translateTargetLang: 'en', saveHistory: true, }; const audio = Buffer.from('test audio'); afterEach(() => vi.unstubAllGlobals()); diff --git a/electron/history.ts b/electron/history.ts index b632b0d..03971d1 100644 --- a/electron/history.ts +++ b/electron/history.ts @@ -1,37 +1,69 @@ -import Store from 'electron-store'; +import path from 'node:path'; +import { Notification, safeStorage } from 'electron'; import { STORE_DIRECTORY } from './storage'; -import { HistoryRepository, type HistoryEntry } from './historyRepository'; +import { HistoryEncryptionUnavailable, HistoryRepository, type HistoryCodec, type HistoryEntry } from './historyRepository'; export type { HistoryEntry } from './historyRepository'; +export { HistoryEncryptionUnavailable } from './historyRepository'; -interface HistorySchema { - entries: HistoryEntry[]; +const historyPath = path.join(STORE_DIRECTORY, 'history.json'); + +function historyCodec(): HistoryCodec { + return { + encrypt(plaintext: string): Buffer { + if (!safeStorage.isEncryptionAvailable()) throw new HistoryEncryptionUnavailable(); + try { return safeStorage.encryptString(plaintext); } + catch { throw new HistoryEncryptionUnavailable(); } + }, + decrypt(ciphertext: Buffer): string { + if (!safeStorage.isEncryptionAvailable()) throw new HistoryEncryptionUnavailable(); + return safeStorage.decryptString(ciphertext); + }, + }; +} + +let repository: HistoryRepository | undefined; +const warned = new Set(); + +function warnOnce(body: string): void { + if (warned.has(body)) return; + warned.add(body); + try { + if (Notification.isSupported()) new Notification({ title: 'บันทึกประวัติไม่ได้', body }).show(); + else console.warn(body); + } catch { + console.warn(body); + } } -const historyStore = new Store({ - cwd: STORE_DIRECTORY, - name: 'history', - defaults: { entries: [] }, -}); +function repo(): HistoryRepository { + repository ??= new HistoryRepository(historyPath, historyCodec(), { + onEncryptionUnavailable: () => warnOnce('ระบบเข้ารหัสประวัติยังไม่พร้อม จึงไม่บันทึกประวัติใหม่ลงดิสก์'), + onUnreadable: () => warnOnce('อ่านประวัติที่เข้ารหัสไว้ไม่สำเร็จ จึงไม่แสดงและไม่เขียนทับไฟล์'), + }); + return repository; +} -const repository = new HistoryRepository(historyStore.path, historyStore.get('entries')); +export function warmHistory(): void { + repo(); +} export function addHistoryEntry(text: string, durationMs: number, model: string): Promise { - return repository.add(text, durationMs, model); + return repo().add(text, durationMs, model); } export function listHistory(): HistoryEntry[] { - return repository.list(); + return repo().list(); } export function clearHistory(): Promise { - return repository.clear(); + return repo().clear(); } -export function flushHistory(): Promise { return repository.flush(); } +export function flushHistory(): Promise { return repo().flush(); } export function historyStats() { - const entries = repository.list(); + const entries = repo().list(); const totalWords = entries.reduce((sum, e) => sum + e.wordCount, 0); const totalMs = entries.reduce((sum, e) => sum + e.durationMs, 0); const totalMinutes = totalMs / 60000; diff --git a/electron/historyRepository.test.ts b/electron/historyRepository.test.ts index 419b4c7..1e55978 100644 --- a/electron/historyRepository.test.ts +++ b/electron/historyRepository.test.ts @@ -1,33 +1,45 @@ -import { mkdtemp, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { HistoryRepository, type HistoryEntry } from './historyRepository'; +import { HISTORY_FILE_MAGIC, HistoryEncryptionUnavailable, HistoryRepository, type HistoryCodec, type HistoryEntry } from './historyRepository'; import { RUNTIME } from '../src/lib/runtimeConfig'; +const codec: HistoryCodec = { + encrypt: (plaintext) => Buffer.from(Buffer.from(plaintext, 'utf8').toString('base64'), 'utf8'), + decrypt: (ciphertext) => Buffer.from(ciphertext.toString('utf8'), 'base64').toString('utf8'), +}; + let directory: string; let file: string; beforeEach(async () => { directory = await mkdtemp(path.join(os.tmpdir(), 'openpood-history-')); file = path.join(directory, 'history.json'); }); afterEach(async () => { await rm(directory, { recursive: true, force: true }); }); +async function storedEntries(target = file): Promise { + const raw = await readFile(target); + expect(raw.subarray(0, HISTORY_FILE_MAGIC.length).equals(HISTORY_FILE_MAGIC)).toBe(true); + const body = raw.subarray(HISTORY_FILE_MAGIC.length); + return JSON.parse(codec.decrypt(body)).entries as HistoryEntry[]; +} + describe('asynchronous bounded history', () => { it('serializes concurrent add/clear without resurrecting deleted entries', async () => { - const repository = new HistoryRepository(file, []); + const repository = new HistoryRepository(file, codec); await Promise.all([repository.add('first', 1000, 'test'), repository.clear(), repository.add('last', 2000, 'test')]); expect(repository.list().map((entry) => entry.text)).toEqual(['last']); - expect(JSON.parse(await readFile(file, 'utf8')).entries.map((entry: HistoryEntry) => entry.text)).toEqual(['last']); + expect((await storedEntries()).map((entry) => entry.text)).toEqual(['last']); }); it('keeps a transcript in memory on disk failure and retries persistence next time', async () => { await mkdir(file); - const repository = new HistoryRepository(file, []); + const repository = new HistoryRepository(file, codec); await expect(repository.add('recover me', 1000, 'test')).rejects.toThrow('บันทึกประวัติ'); expect(repository.list()[0].text).toBe('recover me'); await rm(file, { recursive: true }); await repository.add('second', 1000, 'test'); - expect(JSON.parse(await readFile(file, 'utf8')).entries).toHaveLength(2); + expect(await storedEntries()).toHaveLength(2); }); it('does not discard memory history when clear fails', async () => { - const repository = new HistoryRepository(file, []); + const repository = new HistoryRepository(file, codec); await repository.add('keep me', 1000, 'test'); await rm(file); await mkdir(file); @@ -36,18 +48,64 @@ describe('asynchronous bounded history', () => { }); it('caps both entry count and serialized bytes without truncating individual text', async () => { const entry: HistoryEntry = { id: 'id', text: 'ก'.repeat(30000), model: 'test', timestamp: 1, durationMs: 1000, wordCount: 1 }; - const repository = new HistoryRepository(file, Array.from({ length: 600 }, (_, index) => ({ ...entry, id: String(index) }))); + await writeFile(file, JSON.stringify({ entries: Array.from({ length: 600 }, (_, index) => ({ ...entry, id: String(index) })) })); + const repository = new HistoryRepository(file, codec); + await repository.flush(); await repository.add('newest', 1000, 'test'); expect(repository.list().length).toBeLessThanOrEqual(RUNTIME.maxHistoryEntries); - expect((await stat(file)).size).toBeLessThanOrEqual(RUNTIME.maxHistoryBytes); + const payload = JSON.stringify({ entries: await storedEntries() }); + expect(Buffer.byteLength(payload)).toBeLessThanOrEqual(RUNTIME.maxHistoryBytes); expect(repository.list()[1].text).toBe(entry.text); }); it('validates migrated entries and prevents callers from mutating cached history', async () => { - const repository = new HistoryRepository(file, [null, { text: 'broken' }]); + await writeFile(file, JSON.stringify({ entries: [null, { text: 'broken' }] })); + const repository = new HistoryRepository(file, codec); + await repository.flush(); expect(repository.list()).toEqual([]); await repository.add('original', 1000, 'test'); repository.list()[0].text = 'mutated'; expect(repository.list()[0].text).toBe('original'); await expect(repository.add('empty', Number.NaN, 'test')).rejects.toThrow('ไม่ถูกต้อง'); }); + it('encrypts history and migrates an existing plaintext file without leaving the transcript in the clear', async () => { + await writeFile(file, JSON.stringify({ entries: [{ id: '1', text: 'secret transcript', model: 'test', timestamp: 1, durationMs: 1000, wordCount: 2 }] })); + const repository = new HistoryRepository(file, codec); + await repository.flush(); + const raw = await readFile(file); + expect(raw.includes(Buffer.from('secret transcript'))).toBe(false); + expect(raw.includes(Buffer.from('"entries"'))).toBe(false); + expect(repository.list()[0].text).toBe('secret transcript'); + expect((await storedEntries())[0].text).toBe('secret transcript'); + }); + it('does not replace plaintext when encryption is unavailable', async () => { + const plaintext = JSON.stringify({ entries: [{ id: '1', text: 'keep plaintext', model: 'test', timestamp: 1, durationMs: 1000, wordCount: 2 }] }); + await writeFile(file, plaintext); + let warnings = 0; + const unavailable: HistoryCodec = { + encrypt() { throw new HistoryEncryptionUnavailable(); }, + decrypt: codec.decrypt, + }; + const repository = new HistoryRepository(file, unavailable, { onEncryptionUnavailable: () => { warnings += 1; } }); + await repository.flush(); + expect(await readFile(file, 'utf8')).toBe(plaintext); + expect(repository.list()[0].text).toBe('keep plaintext'); + await expect(repository.add('new secret', 1000, 'test')).rejects.toBeInstanceOf(HistoryEncryptionUnavailable); + expect(await readFile(file, 'utf8')).toBe(plaintext); + expect(repository.list().map((entry) => entry.text)).toEqual(['keep plaintext']); + expect(warnings).toBeGreaterThan(0); + }); + it('does not overwrite ciphertext it cannot read, and clear deletes it', async () => { + const sealed = Buffer.concat([HISTORY_FILE_MAGIC, Buffer.from('not-ciphertext')]); + await writeFile(file, sealed); + let unreadable = 0; + const failing: HistoryCodec = { encrypt: codec.encrypt, decrypt() { throw new Error('wrong machine'); } }; + const repository = new HistoryRepository(file, failing, { onUnreadable: () => { unreadable += 1; } }); + expect(repository.list()).toEqual([]); + expect(unreadable).toBe(1); + await expect(repository.add('nope', 1000, 'test')).rejects.toThrow('อ่านประวัติ'); + expect(await readFile(file)).toEqual(sealed); + await repository.clear(); + await expect(readFile(file)).rejects.toThrow(); + expect(repository.list()).toEqual([]); + }); }); diff --git a/electron/historyRepository.ts b/electron/historyRepository.ts index f19dc20..9f6fc7e 100644 --- a/electron/historyRepository.ts +++ b/electron/historyRepository.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; import { rename, rm, writeFile } from 'node:fs/promises'; import { RUNTIME } from '../src/lib/runtimeConfig'; @@ -11,6 +12,21 @@ export interface HistoryEntry { wordCount: number; } +export const HISTORY_FILE_MAGIC = Buffer.from('OPENPOOD-HISTORY-1\n', 'utf8'); +const UNREADABLE_HISTORY = 'อ่านประวัติที่เข้ารหัสไว้ไม่สำเร็จ จึงไม่เขียนทับไฟล์'; + +export class HistoryEncryptionUnavailable extends Error { + constructor() { + super('ระบบเข้ารหัสประวัติยังไม่พร้อม จึงไม่บันทึกประวัติลงดิสก์'); + this.name = 'HistoryEncryptionUnavailable'; + } +} + +export interface HistoryCodec { + encrypt(plaintext: string): Buffer; + decrypt(ciphertext: Buffer): string; +} + function validEntry(value: unknown): value is HistoryEntry { if (!value || typeof value !== 'object') return false; const entry = value as HistoryEntry; @@ -32,33 +48,109 @@ function bounded(entries: HistoryEntry[]): HistoryEntry[] { return kept; } -// Memory reads, serialized asynchronous writes, and atomic replace of the same -// migrated history.json. Commit memory only after persistence succeeds. +function entriesFromUnknown(value: unknown): HistoryEntry[] { + return bounded(Array.isArray(value) ? value.filter(validEntry) : []); +} + +interface LoadedHistory { + entries: HistoryEntry[]; + migrate: boolean; + unreadable: boolean; +} + +function loadFile(filePath: string, codec: HistoryCodec): LoadedHistory { + let raw: Buffer; + try { raw = readFileSync(filePath); } + catch (err) { + const code = (err as NodeJS.ErrnoException).code; + // A missing file or a path that is not a file is not sealed ciphertext. + if (code === 'ENOENT' || code === 'EISDIR') return { entries: [], migrate: false, unreadable: false }; + return { entries: [], migrate: false, unreadable: true }; + } + if (raw.length === 0) return { entries: [], migrate: false, unreadable: false }; + if (raw.subarray(0, HISTORY_FILE_MAGIC.length).equals(HISTORY_FILE_MAGIC)) { + try { + const parsed = JSON.parse(codec.decrypt(raw.subarray(HISTORY_FILE_MAGIC.length))) as { entries?: unknown }; + return { entries: entriesFromUnknown(parsed.entries), migrate: false, unreadable: false }; + } catch { + return { entries: [], migrate: false, unreadable: true }; + } + } + try { + const parsed = JSON.parse(raw.toString('utf8')) as { entries?: unknown }; + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { entries: [], migrate: false, unreadable: true }; + } + return { entries: entriesFromUnknown(parsed.entries), migrate: true, unreadable: false }; + } catch { + return { entries: [], migrate: false, unreadable: true }; + } +} + +// Memory reads, serialized asynchronous writes, and atomic replace of history.json. +// The file body is safeStorage ciphertext. Plaintext {entries} is migrated once +// encryption works. Commit memory only after an encrypted write succeeds. export class HistoryRepository { private entries: HistoryEntry[]; private writes: Promise = Promise.resolve(); private readonly filePath: string; + private readonly codec: HistoryCodec; + private readonly onEncryptionUnavailable?: () => void; + private readonly onUnreadable?: () => void; + private unreadable: boolean; - constructor(filePath: string, initial: unknown) { + constructor(filePath: string, codec: HistoryCodec, options?: { onEncryptionUnavailable?: () => void; onUnreadable?: () => void }) { this.filePath = filePath; - this.entries = bounded(Array.isArray(initial) ? initial.filter(validEntry) : []); + this.codec = codec; + this.onEncryptionUnavailable = options?.onEncryptionUnavailable; + this.onUnreadable = options?.onUnreadable; + const loaded = loadFile(filePath, codec); + this.entries = loaded.entries; + this.unreadable = loaded.unreadable; + if (loaded.unreadable) this.onUnreadable?.(); + if (loaded.migrate) { + this.writes = this.persist(this.entries).catch((err: unknown) => { + if (err instanceof HistoryEncryptionUnavailable) this.onEncryptionUnavailable?.(); + }); + } } list(): HistoryEntry[] { return this.entries.map((entry) => ({ ...entry })); } + private encode(next: HistoryEntry[]): Buffer { + try { + return Buffer.concat([HISTORY_FILE_MAGIC, this.codec.encrypt(JSON.stringify({ entries: next }))]); + } catch (err) { + if (err instanceof HistoryEncryptionUnavailable) throw err; + throw new HistoryEncryptionUnavailable(); + } + } + + private async persist(next: HistoryEntry[]): Promise { + const payload = this.encode(next); + const temporary = `${this.filePath}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, payload, { flag: 'wx', mode: 0o600 }); + await rename(temporary, this.filePath); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } + } + private enqueue(change: (current: HistoryEntry[]) => HistoryEntry[], preserveOnFailure = false): Promise { const operation = this.writes.then(async () => { + if (this.unreadable) throw new Error(UNREADABLE_HISTORY); const next = bounded(change(this.entries)); - const temporary = `${this.filePath}.${randomUUID()}.tmp`; try { - await writeFile(temporary, JSON.stringify({ entries: next }), { flag: 'wx', mode: 0o600 }); - await rename(temporary, this.filePath); + await this.persist(next); this.entries = next; - } catch { + } catch (err) { + if (err instanceof HistoryEncryptionUnavailable) { + this.onEncryptionUnavailable?.(); + throw err; + } if (preserveOnFailure) this.entries = next; throw new Error('บันทึกประวัติไม่สำเร็จ กรุณาตรวจสอบพื้นที่และสิทธิ์เขียนไฟล์'); - } finally { - await rm(temporary, { force: true }).catch(() => undefined); } }); // A failed write must not poison subsequent clear/add attempts. @@ -76,6 +168,30 @@ export class HistoryRepository { return entry; } - clear(): Promise { return this.enqueue(() => []); } + clear(): Promise { + const operation = this.writes.then(async () => { + if (this.unreadable) { + await rm(this.filePath, { force: true }); + this.entries = []; + this.unreadable = false; + return; + } + try { + await this.persist([]); + this.entries = []; + } catch (err) { + if (err instanceof HistoryEncryptionUnavailable) { + await rm(this.filePath, { force: true }); + this.entries = []; + this.onEncryptionUnavailable?.(); + return; + } + throw new Error('บันทึกประวัติไม่สำเร็จ กรุณาตรวจสอบพื้นที่และสิทธิ์เขียนไฟล์'); + } + }); + this.writes = operation.catch(() => undefined); + return operation; + } + flush(): Promise { return this.writes; } } diff --git a/electron/main.test.ts b/electron/main.test.ts index 3389648..ee01cbf 100644 --- a/electron/main.test.ts +++ b/electron/main.test.ts @@ -3,6 +3,7 @@ import type { EventEmitter } from 'node:events'; import { RUNTIME } from '../src/lib/runtimeConfig'; type TestWindow = EventEmitter & { + options?: { webPreferences?: { preload?: string; sandbox?: boolean; contextIsolation?: boolean; nodeIntegration?: boolean } }; webContents: EventEmitter & { send: ReturnType; setWindowOpenHandler: ReturnType; getURL: ReturnType }; destroy: () => void; loadURL: ReturnType; @@ -15,6 +16,11 @@ const mocks = vi.hoisted(() => ({ windows: [] as TestWindow[], handlers: new Map any>(), listeners: new Map any>(), appEvents: new Map any>(), shortcuts: new Map void>(), + permissionRequest: undefined as ((webContents: object, permission: string, callback: (granted: boolean) => void) => void) | undefined, + permissionCheck: undefined as ((webContents: object | null, permission: string) => boolean) | undefined, + settings: { apiKey: 'test-key', model: 'test-model', hotkey: 'Control+Space', translateHotkey: 'Control+Alt+T', + launchAtStartup: false, onboardingCompleted: true, stripFillersEnabled: false, aiPolishEnabled: false, + apiBaseUrl: 'https://provider.test', saveHistory: true }, foreground: vi.fn(), transcribe: vi.fn(), paste: vi.fn(), addHistory: vi.fn(), setSettings: vi.fn(), })); @@ -24,7 +30,8 @@ vi.mock('electron', async () => { static getAllWindows() { return mocks.windows; } webContents = Object.assign(new EventEmitter(), { send: vi.fn(), setWindowOpenHandler: vi.fn(), getURL: vi.fn(() => 'file:///app/index.html#/'), isDestroyed: () => this.destroyed }); private destroyed = false; - constructor() { super(); mocks.windows.push(this as TestWindow); } + options?: TestWindow['options']; + constructor(options?: TestWindow['options']) { super(); this.options = options; mocks.windows.push(this as unknown as TestWindow); } setAlwaysOnTop() {} setVisibleOnAllWorkspaces() {} setIgnoreMouseEvents() {} setPosition() {} focus() {} loadURL = vi.fn(() => Promise.resolve()); isMinimized = vi.fn(() => false); restore = vi.fn(); show = vi.fn(); @@ -54,19 +61,22 @@ vi.mock('electron', async () => { dialog: { showErrorBox: vi.fn() }, Menu: { buildFromTemplate: vi.fn() }, nativeImage: { createFromPath: () => ({ isEmpty: () => false, resize: () => ({}) }) }, screen: { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getDisplayNearestPoint: () => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }), on: vi.fn() }, + session: { defaultSession: { + setPermissionRequestHandler: (handler: typeof mocks.permissionRequest) => { mocks.permissionRequest = handler; }, + setPermissionCheckHandler: (handler: typeof mocks.permissionCheck) => { mocks.permissionCheck = handler; }, + } }, }; }); vi.mock('electron-updater', () => ({ default: { autoUpdater: { on: vi.fn() } } })); -vi.mock('./config', () => { - const settings = { apiKey: 'test-key', model: 'test-model', hotkey: 'Control+Space', translateHotkey: 'Control+Alt+T', - launchAtStartup: false, onboardingCompleted: true, stripFillersEnabled: false, aiPolishEnabled: false, apiBaseUrl: 'https://provider.test' }; - return { getSettings: () => settings, getRendererSettings: () => ({ ...settings, apiKey: '', hasApiKey: true }), - setSettings: mocks.setSettings.mockImplementation((partial) => ({ ...settings, ...partial })) }; -}); +vi.mock('./config', () => ({ + getSettings: () => mocks.settings, + getRendererSettings: () => ({ ...mocks.settings, apiKey: '', hasApiKey: true }), + setSettings: mocks.setSettings.mockImplementation((partial: Partial) => ({ ...mocks.settings, ...partial })), +})); vi.mock('./transcribe', () => ({ transcribeAudio: mocks.transcribe, normalizeThaiSpacing: (text: string) => text, TranscriptionError: class extends Error {} })); vi.mock('./winHost', () => ({ getForegroundWindow: mocks.foreground, warmWinHost: vi.fn(), disposeWinHost: vi.fn() })); vi.mock('./pasteText', () => ({ pasteAtCursor: mocks.paste })); -vi.mock('./history', () => ({ addHistoryEntry: mocks.addHistory, listHistory: () => [], clearHistory: vi.fn(), historyStats: () => ({}), flushHistory: vi.fn().mockResolvedValue(undefined) })); +vi.mock('./history', () => ({ addHistoryEntry: mocks.addHistory, listHistory: () => [], clearHistory: vi.fn(), historyStats: () => ({}), flushHistory: vi.fn().mockResolvedValue(undefined), warmHistory: vi.fn() })); vi.mock('./appProfiles', () => ({ listAppProfiles: () => [], setAppProfiles: vi.fn() })); vi.mock('./dictionary', () => ({ dictionaryPrompt: () => '', applyCorrections: (text: string) => text, listDictionaryWords: () => [], setDictionaryWords: vi.fn(), listCorrections: () => [], setCorrections: vi.fn() })); @@ -79,6 +89,7 @@ function run() { return mocks.handlers.get('transcription:run')!({ sender: mocks beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); mocks.windows.length = 0; mocks.handlers.clear(); mocks.listeners.clear(); mocks.appEvents.clear(); mocks.shortcuts.clear(); + mocks.settings.saveHistory = true; mocks.foreground.mockResolvedValue({ hwnd: '123', processName: 'test' }); mocks.transcribe.mockResolvedValue('transcript'); mocks.paste.mockResolvedValue({ pasted: true }); mocks.addHistory.mockResolvedValue({}); await import('./main'); @@ -88,6 +99,33 @@ beforeEach(async () => { afterEach(() => { mocks.appEvents.get('will-quit')?.(); vi.useRealTimers(); }); describe('main dictation integration', () => { + it('sandboxes both windows and allows microphone permission only for the widget', async () => { + expect(mocks.windows[0].options?.webPreferences).toMatchObject({ sandbox: true, contextIsolation: true, nodeIntegration: false }); + expect(mocks.windows[0].options?.webPreferences?.preload).toMatch(/preload\.cjs$/); + const grant = vi.fn(); + mocks.permissionRequest!(mocks.windows[0].webContents, 'media', grant); + expect(grant).toHaveBeenCalledWith(true); + grant.mockClear(); + mocks.permissionRequest!(mocks.windows[0].webContents, 'geolocation', grant); + expect(grant).toHaveBeenCalledWith(false); + expect(mocks.permissionCheck!(null, 'media')).toBe(false); + mocks.appEvents.get('second-instance')?.(); + await Promise.resolve(); + expect(mocks.windows[1].options?.webPreferences?.sandbox).toBe(true); + grant.mockClear(); + mocks.permissionRequest!(mocks.windows[1].webContents, 'media', grant); + expect(grant).toHaveBeenCalledWith(false); + const navigation = { preventDefault: vi.fn() }; + mocks.windows[0].webContents.emit('will-navigate', navigation, 'https://example.com/phish'); + expect(navigation.preventDefault).toHaveBeenCalledOnce(); + }); + it('skips history when saving is turned off and still pastes', async () => { + mocks.settings.saveHistory = false; + startHotkey(); state('processing'); + expect(await run()).toMatchObject({ ok: true, pasted: true }); + expect(mocks.addHistory).not.toHaveBeenCalled(); + expect(mocks.paste).toHaveBeenCalledOnce(); + }); it('loads the dashboard when tray navigation leaves onboarding and restores the window', async () => { mocks.appEvents.get('second-instance')?.(); await Promise.resolve(); diff --git a/electron/main.ts b/electron/main.ts index 9fbfc49..20a1fa2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,12 +1,15 @@ -import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeImage, Notification, screen, Tray, type WebContents } from 'electron'; +import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeImage, Notification, screen, session, Tray, type WebContents } from 'electron'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import electronUpdater from 'electron-updater'; import { UpdateController, UPDATE_SCHEDULE } from './updater'; import { getRendererSettings, getSettings, setSettings, type AppSettings } from './config'; import { normalizeThaiSpacing, transcribeAudio, TranscriptionError } from './transcribe'; import { pasteAtCursor } from './pasteText'; -import { addHistoryEntry, listHistory, clearHistory, historyStats, flushHistory } from './history'; +import { addHistoryEntry, listHistory, clearHistory, historyStats, flushHistory, warmHistory } from './history'; +import { HistoryEncryptionUnavailable } from './historyRepository'; +import { PRELOAD_FILENAME } from './preloadOutput'; +import { isAppNavigationAllowed, isWidgetMediaAllowed } from './windowGuard'; import { RUNTIME } from '../src/lib/runtimeConfig'; import { measureStage, reportDuration, startPerformanceSampling } from './performance'; import { @@ -56,14 +59,33 @@ const ALLOWED_ROUTES = new Set(['/', '/history', '/dictionary', '/apps', '/setti // Both windows only ever show our own bundle; any other navigation target or // popup is hostile (or a bug) and gets dropped. +function appPageUrl(): URL { + return pathToFileURL(path.join(__dirname, '../dist/index.html')); +} + function hardenWindow(win: BrowserWindow) { win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); - win.webContents.on('will-navigate', (e, url) => { - const allowed = VITE_DEV_SERVER_URL ? url.startsWith(VITE_DEV_SERVER_URL) : url.startsWith('file://'); - if (!allowed) e.preventDefault(); + win.webContents.on('will-navigate', (event, url) => { + if (!isAppNavigationAllowed(url, appPageUrl(), VITE_DEV_SERVER_URL)) event.preventDefault(); + }); +} + +function installSessionPermissions(): void { + const allowed = (contents: WebContents | null, permission: string) => + isWidgetMediaAllowed(permission, contents != null && contents === widget?.webContents); + session.defaultSession.setPermissionRequestHandler((contents, permission, callback) => { + callback(allowed(contents, permission)); }); + session.defaultSession.setPermissionCheckHandler((contents, permission) => allowed(contents, permission)); } +const windowPreferences = { + preload: path.join(__dirname, PRELOAD_FILENAME), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, +} as const; + let widget: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; @@ -187,11 +209,7 @@ function createWidget() { movable: false, focusable: false, skipTaskbar: true, - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - contextIsolation: true, - nodeIntegration: false, - }, + webPreferences: { ...windowPreferences }, }); widget.setAlwaysOnTop(true, 'screen-saver'); widget.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); @@ -220,7 +238,7 @@ function createWidget() { function createMainWindow(route: string) { const url = VITE_DEV_SERVER_URL ? `${VITE_DEV_SERVER_URL}#${route}` - : `file://${path.join(__dirname, '../dist/index.html')}#${route}`; + : `${appPageUrl().href}#${route}`; if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); @@ -244,11 +262,7 @@ function createMainWindow(route: string) { minHeight: 520, title: 'OpenPood', icon: path.join(BRAND_ASSET_DIRECTORY, 'openpood.ico'), - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - contextIsolation: true, - nodeIntegration: false, - }, + webPreferences: { ...windowPreferences }, }); hardenWindow(mainWindow); void mainWindow.loadURL(url).catch(() => notify('เปิดแอปไม่สำเร็จ', 'กรุณาปิดหน้าต่างแล้วเปิดใหม่จาก tray')); @@ -336,8 +350,10 @@ app.on('second-instance', () => { }); app.whenReady().then(() => { + installSessionPermissions(); stopPerformanceSampling = startPerformanceSampling(); warmWinHost(); + warmHistory(); createWidget(); createTray(); registerHotkeys(getSettings()); @@ -518,12 +534,19 @@ app.whenReady().then(() => { if (!text.trim() || text.length > RUNTIME.maxTextChars) throw new TranscriptionError('ไม่พบข้อความที่ใช้ได้จากเสียง — ลองใหม่อีกครั้ง'); // Save before delivery so even unsupported clipboard formats have a // recovery path. A disk failure keeps a temporary in-memory entry. - try { - await measureStage('history', () => addHistoryEntry(text, payload.durationMs, settings.model)); - } catch { - notify('บันทึกประวัติไม่สำเร็จ', 'ประวัติครั้งนี้อยู่ชั่วคราวในแอป กรุณาคัดลอกก่อนปิด และตรวจสอบพื้นที่หรือสิทธิ์เขียนไฟล์'); + if (settings.saveHistory !== false) { + try { + await measureStage('history', () => addHistoryEntry(text, payload.durationMs, settings.model)); + } catch (err) { + const body = err instanceof HistoryEncryptionUnavailable + ? err.message + : err instanceof Error && err.message.startsWith('อ่านประวัติ') + ? err.message + : 'ประวัติครั้งนี้อยู่ชั่วคราวในแอป กรุณาคัดลอกก่อนปิด และตรวจสอบพื้นที่หรือสิทธิ์เขียนไฟล์'; + notify('บันทึกประวัติไม่สำเร็จ', body); + } + sendToWindow(mainWindow, 'history:updated'); } - sendToWindow(mainWindow, 'history:updated'); assertCurrent(); const delivery = await measureStage('paste', () => pasteAtCursor(text, target, controller.signal)); if (delivery.reason === 'focus-changed') { diff --git a/electron/openrouterUsage.test.ts b/electron/openrouterUsage.test.ts index 78e2109..bfbe8d3 100644 --- a/electron/openrouterUsage.test.ts +++ b/electron/openrouterUsage.test.ts @@ -7,7 +7,7 @@ const settings: AppSettings = { language: 'th', hotkey: 'Control+Space', micDeviceId: '', launchAtStartup: false, playSound: false, maxDurationSec: 120, onboardingCompleted: true, stripFillersEnabled: true, aiPolishEnabled: false, chatModel: 'test-chat', - translateHotkey: 'Control+Alt+T', translateTargetLang: 'en', + translateHotkey: 'Control+Alt+T', translateTargetLang: 'en', saveHistory: true, }; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); afterEach(() => { vi.unstubAllGlobals(); }); diff --git a/electron/pasteText.test.ts b/electron/pasteText.test.ts index 5d4f2c7..1b0ef64 100644 --- a/electron/pasteText.test.ts +++ b/electron/pasteText.test.ts @@ -4,7 +4,7 @@ const mocks = vi.hoisted(() => ({ text: 'previous', formats: ['text/plain'], sequence: 1, files: false, unsupported: false, hasFormats: true, clipboard: { availableFormats: vi.fn(), readText: vi.fn(), readHTML: vi.fn(), readRTF: vi.fn(), readImage: vi.fn(), - writeText: vi.fn(), write: vi.fn(), + writeText: vi.fn(), write: vi.fn(), writeBuffer: vi.fn(), }, getClipboardState: vi.fn(), sendCtrlV: vi.fn(), })); @@ -55,6 +55,13 @@ describe('safe paste delivery', () => { await pasteAtCursor('transcript', target); expect(mocks.clipboard.write).toHaveBeenCalledWith({ text: 'previous', html: 'previous', rtf: '{\\rtf1 previous}', image: expect.any(Object) }); }); + it('does not restore a file drop through writeBuffer', async () => { + mocks.files = true; + expect(await pasteAtCursor('transcript', target)).toEqual({ pasted: false, reason: 'clipboard-preserved' }); + expect(mocks.clipboard.writeBuffer).not.toHaveBeenCalled(); + expect(mocks.clipboard.writeText).not.toHaveBeenCalled(); + expect(mocks.clipboard.write).not.toHaveBeenCalled(); + }); it.each(['files', 'custom', 'oversized-image'])('preserves %s without overwriting clipboard', async (scenario) => { if (scenario === 'files') mocks.files = true; if (scenario === 'custom') mocks.formats = ['application/x-custom']; diff --git a/electron/pasteText.ts b/electron/pasteText.ts index 0763812..7c34c0d 100644 --- a/electron/pasteText.ts +++ b/electron/pasteText.ts @@ -40,6 +40,11 @@ export async function pasteAtCursor(text: string, target: ForegroundWindow | nul let previous: Data; try { const before = await getClipboardState(signal); + // CF_HDROP is standard format 15 and has no registered clipboard name. + // readBuffer('CF_HDROP') registers a different format, so it does not return + // the file list. FileNameW is a single UTF-16 path, not the drop Explorer + // pastes, and writeBuffer cannot store bytes back as format 15. Images, + // plain text, HTML, and RTF are snapshotted below and restored together. if (before.hasFiles || before.hasUnsupported) return { pasted: false, reason: 'clipboard-preserved' }; previous = snapshotClipboard(before.hasFormats); const after = await getClipboardState(signal); diff --git a/electron/preloadOutput.ts b/electron/preloadOutput.ts new file mode 100644 index 0000000..29f5632 --- /dev/null +++ b/electron/preloadOutput.ts @@ -0,0 +1,3 @@ +// Sandboxed Electron preloads must be CommonJS. `.cjs` stays CommonJS +// even though package.json says `"type": "module"`. +export const PRELOAD_FILENAME = 'preload.cjs'; diff --git a/electron/providerRequest.test.ts b/electron/providerRequest.test.ts index 07492d7..5eb6e5f 100644 --- a/electron/providerRequest.test.ts +++ b/electron/providerRequest.test.ts @@ -12,7 +12,7 @@ const settings: AppSettings = { language: 'th', hotkey: 'Control+Space', micDeviceId: '', launchAtStartup: false, playSound: false, maxDurationSec: 120, onboardingCompleted: true, stripFillersEnabled: true, aiPolishEnabled: false, chatModel: 'test-chat', - translateHotkey: 'Control+Alt+T', translateTargetLang: 'en', + translateHotkey: 'Control+Alt+T', translateTargetLang: 'en', saveHistory: true, }; afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); diff --git a/electron/windowGuard.test.ts b/electron/windowGuard.test.ts new file mode 100644 index 0000000..2b55363 --- /dev/null +++ b/electron/windowGuard.test.ts @@ -0,0 +1,37 @@ +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { isAppNavigationAllowed, isWidgetMediaAllowed } from './windowGuard'; + +const appPage = pathToFileURL('/Program Files/OpenPood/dist/index.html'); + +describe('app navigation', () => { + it('allows only the app file URL in production', () => { + expect(isAppNavigationAllowed(appPage.href, appPage, undefined)).toBe(true); + expect(isAppNavigationAllowed(`${appPage.href}#/settings`, appPage, undefined)).toBe(true); + expect(isAppNavigationAllowed(`${appPage.href}?next=https://evil.test`, appPage, undefined)).toBe(false); + expect(isAppNavigationAllowed(pathToFileURL('/tmp/other.html').href, appPage, undefined)).toBe(false); + expect(isAppNavigationAllowed('https://example.com', appPage, undefined)).toBe(false); + expect(isAppNavigationAllowed('javascript:alert(1)', appPage, undefined)).toBe(false); + expect(isAppNavigationAllowed('not a url', appPage, undefined)).toBe(false); + }); + + it('allows the dev server origin and rejects other origins', () => { + const dev = 'http://localhost:5173/'; + expect(isAppNavigationAllowed('http://localhost:5173/src/main.tsx', appPage, dev)).toBe(true); + expect(isAppNavigationAllowed('http://localhost:5173#/settings', appPage, dev)).toBe(true); + expect(isAppNavigationAllowed('http://127.0.0.1:5173/', appPage, dev)).toBe(false); + expect(isAppNavigationAllowed('http://localhost:5174/', appPage, dev)).toBe(false); + expect(isAppNavigationAllowed('https://example.com', appPage, dev)).toBe(false); + expect(isAppNavigationAllowed('http://user:pass@localhost:5173/', appPage, dev)).toBe(false); + }); +}); + +describe('renderer permissions', () => { + it('allows microphone access only for the widget', () => { + expect(isWidgetMediaAllowed('media', true)).toBe(true); + expect(isWidgetMediaAllowed('media', false)).toBe(false); + expect(isWidgetMediaAllowed('geolocation', true)).toBe(false); + expect(isWidgetMediaAllowed('display-capture', true)).toBe(false); + expect(isWidgetMediaAllowed('openExternal', true)).toBe(false); + }); +}); diff --git a/electron/windowGuard.ts b/electron/windowGuard.ts new file mode 100644 index 0000000..1c5180c --- /dev/null +++ b/electron/windowGuard.ts @@ -0,0 +1,20 @@ +export function isAppNavigationAllowed(target: string, appPage: URL, devServerUrl: string | undefined): boolean { + let url: URL; + try { url = new URL(target); } + catch { return false; } + if (url.username || url.password) return false; + if (devServerUrl) { + let devServer: URL; + try { devServer = new URL(devServerUrl); } + catch { return false; } + return url.origin === devServer.origin; + } + return url.protocol === 'file:' + && url.host === appPage.host + && url.search === '' + && decodeURIComponent(url.pathname) === decodeURIComponent(appPage.pathname); +} + +export function isWidgetMediaAllowed(permission: string, isWidget: boolean): boolean { + return isWidget && permission === 'media'; +} diff --git a/src/pages/history.tsx b/src/pages/history.tsx index 00f897c..4b849c3 100644 --- a/src/pages/history.tsx +++ b/src/pages/history.tsx @@ -13,6 +13,7 @@ export function HistoryPage() { const [copiedId, setCopiedId] = useState(null); const copyResetTimer = useRef(null); const [page, setPage] = useState(0); + const [saveHistory, setSaveHistory] = useState(true); const pageCount = Math.max(1, Math.ceil((entries?.length ?? 0) / RUNTIME.historyPageSize)); const currentPage = Math.min(page, pageCount - 1); const visibleEntries = entries?.slice(currentPage * RUNTIME.historyPageSize, (currentPage + 1) * RUNTIME.historyPageSize); @@ -38,6 +39,9 @@ export function HistoryPage() { void loadHistory(); }); void loadHistory(); + void window.typeless.getSettings().then((settings) => { + if (active) setSaveHistory(settings.saveHistory); + }).catch(() => undefined); document.getElementById("content")?.focus({ preventScroll: true }); return () => { @@ -86,7 +90,8 @@ export function HistoryPage() {

ประวัติการถอดเสียง

- ย้อนดูและคัดลอกข้อความที่ถอดเสียงไว้ในเครื่องนี้ + ข้อความถูกเข้ารหัสเก็บในเครื่องนี้ + {saveHistory ? "" : " การบันทึกประวัติถูกปิดอยู่ในการตั้งค่า"}