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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions scripts/doctor.mjs
Original file line number Diff line number Diff line change
@@ -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);

8 changes: 5 additions & 3 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
191 changes: 191 additions & 0 deletions src-tauri/src/dictation_stub.rs
Original file line number Diff line number Diff line change
@@ -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<u64>,
}

#[derive(Debug, Serialize, Clone)]
pub(crate) struct DictationModelStatus {
pub(crate) state: DictationModelState,
#[serde(rename = "modelId")]
pub(crate) model_id: String,
pub(crate) progress: Option<DictationDownloadProgress>,
pub(crate) error: Option<String>,
pub(crate) path: Option<String>,
}

#[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<String>) -> 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<String>,
) -> Result<DictationModelStatus, String> {
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<String>,
) -> Result<DictationModelStatus, String> {
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<String>,
) -> Result<DictationModelStatus, String> {
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<String>,
) -> Result<DictationModelStatus, String> {
dictation_model_status(app, state, model_id).await
}

#[tauri::command]
pub(crate) async fn dictation_start(
_preferred_language: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<DictationSessionState, String> {
{
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<DictationSessionState, String> {
{
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<DictationSessionState, String> {
{
let mut dictation = state.dictation.lock().await;
dictation.session_state = DictationSessionState::Idle;
}
emit_event(
&app,
DictationEvent::Canceled {
message: "Canceled".to_string(),
},
);
Ok(DictationSessionState::Idle)
}

5 changes: 5 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions src-tauri/tauri.windows.conf.json
Original file line number Diff line number Diff line change
@@ -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
}
}
47 changes: 47 additions & 0 deletions src-tauri/tests/tauri_config.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
}
Loading