diff --git a/cfg_samples/kanata-input-source-helper.plist b/cfg_samples/kanata-input-source-helper.plist new file mode 100644 index 000000000..32f78495d --- /dev/null +++ b/cfg_samples/kanata-input-source-helper.plist @@ -0,0 +1,56 @@ + + + + + + Label + dev.kanata.input-source-helper + + ProgramArguments + + /opt/homebrew/bin/kanata-input-source-helper + + + LimitLoadToSessionType + Aqua + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + StandardOutPath + /tmp/kanata-input-source-helper.log + + StandardErrorPath + /tmp/kanata-input-source-helper.log + + diff --git a/cfg_samples/macos-input-source.kbd b/cfg_samples/macos-input-source.kbd new file mode 100644 index 000000000..d494ca823 --- /dev/null +++ b/cfg_samples/macos-input-source.kbd @@ -0,0 +1,19 @@ +(defcfg + process-unmapped-keys yes +) + +(defsrc + ralt +) + +(defalias + us (set-input-source "com.apple.keylayout.US") + ru (set-input-source "com.apple.keylayout.RussianWin") + toggle-ru-us (switch + ((input-source-is "com.apple.keylayout.US")) @ru break + () @us break) +) + +(deflayer base + @toggle-ru-us +) diff --git a/docs/config.adoc b/docs/config.adoc index c45fcffc8..acfd367b3 100644 --- a/docs/config.adoc +++ b/docs/config.adoc @@ -958,6 +958,35 @@ When activated, the underlying `defsrc` key will be the output action. _ _ d @src) ---- +[[set-input-source]] +=== set-input-source + +**Reference** + +[cols="1,6"] +|=== +| `(set-input-source $input-source-id)` +| macOS-only action that switches to the exact keyboard input source ID. +|=== + +**Description** + +This action uses native macOS Text Input Source Services to select an installed +keyboard input source by its exact ID. + +On macOS, input-source actions are executed through the +`kanata-input-source-helper` process. Install it as the per-user LaunchAgent +documented in `docs/setup-macos.md` when using this action. + +.Example: +[source] +---- +(defalias us (set-input-source "com.apple.keylayout.US")) +---- + +This is only supported on macOS. Other platforms reject this action during +configuration validation. + [[no-op]] === No-op @@ -2915,6 +2944,7 @@ if any of the items evaluates to true. (input-history $input-type $key-name $input-recency) (layer $layer-name) (base-layer $layer-name) +(input-source-is $input-source-id) ---- [cols="1,4"] @@ -2958,6 +2988,10 @@ The max recency is 8. | `base-layer` | Evaluates to true if the most-recently-switched-to layer from a `layer-switch` action matches `$layer-name`. + +| `input-source-is` +| macOS-only. Evaluates to true if the current keyboard input source ID exactly +matches `$input-source-id`, for example `com.apple.keylayout.US`. |=== **Description** @@ -3221,6 +3255,25 @@ if `layer-switch` has never been activated. ) ---- +==== input-source-is + +The `input-source-is` list item is macOS-only and checks the current keyboard +input source ID using native macOS APIs. + +On macOS, this check is executed through the `kanata-input-source-helper` +process so it observes the logged-in user's input-source session. Install the +per-user LaunchAgent documented in `docs/setup-macos.md` when using this +condition. + +.Example: +[source] +---- +(defalias russian-if-active + (switch + ((input-source-is "com.apple.keylayout.RussianWin")) r break + () _ break)) +---- + [[cmd]] === cmd diff --git a/docs/design.md b/docs/design.md index 3a56a1dff..2a8ab1884 100644 --- a/docs/design.md +++ b/docs/design.md @@ -43,3 +43,10 @@ Most of the OS specific code is in `oskbd/` and `keys/`. There's a bit of it in `kanata/` since the event loops to receive OS events are different. + +macOS input-source switching is intentionally split out into +`kanata-input-source-helper`, a per-user LaunchAgent. Kanata can run as root for +keyboard grabbing, but macOS Text Input Source Services state belongs to the +logged-in Aqua user session. The main Kanata process sends small local IPC +requests to the helper for getting or setting the current input source; the +helper performs the native TIS calls in the user's session. diff --git a/docs/setup-macos.md b/docs/setup-macos.md index 70ed3df72..a620c95bd 100644 --- a/docs/setup-macos.md +++ b/docs/setup-macos.md @@ -130,7 +130,56 @@ After editing your kanata config (or the plist itself), reload with: sudo launchctl kickstart -k system/dev.kanata.kanata ``` -### 7. Uninstall +### 7. (Optional) Install the input-source helper as a LaunchAgent + +If your config uses `(set-input-source "...")` or `(input-source-is "...")`, +install the macOS input-source helper as a per-user LaunchAgent. The helper +must run as the logged-in Aqua console user so macOS Text Input Source Services +sees the same enabled input sources as the menu bar and real typing behavior. + +First install the helper binary somewhere stable: + +```sh +cargo build --release --bin kanata-input-source-helper +sudo mkdir -p /opt/homebrew/bin +sudo cp target/release/kanata-input-source-helper /opt/homebrew/bin/kanata-input-source-helper +sudo chmod 755 /opt/homebrew/bin/kanata-input-source-helper +``` + +If you install the helper somewhere else, edit `ProgramArguments` in +[`cfg_samples/kanata-input-source-helper.plist`](../cfg_samples/kanata-input-source-helper.plist) +before loading it. + +Install and start the LaunchAgent: + +```sh +mkdir -p ~/Library/LaunchAgents +cp cfg_samples/kanata-input-source-helper.plist ~/Library/LaunchAgents/dev.kanata.input-source-helper.plist +chmod 644 ~/Library/LaunchAgents/dev.kanata.input-source-helper.plist +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.kanata.input-source-helper.plist +``` + +Verify it is running: + +```sh +launchctl print gui/$(id -u)/dev.kanata.input-source-helper +``` + +Logs are written to `/tmp/kanata-input-source-helper.log`. + +After editing the helper plist or replacing the helper binary, reload with: + +```sh +launchctl kickstart -k gui/$(id -u)/dev.kanata.input-source-helper +``` + +This is intentionally a LaunchAgent, not a LaunchDaemon. Kanata itself may run +as root for keyboard grabbing, but input-source selection is user-session +state. Running the helper as a root LaunchDaemon can make TIS report layouts as +installed but disabled even though the active user's macOS UI shows them as +enabled. + +### 8. Uninstall Remove the LaunchDaemon (if you installed it): @@ -139,10 +188,19 @@ sudo launchctl bootout system/dev.kanata.kanata sudo rm /Library/LaunchDaemons/dev.kanata.kanata.plist ``` +Remove the input-source LaunchAgent (if you installed it): + +```sh +launchctl bootout gui/$(id -u)/dev.kanata.input-source-helper +rm ~/Library/LaunchAgents/dev.kanata.input-source-helper.plist +rm -f /tmp/kanata-input-source-helper.log +``` + Remove the kanata binary: ```sh sudo rm /usr/local/bin/kanata +sudo rm /opt/homebrew/bin/kanata-input-source-helper ``` If you are also fully removing the Karabiner driver: diff --git a/keyberon/src/action/switch.rs b/keyberon/src/action/switch.rs index 7d438d35d..bc60d1cbd 100644 --- a/keyberon/src/action/switch.rs +++ b/keyberon/src/action/switch.rs @@ -33,6 +33,7 @@ pub type Case<'a, T> = (&'a [OpCode], &'a Action<'a, T>, BreakOrFallthrough); /// - whether to break or fallthrough to the next case if the expression evaluates to true pub struct Switch<'a, T: 'a> { pub cases: &'a [Case<'a, T>], + pub custom_conditions: &'a [T], } // NOTE: have exhausted our opcodes for u16! @@ -49,6 +50,7 @@ const INPUT_VAL: u16 = 851; const HISTORICAL_INPUT_VAL: u16 = 852; const LAYER_VAL: u16 = 853; const BASE_LAYER_VAL: u16 = 854; +const CUSTOM_CONDITION_VAL: u16 = 855; // Binary values: // 0b0100 ... @@ -87,6 +89,7 @@ enum OpCodeType { TicksSinceGreaterThan(TicksSinceNthKey), Layer(u16), BaseLayer(u16), + CustomCondition(u16), } #[derive(Debug, Copy, Clone, PartialEq, Eq)] @@ -139,6 +142,7 @@ impl<'a, T> Switch<'a, T> { historical_positions: H2, layers: L, default_layer: u16, + custom_condition_evaluator: fn(&T) -> bool, ) -> SwitchActions<'a, T, A1, A2, H1, H2, L> where A1: Iterator + Clone, @@ -149,12 +153,14 @@ impl<'a, T> Switch<'a, T> { { SwitchActions { cases: self.cases, + custom_conditions: self.custom_conditions, active_keys, active_positions, historical_keys, historical_positions, layers, default_layer, + custom_condition_evaluator, case_index: 0, } } @@ -171,12 +177,14 @@ where L: Iterator + Clone, { cases: &'a [(&'a [OpCode], &'a Action<'a, T>, BreakOrFallthrough)], + custom_conditions: &'a [T], active_keys: A1, active_positions: A2, historical_keys: H1, historical_positions: H2, layers: L, default_layer: u16, + custom_condition_evaluator: fn(&T) -> bool, case_index: usize, } @@ -201,6 +209,8 @@ where self.historical_positions.clone(), self.layers.clone(), self.default_layer, + self.custom_conditions, + self.custom_condition_evaluator, ) { let ret_ac = case.1; match case.2 { @@ -315,6 +325,11 @@ impl OpCode { (Self(BASE_LAYER_VAL), Self(base_layer)) } + /// Return OpCodes specifying a custom condition check. + pub fn new_custom_condition(condition_index: u16) -> (Self, Self) { + (Self(CUSTOM_CONDITION_VAL), Self(condition_index)) + } + /// Return the interpretation of this `OpCode`. fn opcode_type(self, next: Option) -> OpCodeType { if self.0 < KEY_MAX { @@ -329,6 +344,7 @@ impl OpCode { }), LAYER_VAL => OpCodeType::Layer(op2.0), BASE_LAYER_VAL => OpCodeType::BaseLayer(op2.0), + CUSTOM_CONDITION_VAL => OpCodeType::CustomCondition(op2.0), _ => unreachable!("unexpected opcode {self:?}"), } } else { @@ -366,7 +382,7 @@ impl From for OperatorAndEndIndex { } /// Evaluate the return value of an expression evaluated on the given key codes. -fn evaluate_boolean( +fn evaluate_boolean( bool_expr: &[OpCode], key_codes: impl Iterator + Clone, inputs: impl Iterator + Clone, @@ -374,6 +390,8 @@ fn evaluate_boolean( historical_inputs: impl Iterator> + Clone, layers: impl Iterator + Clone, default_layer: u16, + custom_conditions: &[T], + custom_condition_evaluator: fn(&T) -> bool, ) -> bool { let mut ret = true; let mut current_index = 0; @@ -465,6 +483,14 @@ fn evaluate_boolean( current_index += 1; ret = default_layer == base_layer; } + OpCodeType::CustomCondition(condition_index) => { + // opcode has size 2 + current_index += 1; + ret = custom_conditions + .get(usize::from(condition_index)) + .map(custom_condition_evaluator) + .unwrap_or(false); + } }; if current_op == Not { ret = !ret; @@ -483,6 +509,11 @@ fn evaluate_boolean( ret } +#[cfg(test)] +fn no_custom_condition(_: &()) -> bool { + false +} + #[cfg(test)] fn evaluate_bool_test(opcodes: &[OpCode], keycodes: impl Iterator + Clone) -> bool { evaluate_boolean( @@ -493,6 +524,8 @@ fn evaluate_bool_test(opcodes: &[OpCode], keycodes: impl Iterator::KeyCode(KeyCode::A), Fallthrough), (&[], &Action::<()>::KeyCode(KeyCode::B), Fallthrough), ], + custom_conditions: &[], }; let mut actions = sw.actions( [].iter().copied(), @@ -752,6 +786,7 @@ fn switch_fallthrough() { [].iter().copied(), [].iter().copied(), 0, + |_| false, ); assert_eq!(actions.next(), Some(&Action::<()>::KeyCode(KeyCode::A))); assert_eq!(actions.next(), Some(&Action::<()>::KeyCode(KeyCode::B))); @@ -765,6 +800,7 @@ fn switch_break() { (&[], &Action::<()>::KeyCode(KeyCode::A), Break), (&[], &Action::<()>::KeyCode(KeyCode::B), Break), ], + custom_conditions: &[], }; let mut actions = sw.actions( [].iter().copied(), @@ -773,6 +809,7 @@ fn switch_break() { [].iter().copied(), [].iter().copied(), 0, + |_| false, ); assert_eq!(actions.next(), Some(&Action::<()>::KeyCode(KeyCode::A))); assert_eq!(actions.next(), None); @@ -793,6 +830,26 @@ fn switch_no_actions() { Break, ), ], + custom_conditions: &[], + }; + let mut actions = sw.actions( + [].iter().copied(), + [].iter().copied(), + [].iter().copied(), + [].iter().copied(), + [].iter().copied(), + 0, + |_| false, + ); + assert_eq!(actions.next(), None); +} + +#[test] +fn switch_custom_condition() { + let (op1, op2) = OpCode::new_custom_condition(0); + let sw = Switch { + cases: &[(&[op1, op2], &Action::::KeyCode(KeyCode::A), Break)], + custom_conditions: &[true], }; let mut actions = sw.actions( [].iter().copied(), @@ -801,7 +858,9 @@ fn switch_no_actions() { [].iter().copied(), [].iter().copied(), 0, + |condition| *condition, ); + assert_eq!(actions.next(), Some(&Action::::KeyCode(KeyCode::A))); assert_eq!(actions.next(), None); } @@ -861,6 +920,8 @@ fn switch_historical_1() { [].iter().copied(), [].iter().copied(), 0, + &[] as &[()], + no_custom_condition, )); assert!(evaluate_boolean( opcode_true2.as_slice(), @@ -870,6 +931,8 @@ fn switch_historical_1() { [].iter().copied(), [].iter().copied(), 0, + &[] as &[()], + no_custom_condition, )); assert!(!evaluate_boolean( opcode_false.as_slice(), @@ -879,6 +942,8 @@ fn switch_historical_1() { [].iter().copied(), [].iter().copied(), 0, + &[] as &[()], + no_custom_condition, )); assert!(!evaluate_boolean( opcode_false2.as_slice(), @@ -888,6 +953,8 @@ fn switch_historical_1() { [].iter().copied(), [].iter().copied(), 0, + &[] as &[()], + no_custom_condition, )); } @@ -973,6 +1040,8 @@ fn switch_historical_bools() { [].iter().copied(), [].iter().copied(), 0, + &[] as &[()], + no_custom_condition, ), expectation ); @@ -1089,6 +1158,8 @@ fn switch_historical_ticks_since() { [].iter().copied(), [].iter().copied(), 0, + &[] as &[()], + no_custom_condition, ), expectation ); @@ -1323,6 +1394,8 @@ fn switch_inputs() { [].iter().copied(), [].iter().copied(), 0, + &[] as &[()], + no_custom_condition, ), expectation ); @@ -1391,6 +1464,8 @@ fn switch_historical_inputs() { historical_inputs.iter().copied(), [].iter().copied(), 0, + &[] as &[()], + no_custom_condition, ), expectation ); diff --git a/keyberon/src/layout.rs b/keyberon/src/layout.rs index b0dab95f8..a17473ac5 100644 --- a/keyberon/src/layout.rs +++ b/keyberon/src/layout.rs @@ -137,6 +137,7 @@ where trans_resolution_behavior_v2: bool, delegate_to_first_layer: bool, contextual_execution: ContextualExecution, + custom_condition_evaluator: fn(&T) -> bool, /// Tracks tap-hold activation events (hold/tap resolved). /// Only stores data when the `tap_hold_tracker` feature is enabled; /// otherwise this is a zero-sized no-op. @@ -1234,6 +1235,7 @@ impl<'a, const C: usize, const R: usize, T: 'a + Copy + std::fmt::Debug> Layout< delegate_to_first_layer: false, chords_v2: None, contextual_execution: ContextualExecution::new(), + custom_condition_evaluator: |_| false, tap_hold_tracker: Default::default(), } } @@ -1250,6 +1252,10 @@ impl<'a, const C: usize, const R: usize, T: 'a + Copy + std::fmt::Debug> Layout< new } + pub fn set_custom_condition_evaluator(&mut self, evaluator: fn(&T) -> bool) { + self.custom_condition_evaluator = evaluator; + } + /// Iterates on the key codes of the current state. pub fn keycodes(&self) -> impl Iterator + Clone + '_ { let keys_to_suppress_for_one_cycle = self.keys_to_suppress_for_one_cycle.clone(); @@ -2436,6 +2442,7 @@ impl<'a, const C: usize, const R: usize, T: 'a + Copy + std::fmt::Debug> Layout< // Note on truncating cast: I expect default layer to be in range by other // assertions. self.default_layer as u16, + self.custom_condition_evaluator, ) { action_queue.push_back(Some((coord, delay, ac, layer_stack.clone().collect()))); } @@ -5069,6 +5076,7 @@ mod test { NoOp, Switch(&switch::Switch { cases: &[(&[], &Trans, BreakOrFallthrough::Break)], + custom_conditions: &[], }), ]], ]; diff --git a/parser/src/cfg/chord_v1.rs b/parser/src/cfg/chord_v1.rs index 55c3bbceb..1c0a5fdff 100644 --- a/parser/src/cfg/chord_v1.rs +++ b/parser/src/cfg/chord_v1.rs @@ -223,7 +223,7 @@ pub(crate) fn find_chords_coords( find_chords_coords(chord_groups, coord, left); find_chords_coords(chord_groups, coord, right); } - Action::Switch(Switch { cases }) => { + Action::Switch(Switch { cases, .. }) => { for case in cases.iter() { find_chords_coords(chord_groups, coord, case.1); } @@ -327,7 +327,7 @@ pub(crate) fn fill_chords( None } } - Action::Switch(Switch { cases }) => { + Action::Switch(&sw @ Switch { cases, .. }) => { let mut new_cases = vec![]; for case in cases.iter() { new_cases.push(( @@ -340,6 +340,7 @@ pub(crate) fn fill_chords( } Some(Action::Switch(s.a.sref(Switch { cases: s.a.sref_vec(new_cases), + custom_conditions: sw.custom_conditions, }))) } } diff --git a/parser/src/cfg/input_source.rs b/parser/src/cfg/input_source.rs new file mode 100644 index 000000000..d9dd2a5c7 --- /dev/null +++ b/parser/src/cfg/input_source.rs @@ -0,0 +1,48 @@ +use super::*; + +use crate::bail; +#[cfg(target_os = "macos")] +use crate::{anyhow_expr, bail_expr}; + +pub(crate) fn parse_set_input_source( + ac_params: &[SExpr], + s: &ParserState, +) -> Result<&'static KanataAction> { + let id = parse_input_source_id(ac_params, SET_INPUT_SOURCE, s)?; + custom(CustomAction::SetInputSource(id), &s.a) +} + +pub(crate) fn parse_input_source_is(ac_params: &[SExpr], s: &ParserState) -> Result { + let id = parse_input_source_id(ac_params, "input-source-is", s)?; + Ok(s.a.sref(CustomAction::InputSourceIs(id))) +} + +fn parse_input_source_id( + ac_params: &[SExpr], + action_name: &str, + s: &ParserState, +) -> Result<&'static str> { + #[cfg(not(target_os = "macos"))] + { + let _ = (ac_params, s); + bail!("{action_name} is only supported on macOS"); + } + + #[cfg(target_os = "macos")] + { + if ac_params.len() != 1 { + bail!( + "{action_name} expects exactly one string input source ID, found {}", + ac_params.len() + ); + } + let id = ac_params[0] + .atom(s.vars()) + .ok_or_else(|| anyhow_expr!(&ac_params[0], "input source ID must be a string"))? + .trim_atom_quotes(); + if id.is_empty() { + bail_expr!(&ac_params[0], "input source ID must not be empty"); + } + Ok(s.a.sref_str(id.to_string())) + } +} diff --git a/parser/src/cfg/key_outputs.rs b/parser/src/cfg/key_outputs.rs index 2341d8517..8acd0e9b7 100644 --- a/parser/src/cfg/key_outputs.rs +++ b/parser/src/cfg/key_outputs.rs @@ -108,7 +108,7 @@ pub(crate) fn add_key_output_from_action_to_key_pos( add_key_output_from_action_to_key_pos(osc_slot, ac, outputs, overrides); } } - Action::Switch(Switch { cases }) => { + Action::Switch(Switch { cases, .. }) => { for case in cases.iter() { add_key_output_from_action_to_key_pos(osc_slot, case.1, outputs, overrides); } diff --git a/parser/src/cfg/list_actions.rs b/parser/src/cfg/list_actions.rs index 5f376d106..f1ec08cdf 100644 --- a/parser/src/cfg/list_actions.rs +++ b/parser/src/cfg/list_actions.rs @@ -137,6 +137,7 @@ pub const CLIPBOARD_RESTORE: &str = "clipboard-restore"; pub const CLIPBOARD_SAVE_SET: &str = "clipboard-save-set"; pub const CLIPBOARD_SAVE_CMD_SET: &str = "clipboard-save-cmd-set"; pub const CLIPBOARD_SAVE_SWAP: &str = "clipboard-save-swap"; +pub const SET_INPUT_SOURCE: &str = "set-input-source"; pub const TAP_HOLD_ORDER: &str = "tap-hold-order"; pub const TAP_HOLD_OPPOSITE_HAND: &str = "tap-hold-opposite-hand"; pub const TAP_HOLD_OPPOSITE_HAND_RELEASE: &str = "tap-hold-opposite-hand-release"; @@ -276,6 +277,7 @@ pub fn is_list_action(ac: &str) -> bool { CLIPBOARD_SAVE_SET, CLIPBOARD_SAVE_CMD_SET, CLIPBOARD_SAVE_SWAP, + SET_INPUT_SOURCE, TAP_HOLD_ORDER, TAP_HOLD_OPPOSITE_HAND, TAP_HOLD_OPPOSITE_HAND_RELEASE, diff --git a/parser/src/cfg/mod.rs b/parser/src/cfg/mod.rs index a7176aa78..4ac14e5db 100644 --- a/parser/src/cfg/mod.rs +++ b/parser/src/cfg/mod.rs @@ -75,6 +75,8 @@ use fake_key::*; mod fork; pub use fake_key::{FAKE_KEY_ROW, NORMAL_KEY_ROW}; use fork::*; +mod input_source; +use input_source::*; mod is_a_button; use is_a_button::*; mod live_reload; @@ -279,6 +281,10 @@ impl KanataLayout { // shrink the lifetime unsafe { std::mem::transmute(&self.layout) } } + + pub fn set_custom_condition_evaluator(&mut self, evaluator: fn(&KanataCustom) -> bool) { + self.layout.set_custom_condition_evaluator(evaluator); + } } pub struct Cfg { @@ -1651,6 +1657,7 @@ fn parse_action_list(ac: &[SExpr], s: &ParserState) -> Result<&'static KanataAct CLIPBOARD_SAVE_SET => parse_clipboard_save_set(&ac[1..], s), CLIPBOARD_SAVE_CMD_SET => parse_cmd(&ac[1..], s, CmdType::ClipboardSaveSet), CLIPBOARD_SAVE_SWAP => parse_clipboard_save_swap(&ac[1..], s), + SET_INPUT_SOURCE => parse_set_input_source(&ac[1..], s), _ => unreachable!(), } } diff --git a/parser/src/cfg/switch.rs b/parser/src/cfg/switch.rs index 611c7bbb7..ef79f49e1 100644 --- a/parser/src/cfg/switch.rs +++ b/parser/src/cfg/switch.rs @@ -6,6 +6,7 @@ pub fn parse_switch(ac_params: &[SExpr], s: &ParserState) -> Result<&'static Kan "switch expects triples of params: "; let mut cases = vec![]; + let mut custom_conditions = vec![]; let mut params = ac_params.iter(); loop { @@ -24,7 +25,7 @@ pub fn parse_switch(ac_params: &[SExpr], s: &ParserState) -> Result<&'static Kan }; let mut ops = vec![]; for op in key_match.iter() { - parse_switch_case_bool(1, op, &mut ops, s)?; + parse_switch_case_bool(1, op, &mut ops, &mut custom_conditions, s)?; } let action = parse_action(action, s)?; @@ -47,6 +48,7 @@ pub fn parse_switch(ac_params: &[SExpr], s: &ParserState) -> Result<&'static Kan } Ok(s.a.sref(Action::Switch(s.a.sref(Switch { cases: s.a.sref_vec(cases), + custom_conditions: s.a.sref_vec(custom_conditions), })))) } @@ -54,6 +56,7 @@ pub fn parse_switch_case_bool( depth: u8, op_expr: &SExpr, ops: &mut Vec, + custom_conditions: &mut Vec, s: &ParserState, ) -> Result<()> { if ops.len() > MAX_OPCODE_LEN as usize { @@ -90,6 +93,7 @@ pub fn parse_switch_case_bool( InputHistory, Layer, BaseLayer, + InputSourceIs, } #[derive(Copy, Clone)] enum InputType { @@ -116,6 +120,7 @@ pub fn parse_switch_case_bool( "input-history" => Some(AllowedListOps::InputHistory), "layer" => Some(AllowedListOps::Layer), "base-layer" => Some(AllowedListOps::BaseLayer), + "input-source-is" => Some(AllowedListOps::InputSourceIs), _ => None, }) .ok_or_else(|| { @@ -123,7 +128,7 @@ pub fn parse_switch_case_bool( op_expr, "lists inside switch logic must begin with one of:\n\ or | and | not | key-history | key-timing\n\ - | input | input-history | layer | base-layer", + | input | input-history | layer | base-layer | input-source-is", ) })?; @@ -269,6 +274,20 @@ pub fn parse_switch_case_bool( ops.extend(&[op1, op2]); Ok(()) } + AllowedListOps::InputSourceIs => { + let custom_condition = parse_input_source_is(&l[1..], s)?; + let condition_index = custom_conditions.len(); + if condition_index > u16::MAX as usize { + bail_expr!( + op_expr, + "maximum number of custom switch conditions exceeded" + ); + } + custom_conditions.push(custom_condition); + let (op1, op2) = OpCode::new_custom_condition(condition_index as u16); + ops.extend(&[op1, op2]); + Ok(()) + } AllowedListOps::Or | AllowedListOps::And | AllowedListOps::Not => { let op = match op { AllowedListOps::Or => BooleanOperator::Or, @@ -280,7 +299,7 @@ pub fn parse_switch_case_bool( let placeholder_index = ops.len() as u16; ops.push(OpCode::new_bool(op, placeholder_index)); for op in l.iter().skip(1) { - parse_switch_case_bool(depth + 1, op, ops, s)?; + parse_switch_case_bool(depth + 1, op, ops, custom_conditions, s)?; } if ops.len() > usize::from(MAX_OPCODE_LEN) { bail_expr!(op_expr, "switch logic length has been exceeded"); diff --git a/parser/src/cfg/tests.rs b/parser/src/cfg/tests.rs index f983af465..e83470ff5 100644 --- a/parser/src/cfg/tests.rs +++ b/parser/src/cfg/tests.rs @@ -856,11 +856,70 @@ fn parse_switch() { &Action::KeyCode(KeyCode::A), BreakOrFallthrough::Break ), - ] + ], + custom_conditions: &[], }) ); } +#[cfg(target_os = "macos")] +#[test] +fn parse_macos_input_source_support() { + let source = r#" +(defsrc a b) +(deflayer base + (set-input-source "com.apple.keylayout.US") + (switch + ((input-source-is "com.apple.keylayout.RussianWin")) b break + ) +) +"#; + let res = parse_cfg(source).expect("parses"); + let (op1, op2) = OpCode::new_custom_condition(0); + let (klayers, _) = res.klayers.get(); + assert_eq!( + klayers[0][0][OsCode::KEY_A.as_u16() as usize], + Action::Custom(&CustomAction::SetInputSource("com.apple.keylayout.US")) + ); + assert_eq!( + klayers[0][0][OsCode::KEY_B.as_u16() as usize], + Action::Switch(&Switch { + cases: &[( + &[op1, op2], + &Action::KeyCode(KeyCode::B), + BreakOrFallthrough::Break + )], + custom_conditions: &[&CustomAction::InputSourceIs( + "com.apple.keylayout.RussianWin" + )], + }) + ); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn parse_macos_input_source_support_rejected_on_other_platforms() { + let action_source = r#" +(defsrc a) +(deflayer base + (set-input-source "com.apple.keylayout.US") +) +"#; + let err = parse_cfg(action_source).unwrap_err(); + assert!(format!("{err:?}").contains("only supported on macOS")); + + let condition_source = r#" +(defsrc a) +(deflayer base + (switch + ((input-source-is "com.apple.keylayout.US")) a break + ) +) +"#; + let err = parse_cfg(condition_source).unwrap_err(); + assert!(format!("{err:?}").contains("only supported on macOS")); +} + #[test] fn parse_switch_exceed_depth() { let _lk = lock(&CFG_PARSE_LOCK); diff --git a/parser/src/custom_action.rs b/parser/src/custom_action.rs index 38b946600..629309fb4 100644 --- a/parser/src/custom_action.rs +++ b/parser/src/custom_action.rs @@ -98,6 +98,8 @@ pub enum CustomAction { ClipboardSaveSet(u16, &'static str), ClipboardSaveCmdSet(u16, &'static [&'static str]), ClipboardSaveSwap(u16, u16), + SetInputSource(&'static str), + InputSourceIs(&'static str), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/bin/kanata-input-source-helper.rs b/src/bin/kanata-input-source-helper.rs new file mode 100644 index 000000000..0ce4b6f50 --- /dev/null +++ b/src/bin/kanata-input-source-helper.rs @@ -0,0 +1,19 @@ +#[cfg(target_os = "macos")] +fn main() -> anyhow::Result<()> { + use simplelog::{ColorChoice, Config, LevelFilter, TermLogger, TerminalMode}; + + let _ = TermLogger::init( + LevelFilter::Info, + Config::default(), + TerminalMode::Mixed, + ColorChoice::Auto, + ); + + kanata_state_machine::macos_input_source::serve_helper_forever() +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("kanata-input-source-helper is only supported on macOS"); + std::process::exit(1); +} diff --git a/src/kanata/input_source.rs b/src/kanata/input_source.rs new file mode 100644 index 000000000..94715e291 --- /dev/null +++ b/src/kanata/input_source.rs @@ -0,0 +1,49 @@ +use anyhow::Result; +use kanata_parser::custom_action::CustomAction; + +pub fn set_current_input_source_by_id(id: &str) -> Result<()> { + backend::set_current_input_source_by_id(id) +} + +fn current_input_source_is(id: &str) -> Result { + backend::current_input_source_is(id) +} + +pub fn evaluate_custom_condition(action: &&'static CustomAction) -> bool { + match action { + CustomAction::InputSourceIs(id) => match current_input_source_is(id) { + Ok(is_current) => is_current, + Err(e) => { + log::error!("failed to check macOS input source: {e}"); + false + } + }, + _ => false, + } +} + +#[cfg(target_os = "macos")] +mod backend { + use anyhow::Result; + + pub fn set_current_input_source_by_id(id: &str) -> Result<()> { + crate::macos_input_source::set_current_input_source_by_id_via_helper(id) + } + + pub fn current_input_source_is(id: &str) -> Result { + crate::macos_input_source::current_input_source_is_via_helper(id) + } +} + +#[cfg(not(target_os = "macos"))] +mod backend { + use anyhow::{Result, bail}; + + pub fn set_current_input_source_by_id(_id: &str) -> Result<()> { + bail!("set-input-source is only supported on macOS") + } + + pub fn current_input_source_is(_id: &str) -> Result { + bail!("input-source-is is only supported on macOS") + } +} diff --git a/src/kanata/mod.rs b/src/kanata/mod.rs index a07a1900d..7810ae0c7 100644 --- a/src/kanata/mod.rs +++ b/src/kanata/mod.rs @@ -1,5 +1,7 @@ //! Implements the glue between OS input/output and keyberon state management. +mod input_source; + #[cfg(all(target_os = "windows", feature = "gui"))] use crate::gui::win::*; use anyhow::{Result, bail}; @@ -386,15 +388,20 @@ pub(crate) static MAPPED_KEYS: Lazy> = const LINUX_PERMISSIONS_ERROR: &str = "Failed to open the output uinput device. Make sure you added the user executing kanata to the 'uinput' group and that the 'uinput' group is configured correctly.\nSee for more detail: https://github.com/jtroo/kanata/blob/main/docs/setup-linux.md"; +fn install_runtime_condition_evaluators(layout: &mut cfg::KanataLayout) { + layout.set_custom_condition_evaluator(input_source::evaluate_custom_condition); +} + impl Kanata { pub fn new(args: &ValidatedArgs) -> Result { - let cfg = match cfg::new_from_file(&args.paths[0]) { + let mut cfg = match cfg::new_from_file(&args.paths[0]) { Ok(c) => c, Err(e) => { log::error!("{e:?}"); bail!("failed to parse file"); } }; + install_runtime_condition_evaluators(&mut cfg.layout); let kbd_out = match KbdOut::new( #[cfg(any(target_os = "linux", target_os = "android"))] @@ -562,12 +569,13 @@ impl Kanata { } pub fn new_from_str(cfg: &str, file_content: HashMap) -> Result { - let cfg = match cfg::new_from_str(cfg, file_content) { + let mut cfg = match cfg::new_from_str(cfg, file_content) { Ok(c) => c, Err(e) => { bail!("{e:?}"); } }; + install_runtime_condition_evaluators(&mut cfg.layout); let kbd_out = match KbdOut::new( #[cfg(any(target_os = "linux", target_os = "android"))] @@ -722,7 +730,7 @@ impl Kanata { } fn do_live_reload(&mut self, _tx: &Option>) -> Result<()> { - let cfg = match cfg::new_from_file(&self.cfg_paths[self.cur_cfg_idx]) { + let mut cfg = match cfg::new_from_file(&self.cfg_paths[self.cur_cfg_idx]) { Ok(c) => c, Err(e) => { log::error!("{e:?}"); @@ -733,6 +741,7 @@ impl Kanata { bail!("failed to parse config file"); } }; + install_runtime_condition_evaluators(&mut cfg.layout); update_kbd_out(&cfg.options, &self.kbd_out)?; #[cfg(target_os = "windows")] set_win_altgr_behaviour(cfg.options.windows_opts.windows_altgr); @@ -1824,10 +1833,16 @@ impl Kanata { CustomAction::ClipboardSaveSwap(id1, id2) => { clpb_save_swap(*id1, *id2, &mut self.saved_clipboard_content); } + CustomAction::SetInputSource(id) => { + if let Err(e) = input_source::set_current_input_source_by_id(id) { + log::error!("failed to set macOS input source {id:?}: {e}"); + } + } CustomAction::FakeKeyOnRelease { .. } | CustomAction::DelayOnRelease(_) | CustomAction::Unmodded { .. } | CustomAction::Unshifted { .. } + | CustomAction::InputSourceIs(_) // Note: ReverseReleaseOrder is already handled earlier on. | CustomAction::ReverseReleaseOrder | CustomAction::CancelMacroOnRelease => {} diff --git a/src/lib.rs b/src/lib.rs index a54c15ecc..41d7b5e9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ use std::str::FromStr; #[cfg(all(target_os = "windows", feature = "gui"))] pub mod gui; pub mod kanata; +#[cfg(target_os = "macos")] +pub mod macos_input_source; pub mod oskbd; pub mod tcp_server; #[cfg(test)] diff --git a/src/macos_input_source.rs b/src/macos_input_source.rs new file mode 100644 index 000000000..f3d951d41 --- /dev/null +++ b/src/macos_input_source.rs @@ -0,0 +1,666 @@ +use anyhow::{Context, Result, anyhow, bail}; +use core_foundation::base::{CFType, TCFType}; +use core_foundation::boolean::CFBoolean; +use core_foundation::string::{CFString, CFStringRef}; +use std::ffi::c_void; +use std::fmt; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::ptr; +use std::thread::sleep; +use std::time::Duration; + +type CFArrayRef = *const c_void; +type CFDictionaryRef = *const c_void; +type CFMutableDictionaryRef = *mut c_void; +type CFTypeRef = *const c_void; +type TISInputSourceRef = *const c_void; + +const NO_ERR: i32 = 0; +const INPUT_SOURCE_VERIFY_DELAY_MS: u64 = 50; +const SOCKET_PREFIX: &str = "kanata-input-source-helper"; + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + static kCFBooleanTrue: CFTypeRef; + static kCFTypeDictionaryKeyCallBacks: c_void; + static kCFTypeDictionaryValueCallBacks: c_void; + + fn CFArrayGetCount(theArray: CFArrayRef) -> isize; + fn CFArrayGetValueAtIndex(theArray: CFArrayRef, idx: isize) -> *const c_void; + fn CFDictionaryCreateMutable( + allocator: *const c_void, + capacity: isize, + keyCallBacks: *const c_void, + valueCallBacks: *const c_void, + ) -> CFMutableDictionaryRef; + fn CFDictionarySetValue( + theDict: CFMutableDictionaryRef, + key: *const c_void, + value: *const c_void, + ); + fn CFRelease(cf: CFTypeRef); +} + +#[link(name = "Carbon", kind = "framework")] +unsafe extern "C" { + static kTISPropertyInputSourceID: CFStringRef; + static kTISPropertyInputModeID: CFStringRef; + static kTISPropertyLocalizedName: CFStringRef; + static kTISPropertyInputSourceCategory: CFStringRef; + static kTISPropertyInputSourceIsEnabled: CFStringRef; + static kTISPropertyInputSourceIsSelectCapable: CFStringRef; + static kTISCategoryKeyboardInputSource: CFStringRef; + + fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef; + fn TISCreateInputSourceList( + properties: *const c_void, + includeAllInstalledInputSources: u8, + ) -> CFArrayRef; + fn TISGetInputSourceProperty( + inputSource: TISInputSourceRef, + propertyKey: CFStringRef, + ) -> *const c_void; + fn TISSelectInputSource(inputSource: TISInputSourceRef) -> i32; +} + +pub fn set_current_input_source_by_id_via_helper(id: &str) -> Result<()> { + match send_helper_request(&format!("set\t{}", escape_field(id)))? { + HelperResponse::Ok(None) => Ok(()), + HelperResponse::Ok(Some(_)) => Ok(()), + HelperResponse::Err(error) => Err(anyhow!(error)), + } +} + +pub fn current_input_source_is_via_helper(id: &str) -> Result { + match send_helper_request("current")? { + HelperResponse::Ok(Some(current_id)) => Ok(current_id == id), + HelperResponse::Ok(None) => Ok(false), + HelperResponse::Err(error) => Err(anyhow!(error)), + } +} + +pub fn serve_helper_forever() -> Result<()> { + let socket_path = helper_socket_path_for_current_user(); + prepare_socket_path(&socket_path)?; + let listener = UnixListener::bind(&socket_path) + .with_context(|| format!("failed to bind {}", socket_path.display()))?; + fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to set permissions on {}", socket_path.display()))?; + let _cleanup = SocketCleanup { + path: socket_path.clone(), + }; + + log::info!( + "macOS input-source helper listening at {}", + socket_path.display() + ); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + if let Err(e) = handle_helper_stream(stream) { + log::error!("failed to handle macOS input-source helper request: {e}"); + } + } + Err(e) => log::error!("failed to accept macOS input-source helper connection: {e}"), + } + } + + Ok(()) +} + +fn send_helper_request(request: &str) -> Result { + let uid = console_user_uid(); + let socket_path = helper_socket_path_for_uid(uid); + validate_helper_socket(&socket_path, uid)?; + let mut stream = UnixStream::connect(&socket_path).with_context(|| { + format!( + "macOS input-source helper is not running at {}. Start kanata-input-source-helper as the logged-in console user.", + socket_path.display() + ) + })?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .context("failed to set macOS input-source helper read timeout")?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .context("failed to set macOS input-source helper write timeout")?; + + stream + .write_all(request.as_bytes()) + .context("failed to send request to macOS input-source helper")?; + stream + .write_all(b"\n") + .context("failed to finish request to macOS input-source helper")?; + stream + .shutdown(std::net::Shutdown::Write) + .context("failed to close macOS input-source helper request stream")?; + + let mut response = String::new(); + stream + .read_to_string(&mut response) + .context("failed to read response from macOS input-source helper")?; + parse_helper_response(response.trim_end()) +} + +fn handle_helper_stream(mut stream: UnixStream) -> Result<()> { + let mut request = String::new(); + BufReader::new(stream.try_clone()?) + .read_line(&mut request) + .context("failed to read macOS input-source helper request")?; + + let response = match handle_helper_request(request.trim_end()) { + Ok(response) => response, + Err(e) => format!("err\t{}", escape_field(&e.to_string())), + }; + + stream + .write_all(response.as_bytes()) + .context("failed to write macOS input-source helper response")?; + stream + .write_all(b"\n") + .context("failed to finish macOS input-source helper response")?; + Ok(()) +} + +fn handle_helper_request(request: &str) -> Result { + let mut parts = request.splitn(2, '\t'); + match parts.next() { + Some("current") => { + let current_id = current_keyboard_input_source_id()?; + Ok(format!( + "ok\t{}", + escape_field(current_id.as_deref().unwrap_or_default()) + )) + } + Some("set") => { + let Some(id) = parts.next() else { + bail!("missing input source ID in set request"); + }; + let id = unescape_field(id)?; + set_current_input_source_by_id(&id).map(|()| "ok".to_owned()) + } + Some(op) if !op.is_empty() => bail!("unknown macOS input-source helper request: {op:?}"), + _ => bail!("empty macOS input-source helper request"), + } +} + +fn parse_helper_response(response: &str) -> Result { + let mut parts = response.splitn(2, '\t'); + match parts.next() { + Some("ok") => Ok(HelperResponse::Ok(match parts.next() { + Some(payload) => Some(unescape_field(payload)?), + None => None, + })), + Some("err") => Ok(HelperResponse::Err(match parts.next() { + Some(payload) => unescape_field(payload)?, + None => "macOS input-source helper returned an unspecified error".to_owned(), + })), + Some(status) if !status.is_empty() => { + bail!("invalid macOS input-source helper response status: {status:?}") + } + _ => bail!("empty response from macOS input-source helper"), + } +} + +fn prepare_socket_path(socket_path: &Path) -> Result<()> { + let uid = unsafe { libc::geteuid() }; + let socket_dir = helper_socket_dir_for_uid(uid); + prepare_socket_dir(&socket_dir, uid)?; + + if UnixStream::connect(socket_path).is_ok() { + bail!( + "macOS input-source helper is already running at {}", + socket_path.display() + ); + } + + match fs::remove_file(socket_path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).with_context(|| { + format!( + "failed to remove stale macOS input-source helper socket {}", + socket_path.display() + ) + }), + } +} + +fn helper_socket_path_for_current_user() -> PathBuf { + helper_socket_path_for_uid(unsafe { libc::geteuid() }) +} + +fn helper_socket_dir_for_uid(uid: u32) -> PathBuf { + PathBuf::from(format!("/tmp/{SOCKET_PREFIX}-{uid}")) +} + +fn helper_socket_path_for_uid(uid: u32) -> PathBuf { + helper_socket_dir_for_uid(uid).join("socket") +} + +fn prepare_socket_dir(socket_dir: &Path, uid: u32) -> Result<()> { + match fs::symlink_metadata(socket_dir) { + Ok(_) => validate_socket_dir(socket_dir, uid)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(socket_dir) + .with_context(|| format!("failed to create {}", socket_dir.display()))?; + } + Err(e) => { + return Err(e).with_context(|| format!("failed to inspect {}", socket_dir.display())); + } + } + + fs::set_permissions(socket_dir, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("failed to set permissions on {}", socket_dir.display()))?; + validate_socket_dir(socket_dir, uid) +} + +fn validate_helper_socket(socket_path: &Path, uid: u32) -> Result<()> { + let socket_dir = socket_path.parent().ok_or_else(|| { + anyhow!( + "helper socket path has no parent: {}", + socket_path.display() + ) + })?; + + match fs::symlink_metadata(socket_dir) { + Ok(_) => validate_socket_dir(socket_dir, uid)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(e).with_context(|| format!("failed to inspect {}", socket_dir.display())); + } + } + + let metadata = match fs::symlink_metadata(socket_path) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(e).with_context(|| format!("failed to inspect {}", socket_path.display())); + } + }; + let file_type = metadata.file_type(); + if !file_type.is_socket() { + bail!( + "macOS input-source helper path is not a Unix socket: {}", + socket_path.display() + ); + } + if metadata.uid() != uid { + bail!( + "macOS input-source helper socket {} is owned by uid {}, expected uid {}", + socket_path.display(), + metadata.uid(), + uid + ); + } + if metadata.mode() & 0o077 != 0 { + bail!( + "macOS input-source helper socket {} has overly broad permissions {:o}", + socket_path.display(), + metadata.mode() & 0o777 + ); + } + Ok(()) +} + +fn validate_socket_dir(socket_dir: &Path, uid: u32) -> Result<()> { + let metadata = fs::symlink_metadata(socket_dir) + .with_context(|| format!("failed to inspect {}", socket_dir.display()))?; + let file_type = metadata.file_type(); + if !file_type.is_dir() { + bail!( + "macOS input-source helper socket directory is not a directory: {}", + socket_dir.display() + ); + } + if metadata.uid() != uid { + bail!( + "macOS input-source helper socket directory {} is owned by uid {}, expected uid {}", + socket_dir.display(), + metadata.uid(), + uid + ); + } + if metadata.mode() & 0o077 != 0 { + bail!( + "macOS input-source helper socket directory {} has overly broad permissions {:o}", + socket_dir.display(), + metadata.mode() & 0o777 + ); + } + Ok(()) +} + +fn console_user_uid() -> u32 { + fs::metadata("/dev/console") + .map(|metadata| metadata.uid()) + .unwrap_or_else(|e| { + let uid = unsafe { libc::geteuid() }; + log::warn!( + "failed to read /dev/console owner for macOS input-source helper lookup: {e}; using effective UID {uid}" + ); + uid + }) +} + +fn set_current_input_source_by_id(id: &str) -> Result<()> { + with_input_source_list(|list, count| { + let before_select_id = current_keyboard_input_source_id(); + + let sources = collect_selectable_keyboard_sources(list, count); + + let matching_sources = sources + .into_iter() + .filter(|(_, info)| info.id.as_deref() == Some(id)) + .collect::>(); + + let Some((source, info)) = matching_sources + .iter() + .find(|(_, info)| info.is_enabled && info.is_select_capable) + else { + let diagnostics = input_source_diagnostics(id, &before_select_id, &matching_sources); + if matching_sources.is_empty() { + return Err(anyhow!( + "macOS input source ID not found in selectable enabled sources: {id:?}. {diagnostics}" + )); + } + return Err(anyhow!( + "macOS input source ID {id:?} was found, but no matching source is both enabled and select-capable. Matches: {}. {diagnostics}", + format_matches(&matching_sources), + )); + }; + + let status = unsafe { TISSelectInputSource(*source) }; + + let immediate_id = current_keyboard_input_source_id(); + sleep(Duration::from_millis(INPUT_SOURCE_VERIFY_DELAY_MS)); + let delayed_id = current_keyboard_input_source_id(); + + if status != NO_ERR { + let diagnostics = input_source_diagnostics(id, &before_select_id, &matching_sources); + return Err(anyhow!( + "failed to select macOS input source {id:?}; TISSelectInputSource returned OSStatus {status}. Selected candidate: {info}. Current input source before selection: {}; immediately after: {}; after {INPUT_SOURCE_VERIFY_DELAY_MS}ms: {}. {diagnostics}", + format_current_read_result(&before_select_id), + format_current_read_result(&immediate_id), + format_current_read_result(&delayed_id) + )); + } + + if current_id_matches(&immediate_id, id) || current_id_matches(&delayed_id, id) { + return Ok(()); + } + + let diagnostics = input_source_diagnostics(id, &before_select_id, &matching_sources); + Err(anyhow!( + "TISSelectInputSource returned success for macOS input source {id:?}, but the current keyboard input source did not change to the requested ID. Selected candidate: {info}. Current input source before selection: {}; immediately after: {}; after {INPUT_SOURCE_VERIFY_DELAY_MS}ms: {}. {diagnostics}", + format_current_read_result(&before_select_id), + format_current_read_result(&immediate_id), + format_current_read_result(&delayed_id) + )) + }) +} + +fn collect_selectable_keyboard_sources( + list: CFArrayRef, + count: isize, +) -> Vec<(TISInputSourceRef, InputSourceInfo)> { + let mut sources = Vec::new(); + + for idx in 0..count { + let source = input_source_at(list, idx); + if source.is_null() { + continue; + } + let info = InputSourceInfo::new(source); + sources.push((source, info)); + } + + sources +} + +fn input_source_diagnostics( + requested_id: &str, + current_id: &Result>, + selectable_sources: &[(TISInputSourceRef, InputSourceInfo)], +) -> String { + let list = unsafe { TISCreateInputSourceList(ptr::null(), 1) }; + if list.is_null() { + return "failed to list all installed macOS input sources for diagnostics".to_owned(); + } + + let count = unsafe { CFArrayGetCount(list) }; + let mut installed_sources = Vec::new(); + for idx in 0..count { + let source = input_source_at(list, idx); + if source.is_null() { + continue; + } + let info = InputSourceInfo::new(source); + installed_sources.push(info); + } + unsafe { CFRelease(list as CFTypeRef) }; + + let selectable_id_matches = selectable_sources + .iter() + .filter(|(_, info)| info.id.as_deref() == Some(requested_id)) + .map(|(_, info)| info) + .collect::>(); + let installed_id_matches = installed_sources + .iter() + .filter(|info| info.id.as_deref() == Some(requested_id)) + .collect::>(); + let installed_mode_matches = installed_sources + .iter() + .filter(|info| info.input_mode_id.as_deref() == Some(requested_id)) + .collect::>(); + format!( + "Diagnostics: current={}, selectable input_source_id matches={}, installed input_source_id matches={}, installed input_mode_id matches={}", + format_current_read_result(current_id), + format_info_refs(&selectable_id_matches), + format_info_refs(&installed_id_matches), + format_info_refs(&installed_mode_matches), + ) +} + +fn current_id_matches(current_id: &Result>, id: &str) -> bool { + matches!(current_id, Ok(Some(current_id)) if current_id == id) +} + +fn format_current_read_result(current_id: &Result>) -> String { + match current_id { + Ok(id) => format!("{id:?}"), + Err(e) => format!("read error: {e}"), + } +} + +fn current_keyboard_input_source_id() -> Result> { + let source = unsafe { TISCopyCurrentKeyboardInputSource() }; + if source.is_null() { + bail!("failed to read current macOS keyboard input source"); + } + let current_id = input_source_id(source); + unsafe { CFRelease(source as CFTypeRef) }; + Ok(current_id) +} + +fn with_input_source_list(f: impl FnOnce(CFArrayRef, isize) -> Result) -> Result { + let properties = make_selectable_keyboard_source_filter()?; + let list = unsafe { TISCreateInputSourceList(properties as CFDictionaryRef, 0) }; + unsafe { CFRelease(properties as CFTypeRef) }; + if list.is_null() { + bail!("failed to list macOS input sources"); + } + + let count = unsafe { CFArrayGetCount(list) }; + let result = f(list, count); + unsafe { CFRelease(list as CFTypeRef) }; + result +} + +fn make_selectable_keyboard_source_filter() -> Result { + let properties = unsafe { + CFDictionaryCreateMutable( + ptr::null(), + 0, + &raw const kCFTypeDictionaryKeyCallBacks, + &raw const kCFTypeDictionaryValueCallBacks, + ) + }; + if properties.is_null() { + bail!("failed to create macOS input source filter"); + } + + unsafe { + CFDictionarySetValue( + properties, + kTISPropertyInputSourceIsSelectCapable as *const c_void, + kCFBooleanTrue, + ); + CFDictionarySetValue( + properties, + kTISPropertyInputSourceCategory as *const c_void, + kTISCategoryKeyboardInputSource as *const c_void, + ); + } + + Ok(properties) +} + +fn input_source_at(list: CFArrayRef, idx: isize) -> TISInputSourceRef { + unsafe { CFArrayGetValueAtIndex(list, idx) as TISInputSourceRef } +} + +fn input_source_id(source: TISInputSourceRef) -> Option { + string_property(source, unsafe { kTISPropertyInputSourceID }) +} + +fn string_property(source: TISInputSourceRef, key: CFStringRef) -> Option { + let value = unsafe { TISGetInputSourceProperty(source, key) as CFStringRef }; + if value.is_null() { + None + } else { + Some(unsafe { CFString::wrap_under_get_rule(value) }.to_string()) + } +} + +fn bool_property(source: TISInputSourceRef, key: CFStringRef) -> bool { + let value = unsafe { TISGetInputSourceProperty(source, key) as CFTypeRef }; + if value.is_null() { + return false; + } + let value = unsafe { CFType::wrap_under_get_rule(value) }; + value + .downcast::() + .map(bool::from) + .unwrap_or(false) +} + +#[derive(Debug)] +struct InputSourceInfo { + id: Option, + input_mode_id: Option, + localized_name: Option, + category: Option, + is_enabled: bool, + is_select_capable: bool, +} + +impl InputSourceInfo { + fn new(source: TISInputSourceRef) -> Self { + Self { + id: input_source_id(source), + input_mode_id: string_property(source, unsafe { kTISPropertyInputModeID }), + localized_name: string_property(source, unsafe { kTISPropertyLocalizedName }), + category: string_property(source, unsafe { kTISPropertyInputSourceCategory }), + is_enabled: bool_property(source, unsafe { kTISPropertyInputSourceIsEnabled }), + is_select_capable: bool_property(source, unsafe { + kTISPropertyInputSourceIsSelectCapable + }), + } + } +} + +impl fmt::Display for InputSourceInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "input_source_id={:?}, localized_name={:?}, input_mode_id={:?}, category={:?}, isEnabled={}, isSelectCapable={}", + self.id, + self.localized_name, + self.input_mode_id, + self.category, + self.is_enabled, + self.is_select_capable + ) + } +} + +enum HelperResponse { + Ok(Option), + Err(String), +} + +struct SocketCleanup { + path: PathBuf, +} + +impl Drop for SocketCleanup { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + if let Some(socket_dir) = self.path.parent() { + let _ = fs::remove_dir(socket_dir); + } + } +} + +fn format_matches(matches: &[(TISInputSourceRef, InputSourceInfo)]) -> String { + matches + .iter() + .map(|(_, info)| info.to_string()) + .collect::>() + .join("; ") +} + +fn format_info_refs(infos: &[&InputSourceInfo]) -> String { + if infos.is_empty() { + "none".to_owned() + } else { + infos + .iter() + .map(|info| info.to_string()) + .collect::>() + .join("; ") + } +} + +fn escape_field(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\t', "\\t") + .replace('\n', "\\n") +} + +fn unescape_field(value: &str) -> Result { + let mut result = String::new(); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + result.push(ch); + continue; + } + + match chars.next() { + Some('\\') => result.push('\\'), + Some('t') => result.push('\t'), + Some('n') => result.push('\n'), + Some(ch) => bail!("invalid escape sequence in helper field: \\{ch}"), + None => bail!("trailing escape in helper field"), + } + } + Ok(result) +}