Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
- **Dictionary** — คำเฉพาะ / ชื่อเฉพาะ / ศัพท์เทคนิค ช่วย bias การถอดเสียง (ยังไม่ส่ง Dictionary words ไป ElevenLabs)
- **Correction rules** — แทนที่คำที่ถอดผิดซ้ำๆ แบบตรงตัวหลังถอดเสียง
- **AI polish / Translate** — เก็บประโยค หรือแปลจากเสียง (ใช้ chat model)
- **History** — ประวัติการถอดเสียงสูงสุด 500 รายการ คัดลอกซ้ำได้
- **History** — ประวัติการถอดเสียงสูงสุด 500 รายการ เข้ารหัสในเครื่อง คัดลอกซ้ำได้ และปิดการบันทึกหรือล้างได้
- **ตั้งค่าปุ่มลัดแบบกดจริง** — กดคีย์ที่ต้องการ ระบบจับ accelerator ให้เอง
- **เปิดตอน Windows เริ่ม** — เปิด/ปิดได้ใน Settings

Expand Down Expand Up @@ -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 ที่คุณตั้งไว้เท่านั้น

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion architecture/current-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 7 additions & 6 deletions architecture/system-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions electron/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions electron/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) };
}

Expand Down
2 changes: 1 addition & 1 deletion electron/elevenlabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
62 changes: 47 additions & 15 deletions electron/history.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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<HistorySchema>({
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<HistoryEntry> {
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<void> {
return repository.clear();
return repo().clear();
}

export function flushHistory(): Promise<void> { return repository.flush(); }
export function flushHistory(): Promise<void> { 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;
Expand Down
Loading