From 589db7b47d25e23988cbc9f0a6c6a50a2cbccac1 Mon Sep 17 00:00:00 2001 From: Andy wu Date: Mon, 19 Jan 2026 17:10:17 +0800 Subject: [PATCH 1/4] feat(windows): add build config and stub dictation --- package.json | 5 +- scripts/doctor.mjs | 41 +++++++ src-tauri/Cargo.toml | 10 +- src-tauri/src/dictation_stub.rs | 193 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 5 + src-tauri/tauri.windows.conf.json | 23 ++++ 6 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 scripts/doctor.mjs create mode 100644 src-tauri/src/dictation_stub.rs create mode 100644 src-tauri/tauri.windows.conf.json diff --git a/package.json b/package.json index f03057ff9..1314c322f 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,15 @@ "build:appimage": "NO_STRIP=1 tauri build --bundles appimage", "doctor": "sh scripts/doctor.sh", "doctor:strict": "sh scripts/doctor.sh --strict", + "doctor:win": "node scripts/doctor.mjs --strict", "lint": "eslint . --ext .ts,.tsx", "typecheck": "tsc --noEmit", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "npm run doctor:strict && tauri dev", - "tauri:build": "npm run doctor:strict && tauri build" + "tauri:build": "npm run doctor:strict && tauri build", + "tauri:dev:win": "npm run doctor:win && tauri dev --config src-tauri/tauri.windows.conf.json", + "tauri:build:win": "npm run doctor:win && tauri build --config src-tauri/tauri.windows.conf.json" }, "dependencies": { "@pierre/diffs": "^1.0.6", diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 000000000..b247cb4ad --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,41 @@ +import { spawnSync } from "node:child_process"; + +const strict = process.argv.includes("--strict"); + +function hasCommand(command) { + const checker = process.platform === "win32" ? "where" : "command"; + const checkerArgs = process.platform === "win32" ? [command] : ["-v", command]; + const result = spawnSync(checker, checkerArgs, { stdio: "ignore" }); + return result.status === 0; +} + +const missing = []; +if (!hasCommand("cmake")) missing.push("cmake"); + +if (missing.length === 0) { + console.log("Doctor: OK"); + process.exit(0); +} + +console.log(`Doctor: missing dependencies: ${missing.join(" ")}`); + +switch (process.platform) { + case "darwin": + console.log("Install: brew install cmake"); + break; + case "linux": + console.log("Ubuntu/Debian: sudo apt-get install cmake"); + console.log("Fedora: sudo dnf install cmake"); + console.log("Arch: sudo pacman -S cmake"); + break; + case "win32": + console.log("Install: choco install cmake"); + console.log("Or download from: https://cmake.org/download/"); + break; + default: + console.log("Install CMake from: https://cmake.org/download/"); + break; +} + +process.exit(strict ? 1 : 0); + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 764c66769..c9643d81a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["macos-private-api"] } +tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } @@ -32,10 +32,12 @@ fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } ignore = "0.4.25" portable-pty = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } -cpal = "0.15" -whisper-rs = "0.12" -sha2 = "0.10" libc = "0.2" [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" + +[target."cfg(not(target_os = \"windows\"))".dependencies] +cpal = "0.15" +whisper-rs = "0.12" +sha2 = "0.10" diff --git a/src-tauri/src/dictation_stub.rs b/src-tauri/src/dictation_stub.rs new file mode 100644 index 000000000..cf2024018 --- /dev/null +++ b/src-tauri/src/dictation_stub.rs @@ -0,0 +1,193 @@ +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; + +use crate::state::AppState; + +const DEFAULT_MODEL_ID: &str = "base"; + +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DictationModelState { + Missing, + Downloading, + Ready, + Error, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct DictationDownloadProgress { + #[serde(rename = "downloadedBytes")] + pub(crate) downloaded_bytes: u64, + #[serde(rename = "totalBytes")] + pub(crate) total_bytes: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct DictationModelStatus { + pub(crate) state: DictationModelState, + #[serde(rename = "modelId")] + pub(crate) model_id: String, + pub(crate) progress: Option, + pub(crate) error: Option, + pub(crate) path: Option, +} + +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DictationSessionState { + Idle, + Listening, + Processing, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum DictationEvent { + State { state: DictationSessionState }, + Level { value: f32 }, + Transcript { text: String }, + Error { message: String }, + Canceled { message: String }, +} + +pub(crate) struct DictationState { + pub(crate) model_status: DictationModelStatus, + pub(crate) session_state: DictationSessionState, +} + +impl Default for DictationState { + fn default() -> Self { + Self { + model_status: DictationModelStatus { + state: DictationModelState::Error, + model_id: DEFAULT_MODEL_ID.to_string(), + progress: None, + error: Some("Dictation is not supported on Windows builds yet.".to_string()), + path: None, + }, + session_state: DictationSessionState::Idle, + } + } +} + +fn emit_status(app: &AppHandle, status: &DictationModelStatus) { + let _ = app.emit("dictation-download", status); +} + +fn emit_event(app: &AppHandle, event: DictationEvent) { + let _ = app.emit("dictation-event", event); +} + +fn windows_unsupported_status(model_id: Option) -> DictationModelStatus { + DictationModelStatus { + state: DictationModelState::Error, + model_id: model_id.unwrap_or_else(|| DEFAULT_MODEL_ID.to_string()), + progress: None, + error: Some( + "Dictation requires whisper/clang tooling which is currently disabled on Windows." + .to_string(), + ), + path: None, + } +} + +#[tauri::command] +pub(crate) async fn dictation_model_status( + app: AppHandle, + state: State<'_, AppState>, + model_id: Option, +) -> Result { + let status = windows_unsupported_status(model_id); + { + let mut dictation = state.dictation.lock().await; + dictation.model_status = status.clone(); + dictation.session_state = DictationSessionState::Idle; + } + emit_status(&app, &status); + Ok(status) +} + +#[tauri::command] +pub(crate) async fn dictation_download_model( + app: AppHandle, + state: State<'_, AppState>, + model_id: Option, +) -> Result { + let status = dictation_model_status(app.clone(), state, model_id).await?; + emit_event( + &app, + DictationEvent::Error { + message: status + .error + .clone() + .unwrap_or_else(|| "Dictation is unavailable on Windows.".to_string()), + }, + ); + Ok(status) +} + +#[tauri::command] +pub(crate) async fn dictation_cancel_download( + app: AppHandle, + state: State<'_, AppState>, + model_id: Option, +) -> Result { + dictation_model_status(app, state, model_id).await +} + +#[tauri::command] +pub(crate) async fn dictation_remove_model( + app: AppHandle, + state: State<'_, AppState>, + model_id: Option, +) -> Result { + dictation_model_status(app, state, model_id).await +} + +#[tauri::command] +pub(crate) async fn dictation_start( + _preferred_language: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + { + let mut dictation = state.dictation.lock().await; + dictation.session_state = DictationSessionState::Idle; + } + let message = "Dictation is not supported on Windows builds.".to_string(); + emit_event(&app, DictationEvent::Error { message: message.clone() }); + Err(message) +} + +#[tauri::command] +pub(crate) async fn dictation_stop( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + { + let mut dictation = state.dictation.lock().await; + dictation.session_state = DictationSessionState::Idle; + } + let message = "Dictation is not supported on Windows builds.".to_string(); + emit_event(&app, DictationEvent::Error { message: message.clone() }); + Err(message) +} + +#[tauri::command] +pub(crate) async fn dictation_cancel( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + { + let mut dictation = state.dictation.lock().await; + dictation.session_state = DictationSessionState::Idle; + } + emit_event( + &app, + DictationEvent::Canceled { + message: "Canceled".to_string(), + }, + ); + Ok(DictationSessionState::Idle) +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5ebffabd..b352e72f3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,11 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; mod backend; mod codex; mod codex_config; +#[cfg(not(target_os = "windows"))] +#[path = "dictation.rs"] +mod dictation; +#[cfg(target_os = "windows")] +#[path = "dictation_stub.rs"] mod dictation; mod event_sink; mod git; diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 000000000..adbbfdc72 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,23 @@ +{ + "app": { + "macOSPrivateApi": false, + "windows": [ + { + "title": "CodexMonitor", + "width": 1200, + "height": 700, + "minWidth": 360, + "minHeight": 600, + "dragDropEnabled": true, + "titleBarStyle": "Visible", + "hiddenTitle": false, + "transparent": false, + "devtools": true, + "windowEffects": null + } + ] + }, + "bundle": { + "createUpdaterArtifacts": false + } +} From 9083434667f53d2989c981249cd59186315c9ca1 Mon Sep 17 00:00:00 2001 From: Andy wu Date: Mon, 19 Jan 2026 17:50:41 +0800 Subject: [PATCH 2/4] fix: keep macOS private API feature; avoid dictation promise rejections --- src-tauri/Cargo.toml | 7 +++++- src-tauri/src/dictation_stub.rs | 12 +++++----- .../dictation/hooks/useHoldToDictate.ts | 22 ++++++++++++++----- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c9643d81a..8bfbe890a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,7 +19,6 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } @@ -34,6 +33,12 @@ portable-pty = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } libc = "0.2" +[target."cfg(target_os = \"macos\")".dependencies] +tauri = { version = "2", features = [] } + +[target."cfg(not(target_os = \"macos\"))".dependencies] +tauri = { version = "2", features = [] } + [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/dictation_stub.rs b/src-tauri/src/dictation_stub.rs index cf2024018..755e58c9f 100644 --- a/src-tauri/src/dictation_stub.rs +++ b/src-tauri/src/dictation_stub.rs @@ -4,6 +4,7 @@ use tauri::{AppHandle, Emitter, State}; use crate::state::AppState; const DEFAULT_MODEL_ID: &str = "base"; +const UNSUPPORTED_MESSAGE: &str = "Dictation is not supported on Windows builds."; #[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -62,7 +63,7 @@ impl Default for DictationState { state: DictationModelState::Error, model_id: DEFAULT_MODEL_ID.to_string(), progress: None, - error: Some("Dictation is not supported on Windows builds yet.".to_string()), + error: Some(UNSUPPORTED_MESSAGE.to_string()), path: None, }, session_state: DictationSessionState::Idle, @@ -83,10 +84,7 @@ fn windows_unsupported_status(model_id: Option) -> DictationModelStatus state: DictationModelState::Error, model_id: model_id.unwrap_or_else(|| DEFAULT_MODEL_ID.to_string()), progress: None, - error: Some( - "Dictation requires whisper/clang tooling which is currently disabled on Windows." - .to_string(), - ), + error: Some(UNSUPPORTED_MESSAGE.to_string()), path: None, } } @@ -154,7 +152,7 @@ pub(crate) async fn dictation_start( let mut dictation = state.dictation.lock().await; dictation.session_state = DictationSessionState::Idle; } - let message = "Dictation is not supported on Windows builds.".to_string(); + let message = UNSUPPORTED_MESSAGE.to_string(); emit_event(&app, DictationEvent::Error { message: message.clone() }); Err(message) } @@ -168,7 +166,7 @@ pub(crate) async fn dictation_stop( let mut dictation = state.dictation.lock().await; dictation.session_state = DictationSessionState::Idle; } - let message = "Dictation is not supported on Windows builds.".to_string(); + let message = UNSUPPORTED_MESSAGE.to_string(); emit_event(&app, DictationEvent::Error { message: message.clone() }); Err(message) } diff --git a/src/features/dictation/hooks/useHoldToDictate.ts b/src/features/dictation/hooks/useHoldToDictate.ts index a8f272466..8aeae68ed 100644 --- a/src/features/dictation/hooks/useHoldToDictate.ts +++ b/src/features/dictation/hooks/useHoldToDictate.ts @@ -9,8 +9,8 @@ type UseHoldToDictateArgs = { preferredLanguage: string | null; holdKey: string; startDictation: (preferredLanguage: string | null) => void | Promise; - stopDictation: () => void; - cancelDictation: () => void; + stopDictation: () => void | Promise; + cancelDictation: () => void | Promise; }; const HOLD_STOP_GRACE_MS = 1500; @@ -30,6 +30,16 @@ export function useHoldToDictate({ const holdDictationStopTimeout = useRef(null); useEffect(() => { + const safeInvoke = (action: () => void | Promise) => { + try { + void Promise.resolve(action()).catch(() => { + // Errors are surfaced through dictation events. + }); + } catch { + // Errors are surfaced through dictation events. + } + }; + const normalizedHoldKey = holdKey.toLowerCase(); if (!normalizedHoldKey) { return; @@ -41,7 +51,7 @@ export function useHoldToDictate({ window.clearTimeout(holdDictationStopTimeout.current); holdDictationStopTimeout.current = null; } - stopDictation(); + safeInvoke(stopDictation); } const handleKeyDown = (event: KeyboardEvent) => { @@ -60,7 +70,7 @@ export function useHoldToDictate({ window.clearTimeout(holdDictationStopTimeout.current); holdDictationStopTimeout.current = null; } - startDictation(preferredLanguage); + safeInvoke(() => startDictation(preferredLanguage)); }; const handleKeyUp = (event: KeyboardEvent) => { @@ -85,7 +95,7 @@ export function useHoldToDictate({ window.clearTimeout(holdDictationStopTimeout.current); holdDictationStopTimeout.current = null; } - stopDictation(); + safeInvoke(stopDictation); } }; @@ -100,7 +110,7 @@ export function useHoldToDictate({ holdDictationStopTimeout.current = null; } if (state === "listening") { - cancelDictation(); + safeInvoke(cancelDictation); } }; From f22d3fb9b4ca50b3c8e9281a942eb136828dd430 Mon Sep 17 00:00:00 2001 From: Andy wu Date: Mon, 19 Jan 2026 17:53:44 +0800 Subject: [PATCH 3/4] docs: add opt-in Windows build instructions --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 64630ab2b..e373a2ebc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across - Node.js + npm - Rust toolchain (stable) -- CMake (required to build native Whisper bindings) +- CMake (required for native dependencies; Whisper/dictation uses it on non-Windows) - Codex installed on your system and available as `codex` in `PATH` - Git CLI (used for worktree operations) - GitHub CLI (`gh`) for the Issues panel (optional) @@ -64,6 +64,21 @@ npm run tauri build The macOS app bundle will be in `src-tauri/target/release/bundle/macos/`. +### Windows (opt-in) + +Windows builds are opt-in and use a separate Tauri config file to avoid macOS-only window effects. + +```bash +npm run tauri:build:win +``` + +Artifacts will be in: + +- `src-tauri/target/release/bundle/nsis/` (installer exe) +- `src-tauri/target/release/bundle/msi/` (msi) + +Note: dictation is currently disabled on Windows builds (to avoid requiring LLVM/libclang for `whisper-rs`/bindgen). + ## Type Checking Run the TypeScript checker (no emit): From 1675ef095c12145df7956792ea656cbbce631122 Mon Sep 17 00:00:00 2001 From: Andy wu Date: Mon, 19 Jan 2026 20:48:26 +0800 Subject: [PATCH 4/4] fix(ci): align tauri features with config --- src-tauri/Cargo.toml | 7 +---- src-tauri/tauri.windows.conf.json | 1 - src-tauri/tests/tauri_config.rs | 47 +++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 src-tauri/tests/tauri_config.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8bfbe890a..792a685bc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +tauri = { version = "2", features = ["macos-private-api"] } tauri-plugin-opener = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } @@ -33,12 +34,6 @@ portable-pty = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } libc = "0.2" -[target."cfg(target_os = \"macos\")".dependencies] -tauri = { version = "2", features = [] } - -[target."cfg(not(target_os = \"macos\"))".dependencies] -tauri = { version = "2", features = [] } - [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index adbbfdc72..12d4499d8 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -1,6 +1,5 @@ { "app": { - "macOSPrivateApi": false, "windows": [ { "title": "CodexMonitor", diff --git a/src-tauri/tests/tauri_config.rs b/src-tauri/tests/tauri_config.rs new file mode 100644 index 000000000..1ea5f9c49 --- /dev/null +++ b/src-tauri/tests/tauri_config.rs @@ -0,0 +1,47 @@ +use std::fs; +use std::path::PathBuf; + +use serde_json::Value; + +#[test] +fn macos_private_api_feature_matches_config() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = manifest_dir.join("tauri.conf.json"); + let config_contents = fs::read_to_string(&config_path) + .unwrap_or_else(|error| panic!("Failed to read {config_path:?}: {error}")); + let config: Value = serde_json::from_str(&config_contents) + .unwrap_or_else(|error| panic!("Failed to parse tauri.conf.json: {error}")); + let macos_private_api = config + .get("app") + .and_then(|app| app.get("macOSPrivateApi")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + + if macos_private_api { + let cargo_path = manifest_dir.join("Cargo.toml"); + let cargo_contents = fs::read_to_string(&cargo_path) + .unwrap_or_else(|error| panic!("Failed to read {cargo_path:?}: {error}")); + let mut in_dependencies = false; + let mut has_feature = false; + + for line in cargo_contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_dependencies = trimmed == "[dependencies]"; + continue; + } + if !in_dependencies { + continue; + } + if trimmed.starts_with("tauri") && trimmed.contains("macos-private-api") { + has_feature = true; + break; + } + } + + assert!( + has_feature, + "Cargo.toml [dependencies] must enable macos-private-api when app.macOSPrivateApi is true" + ); + } +}