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