fix(desktop): reserve PTT shortcut only during active huddle (#1315)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-26 16:31:24 -07:00
committed by GitHub
co-authored by Brain
parent 8de7d8f2ff
commit 6c60cb59ab
4 changed files with 135 additions and 87 deletions
+5
View File
@@ -111,6 +111,11 @@ pub async fn set_voice_input_mode(
}
}
// A mode switch changes whether the global shortcut should be reserved but
// does not otherwise emit; emit here so the frontend sees the new mode and
// the shortcut registration is re-synced.
state.emit_huddle_state_changed();
Ok(())
}
+2
View File
@@ -218,6 +218,8 @@ impl HuddleState {
pub fn emit_huddle_state(app: &tauri::AppHandle, state: &HuddleState) {
use tauri::Emitter;
let _ = app.emit("huddle-state-changed", state);
// Reserve Ctrl+Space with the OS only while a huddle is live in PTT mode.
crate::ptt_shortcut::sync_registration(app, state);
}
// ── Response types ────────────────────────────────────────────────────────────
+82 -87
View File
@@ -13,6 +13,7 @@ mod model_tests;
mod models;
pub mod nostr_convert;
mod prevent_sleep;
mod ptt_shortcut;
mod relay;
mod secret_store;
mod shutdown;
@@ -98,94 +99,98 @@ pub fn run() {
)
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin({
use tauri_plugin_global_shortcut::ShortcutState;
.plugin(tauri_plugin_process::init());
// Generation counter for the release delay task. Incremented on
// every press — a delayed release only fires if the generation
// hasn't changed (i.e. no new press happened during the delay).
// This prevents press→release→press within 200 ms from having
// the first release clobber the second press.
let ptt_press_gen = Arc::new(std::sync::atomic::AtomicU64::new(0));
// The global-shortcut plugin is omitted from test builds: linking it into
// the lib-test binary makes it fail to load on Windows
// (STATUS_ENTRYPOINT_NOT_FOUND) before any test runs.
#[cfg(not(test))]
let builder = builder.plugin({
use tauri_plugin_global_shortcut::ShortcutState;
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, _shortcut, event| {
let state = match app.try_state::<AppState>() {
Some(s) => s,
None => return,
};
// Generation counter for the release delay task. Incremented on
// every press — a delayed release only fires if the generation
// hasn't changed (i.e. no new press happened during the delay).
// This prevents press→release→press within 200 ms from having
// the first release clobber the second press.
let ptt_press_gen = Arc::new(std::sync::atomic::AtomicU64::new(0));
// Only act if a huddle is active and mode is PTT.
let (is_ptt_mode, is_active) = match state.huddle_state.lock() {
Ok(hs) => (
hs.voice_input_mode == huddle::VoiceInputMode::PushToTalk,
matches!(
hs.phase,
huddle::HuddlePhase::Connected | huddle::HuddlePhase::Active
),
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, _shortcut, event| {
let state = match app.try_state::<AppState>() {
Some(s) => s,
None => return,
};
// Only act if a huddle is active and mode is PTT.
let (is_ptt_mode, is_active) = match state.huddle_state.lock() {
Ok(hs) => (
hs.voice_input_mode == huddle::VoiceInputMode::PushToTalk,
matches!(
hs.phase,
huddle::HuddlePhase::Connected | huddle::HuddlePhase::Active
),
Err(_) => return,
};
),
Err(_) => return,
};
if !is_ptt_mode || !is_active {
return;
}
if !is_ptt_mode || !is_active {
return;
}
match event.state {
ShortcutState::Pressed => {
// Bump generation — invalidates any pending release delay.
ptt_press_gen.fetch_add(1, std::sync::atomic::Ordering::Release);
match event.state {
ShortcutState::Pressed => {
// Bump generation — invalidates any pending release delay.
ptt_press_gen.fetch_add(1, std::sync::atomic::Ordering::Release);
if let Ok(hs) = state.huddle_state.lock() {
hs.ptt_active
if let Ok(hs) = state.huddle_state.lock() {
hs.ptt_active
.store(true, std::sync::atomic::Ordering::Release);
// Only cancel TTS if it's actually playing — avoids
// a stale cancel flag that drops the next queued message.
if hs.tts_active.load(std::sync::atomic::Ordering::Acquire) {
hs.tts_cancel
.store(true, std::sync::atomic::Ordering::Release);
// Only cancel TTS if it's actually playing — avoids
// a stale cancel flag that drops the next queued message.
if hs.tts_active.load(std::sync::atomic::Ordering::Acquire) {
hs.tts_cancel
.store(true, std::sync::atomic::Ordering::Release);
}
}
// Emit ptt-state=true to the frontend.
// The React side plays the press audio cue on this event
// (Web Audio API via HuddleContext). Rust-side rodio audio
// was considered but rejected: the rodio OutputStream must
// outlive the handler and sharing it across the shortcut
// closure adds lifecycle complexity for marginal gain.
// The React implementation is sufficient and simpler.
let _ = app.emit("ptt-state", true);
}
ShortcutState::Released => {
// Capture generation at release time.
let gen_at_release =
ptt_press_gen.load(std::sync::atomic::Ordering::Acquire);
let gen_arc = Arc::clone(&ptt_press_gen);
let app_handle = app.clone();
// 200 ms release delay — captures the tail of the utterance.
// Only applies if no new press happened during the delay.
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Check generation — if it changed, a new press arrived.
if gen_arc.load(std::sync::atomic::Ordering::Acquire) != gen_at_release
{
return; // Superseded by a new press.
}
if let Some(state) = app_handle.try_state::<AppState>() {
if let Ok(hs) = state.huddle_state.lock() {
hs.ptt_active
.store(false, std::sync::atomic::Ordering::Release);
}
}
// Emit ptt-state=true to the frontend.
// The React side plays the press audio cue on this event
// (Web Audio API via HuddleContext). Rust-side rodio audio
// was considered but rejected: the rodio OutputStream must
// outlive the handler and sharing it across the shortcut
// closure adds lifecycle complexity for marginal gain.
// The React implementation is sufficient and simpler.
let _ = app.emit("ptt-state", true);
}
ShortcutState::Released => {
// Capture generation at release time.
let gen_at_release =
ptt_press_gen.load(std::sync::atomic::Ordering::Acquire);
let gen_arc = Arc::clone(&ptt_press_gen);
let app_handle = app.clone();
// 200 ms release delay — captures the tail of the utterance.
// Only applies if no new press happened during the delay.
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Check generation — if it changed, a new press arrived.
if gen_arc.load(std::sync::atomic::Ordering::Acquire)
!= gen_at_release
{
return; // Superseded by a new press.
}
if let Some(state) = app_handle.try_state::<AppState>() {
if let Ok(hs) = state.huddle_state.lock() {
hs.ptt_active
.store(false, std::sync::atomic::Ordering::Release);
}
}
// Emit ptt-state=false — React plays the release audio cue.
let _ = app_handle.emit("ptt-state", false);
});
}
// Emit ptt-state=false — React plays the release audio cue.
let _ = app_handle.emit("ptt-state", false);
});
}
})
.build()
});
}
})
.build()
});
// Only register the updater in release builds that were compiled with a
// real updater configuration. Local unsigned builds omit that config and
@@ -326,16 +331,6 @@ pub fn run() {
mgr.start_tts_download(state.http_client.clone());
}
// Non-fatal: huddle works without the shortcut (user can switch to VAD mode).
#[cfg(desktop)]
{
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
let shortcut = Shortcut::new(Some(Modifiers::CONTROL), Code::Space);
if let Err(e) = app.handle().global_shortcut().register(shortcut) {
eprintln!("buzz-desktop: failed to register PTT shortcut: {e}");
}
}
// Handle deep link URLs received while the app is running (macOS)
// and on cold start. The single-instance plugin handles forwarding
// from duplicate launches on Windows/Linux.
+46
View File
@@ -0,0 +1,46 @@
//! Push-to-talk global shortcut lifecycle.
//!
//! `Ctrl+Space` is only reserved with the OS while a huddle is connected and
//! the voice input mode is push-to-talk. Reserving it for the whole app
//! lifetime conflicts with IDEs and other apps, so registration is synced to
//! huddle state instead.
use crate::huddle::HuddleState;
#[cfg(not(test))]
use crate::huddle::{HuddlePhase, VoiceInputMode};
/// Whether the PTT shortcut should currently be reserved with the OS.
#[cfg(not(test))]
fn should_register(hs: &HuddleState) -> bool {
hs.voice_input_mode == VoiceInputMode::PushToTalk
&& matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active)
}
/// Register or unregister the PTT shortcut to match the given huddle state.
///
/// Idempotent and best-effort: failures are logged, never fatal — the huddle
/// still works in VAD mode without the shortcut.
#[cfg(not(test))]
pub fn sync_registration(app: &tauri::AppHandle, hs: &HuddleState) {
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
let shortcut = Shortcut::new(Some(Modifiers::CONTROL), Code::Space);
let manager = app.global_shortcut();
let want = should_register(hs);
let is_registered = manager.is_registered(shortcut);
if want && !is_registered {
if let Err(e) = manager.register(shortcut) {
eprintln!("buzz-desktop: failed to register PTT shortcut: {e}");
}
} else if !want && is_registered {
if let Err(e) = manager.unregister(shortcut) {
eprintln!("buzz-desktop: failed to unregister PTT shortcut: {e}");
}
}
}
/// Test builds omit the global-shortcut plugin (see `run()`), so syncing is a
/// no-op — calling the plugin would panic without it installed.
#[cfg(test)]
pub fn sync_registration(_app: &tauri::AppHandle, _hs: &HuddleState) {}