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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

สร้างด้วย Electron + React + TypeScript + Vite + Tailwind CSS v4 + DaisyUI

**[ดาวน์โหลดตัวติดตั้งล่าสุด](https://github.com/gotzastory/OpenPud/releases/latest)** · [Releases ทั้งหมด](https://github.com/gotzastory/OpenPud/releases)
**[ดาวน์โหลดตัวติดตั้งล่าสุด](https://github.com/gotzastory/OpenPood/releases/latest)** · [Releases ทั้งหมด](https://github.com/gotzastory/OpenPood/releases)

---

Expand All @@ -27,7 +27,7 @@

## ดาวน์โหลด / ติดตั้ง

ดาวน์โหลดตัวติดตั้งจาก [GitHub Releases](https://github.com/gotzastory/OpenPud/releases/latest) — **ไม่ต้องมี Node.js**
ดาวน์โหลดตัวติดตั้งจาก [GitHub Releases](https://github.com/gotzastory/OpenPood/releases/latest) — **ไม่ต้องมี Node.js**

1. เปิดหน้า Releases แล้วดาวน์โหลด `OpenPood-Setup-x.x.x.exe`
2. ดับเบิลคลิกเพื่อติดตั้ง (NSIS, ไม่ต้องสิทธิ์ admin)
Expand Down
2 changes: 1 addition & 1 deletion architecture/current-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ flowchart LR

- Keep renderer access behind the preload bridge: `contextIsolation: true`, `nodeIntegration: false`.
- 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.
- 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.
- Microphone, hotkeys, focus, clipboard/paste, tray and updates require manual Windows verification.

Expand Down
2 changes: 1 addition & 1 deletion architecture/reliability-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ npm.cmd run dev
- Clipboard restore grace period เป็นค่า configurable; ทดสอบ Word/browser/chat และแอปที่ตอบสนองช้า.
- Cancellation หลังส่ง keystrokeแล้วไม่สามารถดึงข้อความกลับจากแอปปลายทาง.
- History failure เก็บ entry ใน RAM; entry ที่ยังไม่ persist จะหายเมื่อปิดแอป.
- Clipboard handling อ้างอิง [Electron 43.2.0 API](https://github.com/electron/electron/blob/v43.2.0/docs/api/clipboard.md); file/custom formats ที่คืนไม่ได้จะไม่ถูกเขียนทับ.
- Clipboard handling อ้างอิง [Electron 43.8.7 API](https://github.com/electron/electron/blob/v43.8.7/docs/api/clipboard.md); file/custom formats ที่คืนไม่ได้จะไม่ถูกเขียนทับ.

## Performance diagnostics

Expand Down
4 changes: 2 additions & 2 deletions architecture/system-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Global hotkey
```

- Escape is registered globally during microphone setup, recording, and processing. Processing cancellation aborts provider work and prevents late delivery; already-injected keystrokes cannot be retracted.
- Hotkey starts are serialized before microphone acquisition. The foreground target is captured once on the start press; a stop press never changes it. Slow or failed foreground capture becomes an unknown target.
- Hotkey starts are serialized before microphone acquisition. The foreground target is captured once on the start press; a stop press never changes it. Capture waits for the PowerShell host and retries once after a failed start. Only a lookup that still fails becomes an unknown target, and an unknown target does not replace the clipboard.
- Recorder permission/stop operations have deadlines, late permission grants are released after cancellation, and errors/device disconnection stop tracks and close the audio context.
- If a saved microphone disappears, recording retries with the OS default device.
- Missing/unknown HWNDs never trigger automatic paste. The native host verifies the captured HWND and transcript clipboard sequence immediately before input injection; changed foreground falls back to clipboard delivery.
Expand Down Expand Up @@ -118,7 +118,7 @@ Global hotkey

## Desktop updates

- `electron/updater.ts` owns `electron-updater` in main. Installed Windows builds check the public GitHub Releases feed configured by `build.publish` after startup and every six hours; development builds disable updates.
- `electron/updater.ts` owns `electron-updater` in main. Installed Windows builds check the public GitHub Releases feed for `gotzastory/OpenPood` (`build.publish`, also set with `setFeedURL`) after startup and every six hours; development builds disable updates. Builds already installed from v1.0.3 still have `gotzastory/OpenPud` in `app-update.yml` and follow GitHub's rename redirect.
- Updates never download or install without an explicit dashboard action. Closing the dashboard or quitting via tray does not install a downloaded update.
- Settings displays current/new versions, plain-text release notes, download progress, retry, and Restart & Update. A native notification opens Settings when a new version is found.
- Update IPC is dashboard-only. Main rejects installation while a hotkey start is pending, the widget is recording/processing, or transcription/paste is active. Hotkeys are suspended during installation and restored if installation fails.
Expand Down
22 changes: 20 additions & 2 deletions electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ vi.mock('electron', async () => {
screen: { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getDisplayNearestPoint: () => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }), on: vi.fn() },
};
});
vi.mock('electron-updater', () => ({ default: { autoUpdater: { on: vi.fn() } } }));
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' };
Expand Down Expand Up @@ -130,12 +130,30 @@ describe('main dictation integration', () => {
state('idle');
expect(mocks.shortcuts.has('Escape')).toBe(false);
});
it('passes unknown foreground through the clipboard fallback', async () => {
it('passes unknown foreground through without a paste target', async () => {
mocks.foreground.mockRejectedValue(new Error('host unavailable'));
startHotkey(); state('processing');
await run();
expect(mocks.foreground).toHaveBeenCalledTimes(2);
expect(mocks.paste).toHaveBeenCalledWith('transcript', null, expect.any(AbortSignal));
});
it('waits for a slow foreground host instead of dropping the HWND', async () => {
mocks.foreground.mockImplementation(() => new Promise((resolve) => {
setTimeout(() => resolve({ hwnd: '456', processName: 'notepad' }), 350);
}));
startHotkey(); state('processing');
await run();
expect(mocks.paste).toHaveBeenCalledWith('transcript', { hwnd: '456', processName: 'notepad' }, expect.any(AbortSignal));
});
it('retries after a cold host fails and uses the HWND from the restarted lookup', async () => {
mocks.foreground
.mockRejectedValueOnce(new Error('win host did not become ready'))
.mockResolvedValueOnce({ hwnd: '789', processName: 'chrome' });
startHotkey(); state('processing');
await run();
expect(mocks.foreground).toHaveBeenCalledTimes(2);
expect(mocks.paste).toHaveBeenCalledWith('transcript', { hwnd: '789', processName: 'chrome' }, expect.any(AbortSignal));
});
it('cancels an active request and prevents late history/paste', async () => {
mocks.transcribe.mockImplementation((_audio, _mime, _settings, _bias, signal: AbortSignal) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
Expand Down
31 changes: 22 additions & 9 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,24 @@ function recoverWidget(win: BrowserWindow): void {
}, RUNTIME.widgetRecoveryDelayMs);
}

const FOREGROUND_LOOKUP_ATTEMPTS = 2;

function knownForeground(window: ForegroundWindow): boolean {
return /^[1-9]\d*$/.test(window.hwnd);
}

// Wait for the PowerShell host to compile and answer. A cold or crashed host
// is restarted by the next lookup; do not give up on a short timer.
async function captureForeground(): Promise<ForegroundWindow | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
getForegroundWindow().catch(() => null),
new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), RUNTIME.foregroundCaptureTimeoutMs); }),
]);
} finally { clearTimeout(timer); }
for (let attempt = 0; attempt < FOREGROUND_LOOKUP_ATTEMPTS; attempt++) {
try {
const window = await getForegroundWindow();
if (knownForeground(window)) return window;
} catch (err) {
console.warn('[winHost] foreground lookup failed:', err instanceof Error ? err.message : 'unknown');
}
}
return null;
}

type RendererCapability = 'main' | 'widget' | 'either';
Expand All @@ -153,7 +163,8 @@ function requireRenderer(event: { sender: WebContents }, capability: RendererCap
}

// Capture once at the start press. Stopping in another window must never retarget
// the dictation. Slow/unavailable lookups deliberately fall back to clipboard.
// the dictation. A lookup that still fails after a host restart leaves the target
// unknown; paste then keeps the previous clipboard and History holds the text.
let foregroundAtHotkey: Promise<ForegroundWindow | null> = Promise.resolve(null);

// Bottom-center of whichever monitor the cursor is on, not just the primary.
Expand Down Expand Up @@ -526,7 +537,9 @@ app.whenReady().then(() => {
sendToWindow(mainWindow, 'history:updated');
assertCurrent();
const delivery = await measureStage('paste', () => pasteAtCursor(text, target, controller.signal));
if (delivery.reason === 'focus-changed') {
if (delivery.reason === 'target-unknown') {
notify('ยืนยันหน้าต่างปลายทางไม่ได้', 'ไม่พบหน้าต่างปลายทาง กรุณาคัดลอกข้อความจาก History');
} else if (delivery.reason === 'focus-changed') {
notify('ยืนยันหน้าต่างปลายทางไม่ได้', 'ข้อความอยู่ใน clipboard แล้ว กด Ctrl+V เพื่อวางเอง');
} else if (delivery.reason === 'clipboard-preserved') {
notify('เก็บ clipboard เดิมไว้แล้ว', 'clipboard มีไฟล์ รูปแบบพิเศษ หรือถูกเปลี่ยน กรุณาคัดลอกข้อความจาก History');
Expand Down
7 changes: 4 additions & 3 deletions electron/pasteText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ afterEach(() => vi.restoreAllMocks());

const target = { hwnd: '123', processName: 'test' };
describe('safe paste delivery', () => {
it.each([null, { hwnd: '0', processName: '' }])('does not inject keys when the captured target is unknown', async (missing) => {
expect(await pasteAtCursor('transcript', missing)).toEqual({ pasted: false, reason: 'focus-changed' });
it.each([null, { hwnd: '0', processName: '' }])('does not replace the clipboard when the captured target is unknown', async (missing) => {
expect(await pasteAtCursor('transcript', missing)).toEqual({ pasted: false, reason: 'target-unknown' });
expect(mocks.sendCtrlV).not.toHaveBeenCalled();
expect(mocks.text).toBe('transcript');
expect(mocks.clipboard.writeText).not.toHaveBeenCalled();
expect(mocks.text).toBe('previous');
});
it('passes the original HWND and clipboard sequence to the native focus guard', async () => {
expect(await pasteAtCursor('transcript', target)).toEqual({ pasted: true });
Expand Down
5 changes: 3 additions & 2 deletions electron/pasteText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { RUNTIME } from '../src/lib/runtimeConfig';

export interface PasteResult {
pasted: boolean;
reason?: 'focus-changed' | 'paste-failed' | 'clipboard-preserved';
reason?: 'focus-changed' | 'paste-failed' | 'clipboard-preserved' | 'target-unknown';
}

function delay(ms: number): Promise<void> {
Expand Down Expand Up @@ -37,6 +37,8 @@ function snapshotClipboard(hasNativeFormats: boolean): Data {

export async function pasteAtCursor(text: string, target: ForegroundWindow | null, signal?: AbortSignal): Promise<PasteResult> {
signal?.throwIfAborted();
// A missing HWND must not touch the clipboard. The transcript stays in History.
if (!target || !/^[1-9]\d*$/.test(target.hwnd)) return { pasted: false, reason: 'target-unknown' };
let previous: Data;
try {
const before = await getClipboardState(signal);
Expand Down Expand Up @@ -64,7 +66,6 @@ export async function pasteAtCursor(text: string, target: ForegroundWindow | nul
// Check content too: another application could change clipboard while the
// host request is in flight. Recheck sequence in native code at injection.
if (clipboard.readText() !== text || written.hasFiles || written.hasUnsupported) return { pasted: false, reason: 'clipboard-preserved' };
if (!target || !/^[1-9]\d*$/.test(target.hwnd)) return { pasted: false, reason: 'focus-changed' };
await delay(RUNTIME.clipboardSettleMs);
signal?.throwIfAborted();
const result = await sendCtrlV(target.hwnd, ownedSequence, signal);
Expand Down
11 changes: 10 additions & 1 deletion electron/updater.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { EventEmitter } from 'node:events';
import { readFileSync } from 'node:fs';
import type { AppUpdater } from 'electron-updater';
import { describe, expect, it, vi } from 'vitest';
import { UpdateController } from './updater';
import { UpdateController, UPDATE_FEED } from './updater';

function fixture(enabled = true) {
const events = new EventEmitter();
const client = Object.assign(events, {
setFeedURL: vi.fn(),
autoDownload: true, autoInstallOnAppQuit: true, allowPrerelease: true, allowDowngrade: true,
checkForUpdates: vi.fn(async () => {
events.emit('update-available', { version: '1.0.2', releaseNotes: '<script>untrusted</script>' });
Expand All @@ -23,6 +25,13 @@ function fixture(enabled = true) {
}

describe('desktop updates', () => {
it('checks the OpenPood GitHub release feed', () => {
const { client } = fixture();
expect(client.setFeedURL).toHaveBeenCalledWith(UPDATE_FEED);
expect(UPDATE_FEED.repo).toBe('OpenPood');
const pkg = JSON.parse(readFileSync('package.json', 'utf8')) as { build: { publish: { provider: string; owner: string; repo: string } } };
expect(pkg.build.publish).toMatchObject({ provider: UPDATE_FEED.provider, owner: UPDATE_FEED.owner, repo: UPDATE_FEED.repo });
});
it('requires explicit download and install, and excludes prereleases/downgrades', async () => {
const { client, controller } = fixture();
expect(client.autoDownload).toBe(false);
Expand Down
11 changes: 10 additions & 1 deletion electron/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import type { AppUpdater, UpdateInfo } from 'electron-updater';
import type { UpdateStatus } from '../src/lib/updateTypes';

export const UPDATE_SCHEDULE = { startupDelayMs: 15_000, intervalMs: 6 * 60 * 60 * 1000 };
type Client = Pick<AppUpdater, 'on' | 'autoDownload' | 'autoInstallOnAppQuit' | 'allowPrerelease' | 'allowDowngrade' | 'checkForUpdates' | 'downloadUpdate' | 'quitAndInstall'>;
// Installed v1.0.3 builds still have gotzastory/OpenPud in app-update.yml.
// New builds ask GitHub for this repository directly.
export const UPDATE_FEED = {
provider: 'github',
owner: 'gotzastory',
repo: 'OpenPood',
releaseType: 'release',
} as const;
type Client = Pick<AppUpdater, 'on' | 'setFeedURL' | 'autoDownload' | 'autoInstallOnAppQuit' | 'allowPrerelease' | 'allowDowngrade' | 'checkForUpdates' | 'downloadUpdate' | 'quitAndInstall'>;

function notes(info: UpdateInfo): string {
const value = typeof info.releaseNotes === 'string'
Expand Down Expand Up @@ -40,6 +48,7 @@ export class UpdateController {
this.prepareInstall = prepareInstall;
this.restoreAfterFailure = restoreAfterFailure;
this.status = { phase: enabled ? 'idle' : 'disabled', currentVersion, busy: false };
client.setFeedURL(UPDATE_FEED);
client.autoDownload = false;
client.autoInstallOnAppQuit = false;
client.allowPrerelease = false;
Expand Down
23 changes: 23 additions & 0 deletions electron/winHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { HOST_SCRIPT } from './winHost';

function pasteBranch(script: string): string {
const start = script.indexOf("'paste'");
const end = script.indexOf("'clipboard'");
return script.slice(start, end);
}

describe('PowerShell host paste replies', () => {
it('replies to a declined paste and stays in the host loop so the reply is flushed', () => {
const paste = pasteBranch(HOST_SCRIPT);
expect(paste).toContain('clipboard-changed');
expect(paste).toContain('focus-changed');
expect(paste).not.toMatch(/\b(break|continue|exit|return)\b/);
const declinedAt = HOST_SCRIPT.indexOf('clipboard-changed');
const flushAt = HOST_SCRIPT.lastIndexOf('$out.Flush()');
expect(declinedAt).toBeGreaterThan(-1);
expect(flushAt).toBeGreaterThan(declinedAt);
expect(HOST_SCRIPT.match(/\bbreak\b/g)).toEqual(['break']);
expect(HOST_SCRIPT).toContain('if ($null -eq $line) { break }');
});
});
15 changes: 8 additions & 7 deletions electron/winHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const READY_TIMEOUT_MS = 15000;
// SendInput with virtual-key codes rather than WinForms SendKeys("^v"):
// SendKeys maps the letter "v" through the active keyboard layout — with Thai
// Kedmanee active that is not physical V, so Ctrl+V silently does nothing.
const HOST_SCRIPT = `
// Exported so tests can check the host loop without launching PowerShell.
export const HOST_SCRIPT = `
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Add-Type -TypeDefinition @"
using System;
Expand Down Expand Up @@ -130,16 +131,16 @@ while ($true) {
}
'paste' {
if ($commandParts.Length -ne 3 -or $commandParts[1] -notmatch '^[1-9][0-9]*$' -or $commandParts[2] -notmatch '^[0-9]+$') { throw 'invalid paste target' }
# Stay inside this switch case. A loop-control statement here would leave
# the while, skip the flush below, and end the host process.
if ([OpenPoodNative]::GetClipboardSequenceNumber() -ne [uint32]$commandParts[2]) {
$out.WriteLine("$id ok clipboard-changed")
break
}
if ([OpenPoodNative]::GetForegroundWindow().ToInt64() -ne [int64]$commandParts[1]) {
} elseif ([OpenPoodNative]::GetForegroundWindow().ToInt64() -ne [int64]$commandParts[1]) {
$out.WriteLine("$id ok focus-changed")
break
} else {
[OpenPoodNative]::CtrlV()
$out.WriteLine("$id ok pasted")
}
[OpenPoodNative]::CtrlV()
$out.WriteLine("$id ok pasted")
}
'clipboard' {
$sequence = [OpenPoodNative]::GetClipboardSequenceNumber()
Expand Down
Loading