4 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
7 changed files with 101 additions and 23 deletions
Generated
+1 -1
View File
@@ -2224,7 +2224,7 @@ dependencies = [
[[package]] [[package]]
name = "mouth" name = "mouth"
version = "0.2.1" version = "0.2.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard", "arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mouth" name = "mouth"
version = "0.2.1" version = "0.2.3"
edition = "2024" edition = "2024"
description = "Offline speech-to-text with global hotkey and paste" description = "Offline speech-to-text with global hotkey and paste"
license-file = "LICENSE" license-file = "LICENSE"
+7 -1
View File
@@ -170,7 +170,13 @@ if [ ${#BUILT[@]} -eq 0 ]; then
fi fi
echo "" 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 if [[ ! "${confirm}" =~ ^[Yy]$ ]]; then
echo "Skipped. Artifacts are in ${RELEASE_DIR}/" echo "Skipped. Artifacts are in ${RELEASE_DIR}/"
exit 0 exit 0
+49
View File
@@ -1,4 +1,5 @@
use anyhow::Result; use anyhow::Result;
use cpal::traits::{DeviceTrait, HostTrait};
use dialoguer::{Input, Select}; use dialoguer::{Input, Select};
use std::time::Duration; use std::time::Duration;
@@ -105,6 +106,8 @@ pub fn interactive() -> Result<()> {
.interact()?; .interact()?;
config.audio_feedback = feedback_idx == 0; config.audio_feedback = feedback_idx == 0;
config.input_device = prompt_input_device(config.input_device.as_deref())?;
let vad_idx = Select::new() let vad_idx = Select::new()
.with_prompt("VAD (voice activity detection)") .with_prompt("VAD (voice activity detection)")
.items(&["enabled", "disabled"]) .items(&["enabled", "disabled"])
@@ -122,6 +125,52 @@ pub fn interactive() -> Result<()> {
Ok(()) 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. /// Prompt the user to either press a key combination or type it manually.
fn prompt_hotkey(label: &str, current: &str) -> Result<String> { fn prompt_hotkey(label: &str, current: &str) -> Result<String> {
let choice = Select::new() let choice = Select::new()
-16
View File
@@ -22,10 +22,6 @@ pub fn run() -> Result<()> {
std::process::exit(1); std::process::exit(1);
} }
// Hide Windows console window
#[cfg(windows)]
hide_console();
info!("Mouth v{} starting", env!("CARGO_PKG_VERSION")); info!("Mouth v{} starting", env!("CARGO_PKG_VERSION"));
info!("Mode: {:?}", config.mode); info!("Mode: {:?}", config.mode);
info!("Hotkey: {}", config.hotkey); info!("Hotkey: {}", config.hotkey);
@@ -146,15 +142,3 @@ pub fn run() -> Result<()> {
ipc::cleanup(); ipc::cleanup();
Ok(()) 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);
}
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::sync::Arc; use std::sync::Arc;
use tracing::{debug, info, warn}; use tracing::{debug, info};
use crate::shared_state::SharedState; use crate::shared_state::SharedState;
@@ -116,7 +116,7 @@ fn unix_listener(path: &str, shared_state: Arc<SharedState>) -> Result<()> {
} }
} }
Err(e) => { 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 audio_feedback;
mod cli; mod cli;
mod config; mod config;
@@ -48,7 +50,16 @@ enum Commands {
Status, 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() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
@@ -57,8 +68,9 @@ fn main() -> anyhow::Result<()> {
.init(); .init();
let cli = Cli::parse(); 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(), None | Some(Commands::Run) => cli::run_cmd::run(),
Some(Commands::Config { show, reset }) => { Some(Commands::Config { show, reset }) => {
@@ -80,5 +92,32 @@ fn main() -> anyhow::Result<()> {
} }
Some(Commands::Status) => cli::status_cmd::status(), 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,
);
} }
} }