fix: switch prefix ASCII input source on the foreground client (#1016)
* fix: switch prefix ASCII input source on the foreground client The macOS prefix ASCII-switch ran in the headless server, whose TISCopyCurrentKeyboardInputSource read is a stale per-process cache (refreshed only via a main-run-loop notification the server never pumps), so prefix could leave the terminal on the wrong input source. Forward it to the foreground client, like clipboard: sync_prefix_input_source emits AppEvent::PrefixInputSource as an intent; the server forwards it to the foreground client and the monolithic app applies it in-process via RealPrefixInputSource, which pumps its main run loop before the stale TIS read. Key it on Mode::wants_ascii_input so multi-level prefix commands keep ASCII until returning to the terminal, and restore the IME for rename text entry. Adds ServerMessage::PrefixInputSource to the already-unreleased protocol 15. refs #774 * fix: keep prefix input-source switch out of the headless server process An App-internal drain (the exhaustive drain at the top of handle_api_request, reachable from prefix-key runtime mutations) can consume a queued PrefixInputSource intent before the server's forwarding drain sees it, applying the TIS switch in the headless server process and stranding the restore state. Gate the in-process switch on App.local_input_source_switch, disabled by the headless server alongside the other local side effects, so a swallowed intent degrades to a skipped switch. Widen the forwarding-bypass maintenance test to the handle_internal_event_with_prefix_sync wrapper. refs #774 * docs: clarify prefix input-source comments and flag docs refs #774 * fix: bump protocol for prefix input source refs #774 --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
This commit is contained in:
parent
e713949ca5
commit
2bc1724c2d
|
|
@ -13,6 +13,7 @@
|
|||
- Added `ui.hide_tab_bar_when_single_tab` to hide the tab row when a workspace has one tab. (#448)
|
||||
|
||||
### Changed
|
||||
- Bumped the client/server protocol version to 16 for foreground-client prefix input-source switching.
|
||||
- Bumped the client/server protocol version to 15 for socket API placement mutation event and response compatibility.
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"protocol": 15,
|
||||
"protocol": 16,
|
||||
"schema_version": 1,
|
||||
"schemas": {
|
||||
"error_response": {
|
||||
|
|
|
|||
|
|
@ -500,14 +500,14 @@ The trade-off when enabled: an extra hardware cursor is visible in the outer ter
|
|||
|
||||
On macOS, prefix-mode commands can be hard to use while a non-ASCII input source is active because prefix commands are still interpreted through the host input source.
|
||||
|
||||
Set `switch_ascii_input_source_in_prefix = true` to switch the host input source to the system ASCII-capable input source while prefix mode is active:
|
||||
Set `switch_ascii_input_source_in_prefix = true` to switch the host input source to the system ASCII-capable input source while prefix commands and prefix-launched navigation are active:
|
||||
|
||||
```toml
|
||||
[experimental]
|
||||
switch_ascii_input_source_in_prefix = false
|
||||
```
|
||||
|
||||
When enabled, Herdr switches input sources only after prefix mode is entered, then restores the previous input source when prefix mode exits. The setting is macOS-only and is a no-op on other platforms or when the system input-source switch fails.
|
||||
When enabled, Herdr switches input sources after prefix mode is entered, keeps the ASCII source across prefix-launched modes such as navigation, menus, resize, and copy mode, and restores the previous input source when returning to the terminal or entering a text field such as a rename dialog. The setting is macOS-only and is a no-op on other platforms or when the system input-source switch fails.
|
||||
|
||||
You can also toggle it from Settings > Experiments > switch to ascii input source in prefix (macOS).
|
||||
|
||||
|
|
|
|||
|
|
@ -521,7 +521,7 @@ fn session_snapshot_request_and_response_round_trip() {
|
|||
result: ResponseResult::SessionSnapshot {
|
||||
snapshot: Box::new(SessionSnapshot {
|
||||
version: "0.1.2".into(),
|
||||
protocol: 15,
|
||||
protocol: 16,
|
||||
focused_workspace_id: None,
|
||||
focused_tab_id: None,
|
||||
focused_pane_id: None,
|
||||
|
|
|
|||
|
|
@ -2540,9 +2540,11 @@ impl AppState {
|
|||
.collect()
|
||||
}
|
||||
}
|
||||
// Intercepted in App::handle_internal_event before reaching this
|
||||
// dispatch; never touches AppState.
|
||||
// Both intercepted before this dispatch — in App::handle_internal_event (monolithic)
|
||||
// or via HeadlessServer forwarding to the foreground client (server); never touch
|
||||
// AppState. Kept for AppEvent exhaustiveness.
|
||||
AppEvent::ClipboardWrite { .. } => Vec::new(),
|
||||
AppEvent::PrefixInputSource { .. } => Vec::new(),
|
||||
AppEvent::TerminalCwdReported { pane_id, cwd } => {
|
||||
if !cwd.is_absolute() || !cwd.is_dir() {
|
||||
return Vec::new();
|
||||
|
|
|
|||
|
|
@ -67,6 +67,22 @@ impl App {
|
|||
return;
|
||||
}
|
||||
|
||||
if let AppEvent::PrefixInputSource { active } = ev {
|
||||
// Monolithic path applies the switch here. Server mode forwards it to the foreground
|
||||
// client instead (see HeadlessServer::handle_internal_event_with_forwarding); should an
|
||||
// App-internal drain consume the event before the forwarding drain, the flag keeps the
|
||||
// switch out of the headless server process.
|
||||
if !self.local_input_source_switch {
|
||||
return;
|
||||
}
|
||||
if active {
|
||||
self.prefix_input_source.switch_to_ascii();
|
||||
} else {
|
||||
self.prefix_input_source.restore();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let AppEvent::GitStatusRefreshed {
|
||||
results,
|
||||
cache_updates,
|
||||
|
|
|
|||
213
src/app/mod.rs
213
src/app/mod.rs
|
|
@ -137,6 +137,10 @@ pub struct App {
|
|||
pub(crate) full_redraw_pending: bool,
|
||||
pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>,
|
||||
pub(crate) local_terminal_notifications: bool,
|
||||
/// Whether this process applies `AppEvent::PrefixInputSource` to the host input source.
|
||||
/// The headless server sets this to false: the switch belongs to the foreground client,
|
||||
/// even when an App-internal drain consumes the event before the forwarding drain.
|
||||
pub(crate) local_input_source_switch: bool,
|
||||
pub(crate) config_reloaded_from_disk: bool,
|
||||
prefix_input_source: Box<dyn crate::platform::PrefixInputSource>,
|
||||
}
|
||||
|
|
@ -731,6 +735,7 @@ impl App {
|
|||
full_redraw_pending: false,
|
||||
overlay_panes: HashMap::new(),
|
||||
local_terminal_notifications: true,
|
||||
local_input_source_switch: true,
|
||||
config_reloaded_from_disk: false,
|
||||
prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()),
|
||||
}
|
||||
|
|
@ -821,15 +826,23 @@ impl App {
|
|||
}
|
||||
|
||||
pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) {
|
||||
match (
|
||||
previous_mode == Mode::Prefix,
|
||||
self.state.mode == Mode::Prefix,
|
||||
// Emit the input-source intent on entering/leaving the ASCII realm, like `ClipboardWrite`;
|
||||
// the foreground (client, or this app in monolithic mode) applies the switch. Keyed on the
|
||||
// realm so multi-level prefix commands stay ASCII. The switch is flag-gated but the restore
|
||||
// always fires on exit, so a mid-interaction flag toggle can't strand the host on ASCII.
|
||||
let active = match (
|
||||
previous_mode.wants_ascii_input(),
|
||||
self.state.mode.wants_ascii_input(),
|
||||
) {
|
||||
(false, true) if self.state.switch_ascii_input_source_in_prefix => {
|
||||
self.prefix_input_source.switch_to_ascii();
|
||||
}
|
||||
(true, false) => self.prefix_input_source.restore(),
|
||||
_ => {}
|
||||
(false, true) if self.state.switch_ascii_input_source_in_prefix => true,
|
||||
(true, false) => false,
|
||||
_ => return,
|
||||
};
|
||||
if let Err(err) = self
|
||||
.event_tx
|
||||
.try_send(crate::events::AppEvent::PrefixInputSource { active })
|
||||
{
|
||||
tracing::warn!(active, %err, "failed to queue prefix input-source change");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1775,83 +1788,171 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drain the app event channel, returning the `active` flags of any emitted
|
||||
/// `PrefixInputSource` events (the host-local input-source intents).
|
||||
fn drained_prefix_active(app: &mut App) -> Vec<bool> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(ev) = app.event_rx.try_recv() {
|
||||
if let crate::events::AppEvent::PrefixInputSource { active } = ev {
|
||||
out.push(active);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_prefix_input_source_switches_then_restores_when_enabled() {
|
||||
fn sync_prefix_input_source_emits_switch_then_restore_when_enabled() {
|
||||
let mut app = test_app();
|
||||
app.state.switch_ascii_input_source_in_prefix = true;
|
||||
|
||||
// Terminal -> Prefix emits the ASCII-switch intent.
|
||||
app.state.mode = Mode::Prefix;
|
||||
app.sync_prefix_input_source(Mode::Terminal);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![true]);
|
||||
|
||||
// Prefix -> Terminal emits the restore intent.
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.sync_prefix_input_source(Mode::Prefix);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_prefix_input_source_does_not_emit_switch_when_flag_disabled() {
|
||||
let mut app = test_app();
|
||||
app.state.switch_ascii_input_source_in_prefix = false;
|
||||
|
||||
// Entering the realm with the flag off emits nothing.
|
||||
app.state.mode = Mode::Prefix;
|
||||
app.sync_prefix_input_source(Mode::Terminal);
|
||||
assert!(drained_prefix_active(&mut app).is_empty());
|
||||
|
||||
// Leaving the realm still emits the restore (harmless if nothing was switched), so a
|
||||
// mid-interaction flag toggle can't strand the host on ASCII.
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.sync_prefix_input_source(Mode::Prefix);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_wants_ascii_input_classification() {
|
||||
// Allowlist: the prefix command/navigation realm wants ASCII.
|
||||
for mode in [
|
||||
Mode::Prefix,
|
||||
Mode::Navigate,
|
||||
Mode::Navigator,
|
||||
Mode::Copy,
|
||||
Mode::Resize,
|
||||
Mode::ConfirmClose,
|
||||
Mode::ConfirmRemoveWorktree,
|
||||
Mode::ContextMenu,
|
||||
Mode::GlobalMenu,
|
||||
Mode::KeybindHelp,
|
||||
] {
|
||||
assert!(mode.wants_ascii_input(), "{mode:?} should want ASCII");
|
||||
}
|
||||
// Everything else (terminal, text entry, startup overlays) keeps the user's IME.
|
||||
for mode in [
|
||||
Mode::Terminal,
|
||||
Mode::RenameWorkspace,
|
||||
Mode::RenameTab,
|
||||
Mode::RenamePane,
|
||||
Mode::NewLinkedWorktree,
|
||||
Mode::OpenExistingWorktree,
|
||||
Mode::Settings,
|
||||
Mode::Onboarding,
|
||||
Mode::ReleaseNotes,
|
||||
Mode::ProductAnnouncement,
|
||||
] {
|
||||
assert!(!mode.wants_ascii_input(), "{mode:?} should keep the IME");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_prefix_input_source_keeps_realm_across_multi_level_prefix_commands() {
|
||||
let mut app = test_app();
|
||||
app.state.switch_ascii_input_source_in_prefix = true;
|
||||
|
||||
// Terminal -> Prefix switches once.
|
||||
app.state.mode = Mode::Prefix;
|
||||
app.sync_prefix_input_source(Mode::Terminal);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![true]);
|
||||
|
||||
// Prefix -> sub-mode and sub-mode -> sub-mode stay in the realm: no emit.
|
||||
app.state.mode = Mode::Navigator;
|
||||
app.sync_prefix_input_source(Mode::Prefix);
|
||||
app.state.mode = Mode::Resize;
|
||||
app.sync_prefix_input_source(Mode::Navigator);
|
||||
assert!(
|
||||
drained_prefix_active(&mut app).is_empty(),
|
||||
"must not switch or restore while still in the realm"
|
||||
);
|
||||
|
||||
// Leaving the realm back to the terminal restores.
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.sync_prefix_input_source(Mode::Resize);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_prefix_input_source_restores_when_entering_rename_text_mode() {
|
||||
let mut app = test_app();
|
||||
app.state.switch_ascii_input_source_in_prefix = true;
|
||||
|
||||
app.state.mode = Mode::Prefix;
|
||||
app.sync_prefix_input_source(Mode::Terminal);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![true]);
|
||||
|
||||
// Prefix -> RenameTab leaves the realm (text entry wants the IME): restore.
|
||||
app.state.mode = Mode::RenameTab;
|
||||
app.sync_prefix_input_source(Mode::Prefix);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_internal_event_prefix_input_source_applies_switch_and_restore() {
|
||||
// The monolithic (in-process) path applies the host switch when it consumes the event.
|
||||
let mut app = test_app();
|
||||
let fake = FakePrefixInputSource::switching();
|
||||
let switch_calls = fake.switch_calls.clone();
|
||||
let restore_calls = fake.restore_calls.clone();
|
||||
app.set_prefix_input_source(Box::new(fake));
|
||||
|
||||
// Terminal -> Prefix should switch to ASCII.
|
||||
app.state.mode = Mode::Prefix;
|
||||
app.sync_prefix_input_source(Mode::Terminal);
|
||||
app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true });
|
||||
assert_eq!(switch_calls.get(), 1);
|
||||
assert_eq!(restore_calls.get(), 0);
|
||||
|
||||
// Prefix -> Terminal should restore the saved source.
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.sync_prefix_input_source(Mode::Prefix);
|
||||
assert_eq!(switch_calls.get(), 1);
|
||||
app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false });
|
||||
assert_eq!(restore_calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_prefix_input_source_is_noop_when_flag_disabled() {
|
||||
fn handle_internal_event_prefix_input_source_restore_is_safe_when_switch_was_noop() {
|
||||
// Already-ASCII / failed-switch case: the restore on leave must stay harmless.
|
||||
let mut app = test_app();
|
||||
app.state.switch_ascii_input_source_in_prefix = false;
|
||||
let fake = FakePrefixInputSource::switching();
|
||||
let switch_calls = fake.switch_calls.clone();
|
||||
let restore_calls = fake.restore_calls.clone();
|
||||
app.set_prefix_input_source(Box::new(fake));
|
||||
|
||||
app.state.mode = Mode::Prefix;
|
||||
app.sync_prefix_input_source(Mode::Terminal);
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.sync_prefix_input_source(Mode::Prefix);
|
||||
|
||||
assert_eq!(switch_calls.get(), 0);
|
||||
assert_eq!(restore_calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_prefix_input_source_restore_is_safe_when_switch_was_noop() {
|
||||
// Simulates the already-ASCII / failed-switch case: switch reports no
|
||||
// change, and the later restore on leave must stay harmless.
|
||||
let mut app = test_app();
|
||||
app.state.switch_ascii_input_source_in_prefix = true;
|
||||
let fake = FakePrefixInputSource::no_op();
|
||||
let switch_calls = fake.switch_calls.clone();
|
||||
let restore_calls = fake.restore_calls.clone();
|
||||
app.set_prefix_input_source(Box::new(fake));
|
||||
|
||||
app.state.mode = Mode::Prefix;
|
||||
app.sync_prefix_input_source(Mode::Terminal);
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.sync_prefix_input_source(Mode::Prefix);
|
||||
|
||||
app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true });
|
||||
app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false });
|
||||
assert_eq!(switch_calls.get(), 1);
|
||||
assert_eq!(restore_calls.get(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_input_dispatch_restores_input_source_when_leaving_prefix() {
|
||||
// Leaving prefix mode happens inside the raw-input dispatch, not in
|
||||
// `handle_key` itself — the sync must sit at the dispatch layer so any
|
||||
// event that exits prefix (here Esc) still restores the host source.
|
||||
async fn raw_input_dispatch_emits_input_source_intent_when_leaving_prefix() {
|
||||
// Leaving prefix mode happens inside the raw-input dispatch, not in `handle_key` itself —
|
||||
// the sync must sit at the dispatch layer so any event that exits prefix (here Esc) still
|
||||
// emits the restore intent.
|
||||
let mut app = test_app();
|
||||
app.state.switch_ascii_input_source_in_prefix = true;
|
||||
app.state.workspaces = vec![Workspace::test_new("test")];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
let fake = FakePrefixInputSource::switching();
|
||||
let switch_calls = fake.switch_calls.clone();
|
||||
let restore_calls = fake.restore_calls.clone();
|
||||
app.set_prefix_input_source(Box::new(fake));
|
||||
|
||||
// ctrl+b (the default prefix key) enters prefix mode → switch edge.
|
||||
// ctrl+b (the default prefix key) enters prefix mode → switch intent.
|
||||
app.handle_raw_input_event(raw_key(
|
||||
KeyCode::Char('b'),
|
||||
KeyModifiers::CONTROL,
|
||||
|
|
@ -1859,11 +1960,9 @@ mod tests {
|
|||
))
|
||||
.await;
|
||||
assert_eq!(app.state.mode, Mode::Prefix);
|
||||
assert_eq!(switch_calls.get(), 1);
|
||||
assert_eq!(restore_calls.get(), 0);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![true]);
|
||||
|
||||
// Esc leaves prefix mode → restore edge, even though the exit is decided
|
||||
// below `handle_key`.
|
||||
// Esc leaves prefix mode → restore intent.
|
||||
app.handle_raw_input_event(raw_key(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::empty(),
|
||||
|
|
@ -1871,7 +1970,7 @@ mod tests {
|
|||
))
|
||||
.await;
|
||||
assert_eq!(app.state.mode, Mode::Terminal);
|
||||
assert_eq!(restore_calls.get(), 1);
|
||||
assert_eq!(drained_prefix_active(&mut app), vec![false]);
|
||||
}
|
||||
|
||||
fn config_env_lock() -> &'static Mutex<()> {
|
||||
|
|
|
|||
|
|
@ -768,6 +768,33 @@ pub enum Mode {
|
|||
Navigator,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// Whether keys in this mode are commands/navigation (an ASCII input source is wanted) rather
|
||||
/// than free text. This is an explicit **allowlist** of the prefix command/navigation realm:
|
||||
/// any mode NOT listed defaults to leaving the user's IME alone (the safe default), so adding a
|
||||
/// new text-entry or overlay mode can never silently force ASCII. Used by
|
||||
/// `sync_prefix_input_source` (gated by `switch_ascii_input_source_in_prefix`) so multi-level
|
||||
/// prefix commands keep ASCII until they return to the terminal.
|
||||
///
|
||||
/// Known limitation: `Navigator`'s search box is also held on ASCII, since this `Mode`-level
|
||||
/// predicate can't see `search_focused` (non-ASCII filtering there would need a runtime check).
|
||||
pub(crate) fn wants_ascii_input(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Mode::Prefix
|
||||
| Mode::Navigate
|
||||
| Mode::Navigator
|
||||
| Mode::Copy
|
||||
| Mode::Resize
|
||||
| Mode::ConfirmClose
|
||||
| Mode::ConfirmRemoveWorktree
|
||||
| Mode::ContextMenu
|
||||
| Mode::GlobalMenu
|
||||
| Mode::KeybindHelp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum NavigatorTarget {
|
||||
Workspace {
|
||||
|
|
|
|||
|
|
@ -1354,6 +1354,10 @@ async fn run_client_loop(
|
|||
.set_nonblocking(false)
|
||||
.map_err(ClientError::ConnectionFailed)?;
|
||||
|
||||
// This (foreground) client owns the prefix ASCII input-source switch; a no-op on non-macOS.
|
||||
use crate::platform::PrefixInputSource;
|
||||
let mut prefix_input_source = crate::platform::RealPrefixInputSource::default();
|
||||
|
||||
// Main event loop.
|
||||
while !should_quit.load(Ordering::Acquire) {
|
||||
let event = tokio::select! {
|
||||
|
|
@ -1560,6 +1564,13 @@ async fn run_client_loop(
|
|||
host_mouse_capture_active.store(desired, Ordering::Release);
|
||||
}
|
||||
}
|
||||
ServerMessage::PrefixInputSource { active } => {
|
||||
if active {
|
||||
prefix_input_source.switch_to_ascii();
|
||||
} else {
|
||||
prefix_input_source.restore();
|
||||
}
|
||||
}
|
||||
ServerMessage::Welcome { .. } => {
|
||||
debug!("received unexpected Welcome in main loop");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,11 @@ pub enum AppEvent {
|
|||
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
|
||||
/// re-emits it through herdr's own clipboard writer.
|
||||
ClipboardWrite { content: Vec<u8> },
|
||||
/// Prefix-mode ASCII input-source request, emitted on entering/leaving the ASCII input
|
||||
/// realm. The foreground process applies the host-local TIS switch (`active = true`) /
|
||||
/// restore (`active = false`): the client in server mode (via server forwarding), the
|
||||
/// app itself in monolithic mode.
|
||||
PrefixInputSource { active: bool },
|
||||
/// A pane child reported its shell current directory through terminal
|
||||
/// metadata such as OSC 7.
|
||||
TerminalCwdReported {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,34 @@ extern "C" {
|
|||
buffer_size: CfIndex,
|
||||
encoding: u32,
|
||||
) -> Boolean;
|
||||
|
||||
#[link_name = "kCFRunLoopDefaultMode"]
|
||||
static CF_RUN_LOOP_DEFAULT_MODE: CfStringRef;
|
||||
|
||||
#[link_name = "CFRunLoopRunInMode"]
|
||||
fn cf_run_loop_run_in_mode(
|
||||
mode: CfStringRef,
|
||||
seconds: f64,
|
||||
return_after_source_handled: Boolean,
|
||||
) -> libc::c_int;
|
||||
}
|
||||
|
||||
/// Pump the main thread's run loop once (non-blocking) so the process receives the
|
||||
/// `kTISNotifySelectedKeyboardInputSourceChanged` notification and refreshes the per-process cache
|
||||
/// that `TISCopyCurrentKeyboardInputSource` reads. That notification arrives only via the main
|
||||
/// thread's run loop, so a process that never runs a CFRunLoop (the headless server) reads a stale
|
||||
/// source. Must run on the main thread.
|
||||
pub(crate) fn pump_input_source_runloop() {
|
||||
debug_assert!(
|
||||
// SAFETY: `pthread_main_np` is always safe to call.
|
||||
unsafe { libc::pthread_main_np() } != 0,
|
||||
"pump_input_source_runloop must run on the main thread"
|
||||
);
|
||||
// SAFETY: `CFRunLoopRunInMode` is thread-safe; a 0-second call drains the ready sources and
|
||||
// returns immediately (no blocking). `CF_RUN_LOOP_DEFAULT_MODE` is a framework-owned constant.
|
||||
unsafe {
|
||||
let _ = cf_run_loop_run_in_mode(CF_RUN_LOOP_DEFAULT_MODE, 0.0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
|
|||
|
|
@ -154,6 +154,9 @@ pub(crate) fn switch_to_ascii_input_source() -> Option<InputSourceRestore> {
|
|||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn pump_input_source_runloop() {}
|
||||
|
||||
/// Switches the host keyboard input source while prefix mode is active.
|
||||
///
|
||||
/// `App` drives this through a trait so the prefix-mode transitions can be
|
||||
|
|
@ -179,6 +182,9 @@ pub(crate) struct RealPrefixInputSource {
|
|||
impl PrefixInputSource for RealPrefixInputSource {
|
||||
fn switch_to_ascii(&mut self) {
|
||||
if self.restore.is_none() {
|
||||
// Drain pending input-source-change notifications so the read below is fresh (see
|
||||
// `pump_input_source_runloop`); a no-op on non-macOS.
|
||||
pump_input_source_runloop();
|
||||
self.restore = switch_to_ascii_input_source();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current protocol version. Bumped when wire format changes incompatibly.
|
||||
pub const PROTOCOL_VERSION: u32 = 15;
|
||||
pub const PROTOCOL_VERSION: u32 = 16;
|
||||
|
||||
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
|
||||
/// rejected to prevent denial-of-service via oversized length prefixes.
|
||||
|
|
@ -656,6 +656,14 @@ pub enum ServerMessage {
|
|||
/// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting.
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Apply the prefix-mode ASCII input-source change on the foreground client.
|
||||
/// `active = true` → switch to an ASCII-capable source (saving the current one);
|
||||
/// `active = false` → restore the saved source.
|
||||
PrefixInputSource {
|
||||
/// Whether the ASCII input source should be active.
|
||||
active: bool,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1404,6 +1412,17 @@ mod tests {
|
|||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_prefix_input_source_roundtrip() {
|
||||
for active in [true, false] {
|
||||
let msg = ServerMessage::PrefixInputSource { active };
|
||||
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
|
||||
let (decoded, _): (ServerMessage, _) =
|
||||
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Framing ----
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1842,6 +1842,14 @@ impl HeadlessServer {
|
|||
}
|
||||
true
|
||||
}
|
||||
AppEvent::PrefixInputSource { active } => {
|
||||
// Input-source switching is a client-local host side effect; forward it to the
|
||||
// foreground client (which owns the real TIS switch + run-loop pump), like clipboard.
|
||||
self.send_to_foreground_client(ServerMessage::PrefixInputSource {
|
||||
active: *active,
|
||||
});
|
||||
true
|
||||
}
|
||||
AppEvent::StateChanged { pane_id, agent, .. } => {
|
||||
// Capture toast before handling.
|
||||
let toast_before = self.app.state.toast.clone();
|
||||
|
|
@ -3953,8 +3961,11 @@ pub fn run_server() -> io::Result<()> {
|
|||
// The server runs headless — disable local notification side effects.
|
||||
// Sound and terminal notifications are forwarded to connected clients
|
||||
// as ServerMessage::Notify instead of emitted by the server process.
|
||||
// The prefix input-source switch is likewise forwarded to the foreground
|
||||
// client (ServerMessage::PrefixInputSource), never applied in-process.
|
||||
app.state.local_sound_playback = false;
|
||||
app.local_terminal_notifications = false;
|
||||
app.local_input_source_switch = false;
|
||||
|
||||
// Create the headless server.
|
||||
let mut server = match HeadlessServer::new(
|
||||
|
|
@ -4054,6 +4065,7 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()>
|
|||
)?;
|
||||
app.state.local_sound_playback = false;
|
||||
app.local_terminal_notifications = false;
|
||||
app.local_input_source_switch = false;
|
||||
crate::server::handoff::report_restored(&mut received.stream)?;
|
||||
if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") {
|
||||
return Err(io::Error::other(
|
||||
|
|
@ -4151,6 +4163,7 @@ mod tests {
|
|||
let mut app = crate::app::App::new(&config, true, None, api_rx, event_hub);
|
||||
app.state.local_sound_playback = false;
|
||||
app.local_terminal_notifications = false;
|
||||
app.local_input_source_switch = false;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"hh-{}-{}",
|
||||
|
|
@ -7744,6 +7757,107 @@ next_tab = ""
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_input_source_targets_foreground_client_only() {
|
||||
let mut server = test_headless_server();
|
||||
let (background_tx, background_control_rx, _background_rx) = test_client_writer();
|
||||
let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
|
||||
|
||||
server.clients.insert(
|
||||
1,
|
||||
ClientConnection::new(
|
||||
(120, 40),
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
None,
|
||||
1,
|
||||
RenderEncoding::SemanticFrame,
|
||||
Some(background_tx),
|
||||
),
|
||||
);
|
||||
server.clients.insert(
|
||||
2,
|
||||
ClientConnection::new(
|
||||
(80, 24),
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
None,
|
||||
2,
|
||||
RenderEncoding::SemanticFrame,
|
||||
Some(foreground_tx),
|
||||
),
|
||||
);
|
||||
server.foreground_client_id = Some(2);
|
||||
server.sync_foreground_client_state();
|
||||
// Drain any setup messages (e.g. mouse-capture sync) before exercising the event.
|
||||
while foreground_control_rx
|
||||
.recv_timeout(Duration::from_millis(20))
|
||||
.is_ok()
|
||||
{}
|
||||
|
||||
let changed = server
|
||||
.handle_internal_event_with_forwarding(AppEvent::PrefixInputSource { active: true });
|
||||
|
||||
assert!(changed);
|
||||
match read_server_message(
|
||||
foreground_control_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("foreground prefix input-source message"),
|
||||
) {
|
||||
ServerMessage::PrefixInputSource { active } => assert!(active),
|
||||
other => panic!("expected prefix input-source message, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
background_control_rx
|
||||
.recv_timeout(Duration::from_millis(50))
|
||||
.is_err(),
|
||||
"background client should not receive prefix input-source changes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_app_keeps_prefix_input_source_switch_off_process() {
|
||||
// An App-internal drain (e.g. the exhaustive drain at the top of
|
||||
// handle_api_request) can consume a queued PrefixInputSource intent
|
||||
// before the forwarding drain sees it. The headless App must treat the
|
||||
// event as inert instead of switching the host input source from the
|
||||
// server process.
|
||||
struct CountingPrefixInputSource(std::rc::Rc<std::cell::Cell<usize>>);
|
||||
impl crate::platform::PrefixInputSource for CountingPrefixInputSource {
|
||||
fn switch_to_ascii(&mut self) {
|
||||
self.0.set(self.0.get() + 1);
|
||||
}
|
||||
fn restore(&mut self) {
|
||||
self.0.set(self.0.get() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
let mut server = test_headless_server();
|
||||
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
server
|
||||
.app
|
||||
.set_prefix_input_source(Box::new(CountingPrefixInputSource(calls.clone())));
|
||||
|
||||
server
|
||||
.app
|
||||
.handle_internal_event(AppEvent::PrefixInputSource { active: true });
|
||||
server
|
||||
.app
|
||||
.handle_internal_event(AppEvent::PrefixInputSource { active: false });
|
||||
assert_eq!(
|
||||
calls.get(),
|
||||
0,
|
||||
"headless server must not apply the host input-source switch"
|
||||
);
|
||||
|
||||
// Sanity: the same event does apply once the flag is on (monolithic semantics).
|
||||
server.app.local_input_source_switch = true;
|
||||
server
|
||||
.app
|
||||
.handle_internal_event(AppEvent::PrefixInputSource { active: true });
|
||||
assert_eq!(calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_local_notifications_target_foreground_client_only() {
|
||||
let mut server = test_headless_server();
|
||||
|
|
@ -8470,7 +8584,8 @@ next_tab = ""
|
|||
}
|
||||
|
||||
/// Verify that no direct calls to `self.app.handle_internal_event`
|
||||
/// exist outside of `handle_internal_event_with_forwarding` in this
|
||||
/// (or its `handle_internal_event_with_prefix_sync` wrapper) exist
|
||||
/// outside of `handle_internal_event_with_forwarding` in this
|
||||
/// module. This ensures the forwarding bypass cannot be reintroduced.
|
||||
///
|
||||
/// The search pattern looks for `handle_internal_event` calls that
|
||||
|
|
@ -8508,7 +8623,8 @@ next_tab = ""
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
} else if line.contains("self.app.handle_internal_event(")
|
||||
} else if (line.contains("self.app.handle_internal_event(")
|
||||
|| line.contains("self.app.handle_internal_event_with_prefix_sync("))
|
||||
&& !line.trim().starts_with("///")
|
||||
&& !line.contains("contains(")
|
||||
{
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() {
|
|||
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
// Intentionally hardcoded so wire protocol bumps require updating this test.
|
||||
// Changing this value means old clients/servers are no longer compatible.
|
||||
assert_eq!(value["result"]["protocol"], 15);
|
||||
assert_eq!(value["result"]["protocol"], 16);
|
||||
|
||||
cleanup_spawned_herdr(child, base);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1620,7 +1620,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {full_stdout}"
|
||||
);
|
||||
assert!(
|
||||
full_stdout.contains(" protocol: 15"),
|
||||
full_stdout.contains(" protocol: 16"),
|
||||
"stdout: {full_stdout}"
|
||||
);
|
||||
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
|
||||
|
|
@ -1653,7 +1653,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {server_stdout}"
|
||||
);
|
||||
assert!(
|
||||
server_stdout.contains("protocol: 15"),
|
||||
server_stdout.contains("protocol: 16"),
|
||||
"stdout: {server_stdout}"
|
||||
);
|
||||
|
||||
|
|
@ -1665,7 +1665,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {client_stdout}"
|
||||
);
|
||||
assert!(
|
||||
client_stdout.contains("protocol: 15"),
|
||||
client_stdout.contains("protocol: 16"),
|
||||
"stdout: {client_stdout}"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -1675,7 +1675,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
|
||||
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
|
||||
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(full_json["client"]["protocol"], 15);
|
||||
assert_eq!(full_json["client"]["protocol"], 16);
|
||||
assert_eq!(full_json["server"]["status"], "running");
|
||||
assert_eq!(full_json["server"]["running"], true);
|
||||
assert_eq!(full_json["server"]["compatible"], true);
|
||||
|
|
@ -1689,12 +1689,12 @@ fn status_commands_report_client_and_server_versions() {
|
|||
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
|
||||
assert_eq!(server_json["status"], "running");
|
||||
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(server_json["protocol"], 15);
|
||||
assert_eq!(server_json["protocol"], 16);
|
||||
assert_eq!(server_json["compatible"], true);
|
||||
|
||||
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
|
||||
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(client_json["protocol"], 15);
|
||||
assert_eq!(client_json["protocol"], 16);
|
||||
assert!(client_json["binary"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.is_empty()));
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ static INIT: Once = Once::new();
|
|||
static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new();
|
||||
const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid";
|
||||
pub const CURRENT_PROTOCOL: u32 = 15;
|
||||
pub const CURRENT_PROTOCOL: u32 = 16;
|
||||
|
||||
pub fn register_spawned_herdr_pid(pid: Option<u32>) {
|
||||
let Some(pid) = pid else {
|
||||
|
|
|
|||
Loading…
Reference in New Issue