Skip to content
Merged
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
47 changes: 47 additions & 0 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,5 +283,52 @@ pub fn trigger_microphone_prompt(app: AppHandle) -> Result<(), String> {

// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────

#[tauri::command]
pub fn is_debug_ui_key_events_enabled() -> bool {
std::env::var("OPENLESS_DEBUG_UI_KEY_EVENTS")
.ok()
.as_deref()
== Some("1")
}

#[tauri::command]
pub fn debug_log_ui_key_event(
event_type: String,
key: String,
code: String,
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
repeat: bool,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): The Tauri command argument names don't match the keys used in the frontend invoke payload, which will likely break deserialization.

On the TS side, handleWindowHotkeyEvent is invoked with { eventType, key, code, repeat }, so Tauri expects arguments with those exact names. The Rust command uses event_type: String, which won’t match eventType, so deserialization will fail at runtime. Please either align the Rust parameter names with the JS keys, use a struct with #[serde(rename = "eventType")] (and similar for others), or update the frontend payload to use snake_case. The same mismatch exists for debug_log_ui_key_event (eventType vs event_type).

) {
if !is_debug_ui_key_events_enabled() {
return;
}
log::info!(
"[ui-key] type={} key={} code={} ctrl={} alt={} shift={} meta={} repeat={}",
event_type,
key.replace(' ', "_"),
code.replace(' ', "_"),
ctrl,
alt,
shift,
meta,
repeat
);
}

#[tauri::command]
pub async fn handle_window_hotkey_event(
coord: CoordinatorState<'_>,
event_type: String,
key: String,
code: String,
repeat: bool,
) -> Result<(), String> {
coord.handle_window_hotkey_event(&event_type, &key, &code, repeat)
.await
}

#[allow(dead_code)]
fn _ensure_snapshot_used(_: CredentialsSnapshot) {}
96 changes: 95 additions & 1 deletion openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::recorder::Recorder;
use crate::types::{
CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus,
HotkeyStatusState, InsertStatus, PolishMode,
HotkeyStatusState, HotkeyTrigger, InsertStatus, PolishMode,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -86,6 +86,7 @@ struct Inner {
recorder: Mutex<Option<Recorder>>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
window_hotkey_held: Mutex<bool>,
}

impl Coordinator {
Expand All @@ -109,6 +110,7 @@ impl Coordinator {
recorder: Mutex::new(None),
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
window_hotkey_held: Mutex::new(false),
}),
}
}
Expand Down Expand Up @@ -176,6 +178,17 @@ impl Coordinator {
Ok(())
}

pub async fn handle_window_hotkey_event(
&self,
event_type: &str,
key: &str,
code: &str,
repeat: bool,
) -> Result<(), String> {
handle_window_hotkey_event(&self.inner, event_type, key, code, repeat).await;
Ok(())
}

pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result<String, String> {
let hotwords = enabled_phrases(&self.inner);
polish_text(&raw_text, mode, &hotwords)
Expand Down Expand Up @@ -260,6 +273,87 @@ fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
}
}

async fn handle_window_hotkey_event(
inner: &Arc<Inner>,
event_type: &str,
key: &str,
code: &str,
repeat: bool,
) {
#[cfg(not(target_os = "windows"))]
{
let _ = (inner, event_type, key, code, repeat);
return;
}

#[cfg(target_os = "windows")]
{
if event_type == "keydown" && key == "Escape" {
log::info!("[window-hotkey] escape cancel from main window");
cancel_session(inner);
return;
}

let binding = inner.prefs.get().hotkey;
if !matches_window_hotkey(binding.trigger, code) {
return;
}

if event_type == "keydown" {
if repeat {
return;
}
let should_press = {
let mut held = inner.window_hotkey_held.lock();
if *held {
false
} else {
*held = true;
true
}
};
if !should_press {
return;
}
log::info!(
"[window-hotkey] pressed trigger={:?} code={} repeat={}",
binding.trigger, code, repeat
);
handle_pressed(inner).await;
} else if event_type == "keyup" {
let should_release = {
let mut held = inner.window_hotkey_held.lock();
if !*held {
false
} else {
*held = false;
true
}
};
if !should_release {
return;
}
log::info!(
"[window-hotkey] released trigger={:?} code={} repeat={}",
binding.trigger, code, repeat
);
handle_released(inner).await;
}
}
}

#[cfg(target_os = "windows")]
fn matches_window_hotkey(trigger: HotkeyTrigger, code: &str) -> bool {
match trigger {
HotkeyTrigger::RightControl => code == "ControlRight",
HotkeyTrigger::LeftControl => code == "ControlLeft",
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => code == "AltRight",
HotkeyTrigger::LeftOption => code == "AltLeft",
HotkeyTrigger::RightCommand => code == "MetaRight",
HotkeyTrigger::Fn => false,
}
}

