6 Commits

Author SHA1 Message Date
steve 82181545a6 v0.2.3: Run without console window on Windows
- Add windows_subsystem = "windows" to prevent console window on double-click
- Use AttachConsole(ATTACH_PARENT_PROCESS) so CLI subcommands still work from a terminal
- Show MessageBoxW error dialog if daemon fails to start
- Remove hide_console() (now unnecessary)
- Fix unused import warning in ipc.rs (warn → tracing::warn! inline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 05:46:53 +01:00
steve e3f823045c Auto-publish in non-interactive mode in release script
When stdin is not a terminal (e.g. run from a tool or CI), skip the
confirmation prompt and proceed with publishing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:23:28 +01:00
steve eff93e4731 Bump version to 0.2.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:22:20 +01:00
steve daa1542672 Add input device selection to interactive config TUI
Enumerates available audio input devices via cpal and presents them
in a dropdown, with "System default" as the first option.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:21:44 +01:00
steve 3fa4d102df v0.2.1: Fix cancel key (Escape) being swallowed globally
The cancel key was consumed by rdev::grab at all times, not just during
recording/transcribing. This made the Escape key unusable system-wide
while Mouth was running. Now the cancel key only gets swallowed when
Mouth is actively recording or transcribing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:37:50 +01:00
steve 9ad870d260 Add Windows Defender false positive guidance to README
Unsigned Rust binaries that use keyboard hooks, input simulation, and
clipboard access trigger Defender heuristics. Document the workaround
(Defender exclusion) and point users to building from source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:59:59 +01:00
10 changed files with 154 additions and 31 deletions
Generated
+1 -1
View File
@@ -2224,7 +2224,7 @@ dependencies = [
[[package]]
name = "mouth"
version = "0.2.0"
version = "0.2.3"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mouth"
version = "0.2.0"
version = "0.2.3"
edition = "2024"
description = "Offline speech-to-text with global hotkey and paste"
license-file = "LICENSE"
+30
View File
@@ -82,6 +82,36 @@ A small colour-coded bar appears at the top (or bottom) of your screen:
Set `overlay_position: none` to disable.
## Windows Defender False Positive
Windows Defender may flag `mouth.exe` as malicious and quarantine it. This is a
false positive caused by the way Mouth works — it uses global keyboard hooks,
simulated input, and clipboard access, which are the same techniques used by
legitimate accessibility tools but also match heuristic patterns that antivirus
software looks for.
Mouth is open source and you can inspect every line of code in this repository.
Unfortunately, the only reliable way to prevent these warnings is to purchase a
code signing certificate, which I can't justify for a free, non-commercial
project. If you're not comfortable adding an exception, you're welcome to build
the exe yourself from source (see below) — a locally built binary is far less
likely to be flagged.
To add an exclusion in Windows Defender:
1. Open **Windows Security** (search for it in the Start menu)
2. Go to **Virus & threat protection**
3. Under "Virus & threat protection settings", click **Manage settings**
4. Scroll down to **Exclusions** and click **Add or remove exclusions**
5. Click **Add an exclusion****File**, then select `mouth.exe`
If Defender has already quarantined the file, you'll need to restore it first:
1. In **Virus & threat protection**, click **Protection history**
2. Find the Mouth entry, expand it, and click **Restore**
Then add the exclusion above to prevent it happening again.
## Building from Source
Requires Rust 1.75+.
+7 -1
View File
@@ -170,7 +170,13 @@ if [ ${#BUILT[@]} -eq 0 ]; then
fi
echo ""
read -rp "Publish release ${TAG} to ${FORGE}? [y/N] " confirm
if [ -t 0 ]; then
read -rp "Publish release ${TAG} to ${FORGE}? [y/N] " confirm
else
# Non-interactive (piped/scripted) — default to yes
confirm="y"
echo "Non-interactive mode: auto-publishing release ${TAG} to ${FORGE}"
fi
if [[ ! "${confirm}" =~ ^[Yy]$ ]]; then
echo "Skipped. Artifacts are in ${RELEASE_DIR}/"
exit 0
+49
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use cpal::traits::{DeviceTrait, HostTrait};
use dialoguer::{Input, Select};
use std::time::Duration;
@@ -105,6 +106,8 @@ pub fn interactive() -> Result<()> {
.interact()?;
config.audio_feedback = feedback_idx == 0;
config.input_device = prompt_input_device(config.input_device.as_deref())?;
let vad_idx = Select::new()
.with_prompt("VAD (voice activity detection)")
.items(&["enabled", "disabled"])
@@ -122,6 +125,52 @@ pub fn interactive() -> Result<()> {
Ok(())
}
/// Prompt the user to select an audio input device from available devices.
fn prompt_input_device(current: Option<&str>) -> Result<Option<String>> {
let current_label = current.unwrap_or("system default");
let host = cpal::default_host();
let mut device_names: Vec<String> = Vec::new();
if let Ok(devices) = host.input_devices() {
for device in devices {
if let Ok(name) = device.name() {
device_names.push(name);
}
}
}
// Build selection list: "System default" first, then each detected device
let mut items: Vec<String> = vec!["System default".to_string()];
for name in &device_names {
items.push(name.clone());
}
// Find the default selection index
let default_idx = if let Some(cur) = current {
let cur_lower = cur.to_lowercase();
device_names
.iter()
.position(|n| n.to_lowercase().contains(&cur_lower))
.map(|i| i + 1) // offset by 1 for "System default"
.unwrap_or(0)
} else {
0
};
let sel = Select::new()
.with_prompt(format!("Input device (current: {current_label})"))
.items(&items)
.default(default_idx)
.interact()?;
if sel == 0 {
Ok(None)
} else {
Ok(Some(device_names[sel - 1].clone()))
}
}
/// Prompt the user to either press a key combination or type it manually.
fn prompt_hotkey(label: &str, current: &str) -> Result<String> {
let choice = Select::new()
+2 -17
View File
@@ -22,10 +22,6 @@ pub fn run() -> Result<()> {
std::process::exit(1);
}
// Hide Windows console window
#[cfg(windows)]
hide_console();
info!("Mouth v{} starting", env!("CARGO_PKG_VERSION"));
info!("Mode: {:?}", config.mode);
info!("Hotkey: {}", config.hotkey);
@@ -103,10 +99,11 @@ pub fn run() -> Result<()> {
})
.context("Failed to spawn recorder thread")?;
let hotkey_state = Arc::clone(&shared_state);
thread::Builder::new()
.name("mouth-hotkey".into())
.spawn(move || {
hotkey::listen(hotkey_combo, cancel_combo, hotkey_tx);
hotkey::listen(hotkey_combo, cancel_combo, hotkey_tx, hotkey_state);
})
.context("Failed to spawn hotkey thread")?;
@@ -145,15 +142,3 @@ pub fn run() -> Result<()> {
ipc::cleanup();
Ok(())
}
#[cfg(windows)]
fn hide_console() {
use windows_sys::Win32::System::Console::GetConsoleWindow;
use windows_sys::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_HIDE};
unsafe {
let console = GetConsoleWindow();
if !console.is_null() {
ShowWindow(console, SW_HIDE);
}
}
}
+15 -7
View File
@@ -1,10 +1,14 @@
use anyhow::{bail, Result};
use rdev::{self, Event, EventType, Key};
use std::cell::RefCell;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{debug, error, info};
use crate::shared_state::SharedState;
/// Events sent from the hotkey listener to the coordinator.
#[derive(Debug, Clone, Copy)]
pub enum HotkeyEvent {
@@ -380,6 +384,7 @@ pub fn listen(
hotkey: HotkeyCombination,
cancel_key: HotkeyCombination,
tx: mpsc::Sender<HotkeyEvent>,
shared_state: Arc<SharedState>,
) {
let debounce_duration = Duration::from_millis(30);
@@ -406,16 +411,19 @@ pub fn listen(
EventType::KeyPress(key) => {
s.modifier_state.update(&key, true);
// Check cancel key — swallow it
// Check cancel key — only swallow it when actively recording/transcribing
if key == cancel_key.key && s.modifier_state.all_held(&cancel_key.modifiers) {
if now.duration_since(s.last_event_time) >= debounce_duration {
s.last_event_time = now;
debug!("Cancel key pressed");
if tx.send(HotkeyEvent::Cancel).is_err() {
error!("Failed to send cancel event");
if shared_state.is_active.load(Ordering::Acquire) {
if now.duration_since(s.last_event_time) >= debounce_duration {
s.last_event_time = now;
debug!("Cancel key pressed");
if tx.send(HotkeyEvent::Cancel).is_err() {
error!("Failed to send cancel event");
}
}
return None;
}
return None;
// Not active — let the key pass through
}
// Check hotkey — swallow it
+2 -2
View File
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::sync::Arc;
use tracing::{debug, info, warn};
use tracing::{debug, info};
use crate::shared_state::SharedState;
@@ -116,7 +116,7 @@ fn unix_listener(path: &str, shared_state: Arc<SharedState>) -> Result<()> {
}
}
Err(e) => {
warn!("Failed to serialize status: {e}");
tracing::warn!("Failed to serialize status: {e}");
}
}
}
+41 -2
View File
@@ -1,3 +1,5 @@
#![cfg_attr(windows, windows_subsystem = "windows")]
mod audio_feedback;
mod cli;
mod config;
@@ -48,7 +50,16 @@ enum Commands {
Status,
}
fn main() -> anyhow::Result<()> {
fn main() {
// Attach to parent console when launched from a terminal so CLI subcommands
// can write output. Silently fails when double-clicked (no parent console).
#[cfg(windows)]
unsafe {
windows_sys::Win32::System::Console::AttachConsole(
windows_sys::Win32::System::Console::ATTACH_PARENT_PROCESS,
);
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
@@ -57,8 +68,9 @@ fn main() -> anyhow::Result<()> {
.init();
let cli = Cli::parse();
let is_daemon = matches!(cli.command, None | Some(Commands::Run));
match cli.command {
let result = match cli.command {
None | Some(Commands::Run) => cli::run_cmd::run(),
Some(Commands::Config { show, reset }) => {
@@ -80,5 +92,32 @@ fn main() -> anyhow::Result<()> {
}
Some(Commands::Status) => cli::status_cmd::status(),
};
if let Err(e) = result {
if is_daemon {
#[cfg(windows)]
show_error_dialog(&format!("Mouth failed to start:\n\n{e:#}"));
#[cfg(not(windows))]
eprintln!("Error: {e:#}");
} else {
eprintln!("Error: {e:#}");
}
std::process::exit(1);
}
}
#[cfg(windows)]
fn show_error_dialog(message: &str) {
use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
let title: Vec<u16> = "Mouth".encode_utf16().chain(Some(0)).collect();
let msg: Vec<u16> = message.encode_utf16().chain(Some(0)).collect();
unsafe {
MessageBoxW(
std::ptr::null_mut(),
msg.as_ptr(),
title.as_ptr(),
MB_OK | MB_ICONERROR,
);
}
}
+6
View File
@@ -1,9 +1,13 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
use std::time::Instant;
/// Thread-safe shared state accessible by the coordinator, IPC listener, and tray icon.
pub struct SharedState {
pub state: RwLock<String>,
/// True when recording or transcribing — the hotkey listener uses this to
/// decide whether to swallow the cancel key.
pub is_active: AtomicBool,
pub model: String,
pub accelerator: String,
pub started_at: Instant,
@@ -13,6 +17,7 @@ impl SharedState {
pub fn new(model: String, accelerator: String) -> Self {
Self {
state: RwLock::new("idle".to_string()),
is_active: AtomicBool::new(false),
model,
accelerator,
started_at: Instant::now(),
@@ -23,6 +28,7 @@ impl SharedState {
if let Ok(mut s) = self.state.write() {
*s = state.to_string();
}
self.is_active.store(state != "idle", Ordering::Release);
}
pub fn get_state(&self) -> String {