mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {}
|
||||
Reference in New Issue
Block a user