async fn handle_pressed(inner: &Arc<Inner>) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
Expand Down
15 changes: 12 additions & 3 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ pub fn run() {
commands::read_credential,
commands::set_active_asr_provider,
commands::set_active_llm_provider,
commands::is_debug_ui_key_events_enabled,
commands::debug_log_ui_key_event,
commands::handle_window_hotkey_event,
restart_app,
])
.build(tauri::generate_context!())
Expand All @@ -175,9 +178,15 @@ pub fn run() {
RunEvent::Reopen { .. } => show_main_window(app),
RunEvent::WindowEvent { label, event, .. } => {
if label == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
hide_main_window(app);
match event {
tauri::WindowEvent::Focused(focused) => {
log::info!("[window] main focused={focused}");
Comment on lines +182 to +183
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Losing window focus while the hotkey is held can leave window_hotkey_held stuck in the "pressed" state.

Because window_hotkey_held is only cleared on keyup, losing focus while the hotkey is pressed can leave it stuck true, making later hotkey presses no-op until restart. Add logic to reset this flag when the main window loses focus (e.g., in a WindowEvent::Focused(false) branch).

}
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
hide_main_window(app);
}
_ => {}
}
}
}
Expand Down
62 changes: 61 additions & 1 deletion openless-all/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { Capsule } from './components/Capsule';
import { FloatingShell } from './components/FloatingShell';
import { Onboarding } from './components/Onboarding';
import { detectOS } from './components/WindowChrome';
import { checkAccessibilityPermission, checkMicrophonePermission, isTauri } from './lib/ipc';
import {
checkAccessibilityPermission,
checkMicrophonePermission,
debugLogUiKeyEvent,
handleWindowHotkeyEvent,
isDebugUiKeyEventsEnabled,
isTauri,
} from './lib/ipc';
import { HotkeySettingsProvider } from './state/HotkeySettingsContext';

interface AppProps {
Expand Down Expand Up @@ -53,6 +60,59 @@ export function App({ isCapsule }: AppProps) {
};
}, [os]);

useEffect(() => {
if (!isTauri) return;
let disposed = false;
let detach: (() => void) | null = null;

void isDebugUiKeyEventsEnabled().then(enabled => {
if (!enabled || disposed) return;
const onKeyboardEvent = (event: KeyboardEvent) => {
void debugLogUiKeyEvent({
eventType: event.type,
key: event.key,
code: event.code,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
repeat: event.repeat,
});
};
window.addEventListener('keydown', onKeyboardEvent, true);
window.addEventListener('keyup', onKeyboardEvent, true);
detach = () => {
window.removeEventListener('keydown', onKeyboardEvent, true);
window.removeEventListener('keyup', onKeyboardEvent, true);
};
});

return () => {
disposed = true;
detach?.();
};
}, []);

useEffect(() => {
if (!isTauri || os !== 'win') return;

const onKeyboardEvent = (event: KeyboardEvent) => {
void handleWindowHotkeyEvent({
eventType: event.type,
key: event.key,
code: event.code,
repeat: event.repeat,
});
};

window.addEventListener('keydown', onKeyboardEvent, true);
window.addEventListener('keyup', onKeyboardEvent, true);
return () => {
window.removeEventListener('keydown', onKeyboardEvent, true);
window.removeEventListener('keyup', onKeyboardEvent, true);
};
}, [os]);

if (gate === 'checking') {
return <StartupShell />;
}
Expand Down
48 changes: 48 additions & 0 deletions openless-all/app/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,54 @@ export function restartApp(): Promise<void> {
return invokeOrMock('restart_app', undefined, () => undefined);
}

export function isDebugUiKeyEventsEnabled(): Promise<boolean> {
return invokeOrMock('is_debug_ui_key_events_enabled', undefined, () => false);
}

export function debugLogUiKeyEvent(payload: {
eventType: string;
key: string;
code: string;
ctrl: boolean;
alt: boolean;
shift: boolean;
meta: boolean;
repeat: boolean;
}): Promise<void> {
return invokeOrMock(
'debug_log_ui_key_event',
{
eventType: payload.eventType,
key: payload.key,
code: payload.code,
ctrl: payload.ctrl,
alt: payload.alt,
shift: payload.shift,
meta: payload.meta,
repeat: payload.repeat,
},
() => undefined,
);
}

export function handleWindowHotkeyEvent(payload: {
eventType: string;
key: string;
code: string;
repeat: boolean;
}): Promise<void> {
return invokeOrMock(
'handle_window_hotkey_event',
{
eventType: payload.eventType,
key: payload.key,
code: payload.code,
repeat: payload.repeat,
},
() => undefined,
);
}

export async function openExternal(url: string): Promise<void> {
if (!isTauri) {
window.open(url, '_blank', 'noopener,noreferrer');
Expand Down