diff --git a/README.md b/README.md index 915eed1..2f99cbb 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 b28163b..103c99a 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. A missing or failed foreground lookup does not paste and does not replace the clipboard. - 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 3e06f62..001112c 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 62b2bd1..97dffd8 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(), setFeedURL: 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 524a710..19946bb 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; @@ -198,11 +220,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 }); @@ -231,7 +249,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(); @@ -255,11 +273,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')); @@ -347,8 +361,10 @@ app.on('second-instance', () => { }); app.whenReady().then(() => { + installSessionPermissions(); stopPerformanceSampling = startPerformanceSampling(); warmWinHost(); + warmHistory(); createWidget(); createTray(); registerHotkeys(getSettings()); @@ -529,12 +545,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 === 'target-unknown') { 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 6c623f0..475b24a 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(), })); @@ -56,6 +56,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 524ebe8..a07092d 100644 --- a/electron/pasteText.ts +++ b/electron/pasteText.ts @@ -42,6 +42,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 ? "" : " การบันทึกประวัติถูกปิดอยู่ในการตั้งค่า"}