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): diff --git a/package.json b/package.json index 261bd6496..9d442fa9b 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 690b5addf..2a12fcabb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,11 +32,13 @@ 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" chrono = { version = "0.4", features = ["clock"] } [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..755e58c9f --- /dev/null +++ b/src-tauri/src/dictation_stub.rs @@ -0,0 +1,191 @@ +use serde::Serialize; +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")] +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(UNSUPPORTED_MESSAGE.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(UNSUPPORTED_MESSAGE.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 = UNSUPPORTED_MESSAGE.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 = UNSUPPORTED_MESSAGE.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 7495007e4..3306ee821 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..12d4499d8 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,22 @@ +{ + "app": { + "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 + } +} 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" + ); + } +} 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); } };