Skip to content
Open
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
244 changes: 136 additions & 108 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions crates/whimpr-asr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ anyhow = { workspace = true }
hound = "3"

[target.'cfg(target_os = "macos")'.dependencies]
whisper-rs = { version = "0.12", features = ["metal"] }
whisper-rs = { version = "0.16", features = ["metal"] }

# No GPU feature on Windows yet — CPU backend first, Vulkan/CUDA can follow.
# No GPU feature on Windows/Linux yet — CPU backend first, Vulkan/CUDA can follow.
[target.'cfg(not(target_os = "macos"))'.dependencies]
whisper-rs = { version = "0.12" }
whisper-rs = { version = "0.16" }
8 changes: 3 additions & 5 deletions crates/whimpr-asr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,11 @@ impl AsrEngine for WhisperEngine {
.full(params, pcm16k)
.map_err(|e| anyhow::anyhow!("whisper full: {e}"))?;

let n = state
.full_n_segments()
.map_err(|e| anyhow::anyhow!("whisper n_segments: {e}"))?;
let n = state.full_n_segments();
let mut text = String::new();
for i in 0..n {
if let Ok(seg) = state.full_get_segment_text(i) {
text.push_str(&seg);
if let Some(seg) = state.get_segment(i) {
text.push_str(&seg.to_string());
}
}

Expand Down
9 changes: 9 additions & 0 deletions crates/whimpr-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ pub struct Settings {
pub anthropic_model: String,
/// Play the record-start ping.
pub sound_on_start: bool,
/// The push-to-talk key, stored as an rdev::Key variant string (e.g. "ControlRight").
/// Defaults to Right Ctrl.
#[serde(default = "default_push_to_talk_key")]
pub push_to_talk_key: String,
}

fn default_push_to_talk_key() -> String {
"ControlRight".to_string()
}

impl Default for Settings {
Expand All @@ -47,6 +55,7 @@ impl Default for Settings {
openai_base_url: String::new(),
anthropic_model: "claude-haiku-4-5".to_string(),
sound_on_start: true,
push_to_talk_key: default_push_to_talk_key(),
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,10 @@ macos-accessibility-client = "0.0.1"
objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication"] }
objc2-foundation = { version = "0.3", features = ["NSString"] }
objc2-av-foundation = { version = "0.3", features = ["AVCaptureDevice", "AVMediaFormat"] }

[target.'cfg(target_os = "linux")'.dependencies]
# Global keyboard listener (X11 + Wayland). Needs /dev/input access — add user to
# the 'input' group if events aren't received.
rdev = "0.5"
# Keyboard simulation is done via ydotool (Wayland) or xdotool (X11) as subprocess
# calls, avoiding libxdo/libei C dependency issues.
19 changes: 18 additions & 1 deletion src-tauri/src/appctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,24 @@ pub fn frontmost_bundle_id() -> Option<String> {
}
}

#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
pub fn frontmost_bundle_id() -> Option<String> {
// Try xdotool first (most reliable on X11)
if let Ok(out) = std::process::Command::new("xdotool")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

frontmost_bundle_id() re-implements the same xdotool getactivewindow / getwindowclassname logic that already exists as foreground_app() in src-tauri/src/linux.rs (~lines 99-111), instead of calling that helper.

Two independent copies of the xdotool-invocation logic now exist — a future fix to one (e.g. the xprop fallback linux.rs already has) won't be applied to the other, so frontmost-app detection will silently diverge between call sites. Worth extracting/reusing the existing helper instead of duplicating it.

.args(["getactivewindow", "getwindowclassname"])
.output()
{
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
}
None
}

#[cfg(target_os = "windows")]
pub fn frontmost_bundle_id() -> Option<String> {
None
}
27 changes: 3 additions & 24 deletions src-tauri/src/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,30 +707,9 @@ pub use crate::win::{
history, install, rebuild_providers, stats_summary, update_settings,
};

// Other platforms (Linux, etc.): inert stubs so the crate still builds.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
mod other {
pub fn install(_app: tauri::AppHandle) {}
pub fn current_settings() -> whimpr_core::Settings {
whimpr_core::Settings::default()
}
pub fn update_settings(_new: whimpr_core::Settings) {}
pub fn rebuild_providers() {}
pub fn stats_summary(tz_offset_minutes: i32) -> whimpr_core::StatsSummary {
whimpr_core::StatsStore::default().summary(tz_offset_minutes, 0)
}
pub fn history(_limit: usize) -> Vec<whimpr_core::HistoryItem> {
Vec::new()
}
pub fn dictionary_entries() -> Vec<super::DictEntryDto> {
Vec::new()
}
pub fn dictionary_add(_correct: String, _mishears: Vec<String>) {}
pub fn dictionary_remove(_correct: &str) {}
pub fn dictionary_learn(_correct: String, _mishears: Vec<String>) {}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub use other::{
// Linux: real platform layer (rdev hotkey + enigo paste + full pipeline).
#[cfg(target_os = "linux")]
pub use crate::linux::{
current_settings, dictionary_add, dictionary_entries, dictionary_learn, dictionary_remove,
history, install, rebuild_providers, stats_summary, update_settings,
};
35 changes: 35 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ mod local_llm;
mod paste;
#[cfg(target_os = "windows")]
mod win;
#[cfg(target_os = "linux")]
mod linux;

use serde::Serialize;
use tauri::{
Expand Down Expand Up @@ -168,6 +170,16 @@ fn open_url(url: &str) {
let _ = std::process::Command::new("open").arg(url).spawn();
}

#[cfg(target_os = "linux")]
fn open_url(url: &str) {
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}

#[cfg(target_os = "windows")]
fn open_url(url: &str) {
let _ = std::process::Command::new("cmd").args(["/c", "start", url]).spawn();
}

/// Request microphone access: trigger the native prompt (bundle has a usage string)
/// by briefly opening the input device, and open the Microphone settings pane.
#[tauri::command]
Expand All @@ -182,6 +194,18 @@ fn request_microphone() {
});
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone");
}
#[cfg(target_os = "linux")]
{
// On Linux, microphone access is controlled by PipeWire/PulseAudio.
// Opening pavucontrol is the closest equivalent to macOS's permission pane.
std::thread::spawn(|| {
if let Ok(h) = whimpr_audio::start(|_: &[f32]| {}) {
std::thread::sleep(std::time::Duration::from_millis(400));
let _ = h.stop();
}
});
let _ = std::process::Command::new("pavucontrol").spawn();
}
}

/// Request Accessibility — the permission that makes the Fn key work in every app and
Expand All @@ -193,6 +217,13 @@ fn request_accessibility() {
let _ = paste::prompt_accessibility();
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
}
#[cfg(target_os = "linux")]
{
// On Linux, keyboard input access is via the 'input' group.
// Open the system settings or print instructions.
eprintln!("[whimpr] Linux: ensure your user is in the 'input' group: sudo usermod -aG input $USER");
let _ = open_url("https://wiki.archlinux.org/title/Input_device");
}
}

/// Request Input Monitoring (needed for the Fn key to be seen in every app, not
Expand All @@ -204,6 +235,10 @@ fn request_input_monitoring() {
let _ = paste::request_input_monitoring();
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent");
}
#[cfg(target_os = "linux")]
{
eprintln!("[whimpr] Linux: input monitoring is handled via rdev (needs 'input' group membership)");
}
}

/// Save (or clear, when empty) an API key in the OS keychain, then rebuild providers
Expand Down
Loading