Skip to content

Commit 0822019

Browse files
malpernclaude
andcommitted
feat: add macos-continue-if-no-devs-found config option
Add macOS equivalent of linux-continue-if-no-devs-found. When enabled, kanata keeps running if no matching devices are found at startup and automatically captures them when they connect, using driverkit's IOKit notification system. This also includes the fix from PR jtroo#1986 (don't fall back to grabbing all devices when an include/exclude list has no matches). Depends on psych3r/driverkit#17 for notification subscription support for not-yet-connected devices. Relates to jtroo#1479 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3bda1ec commit 0822019

File tree

5 files changed

+72
-11
lines changed

5 files changed

+72
-11
lines changed

docs/config.adoc

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4880,6 +4880,29 @@ Use `kanata -l` or `kanata --list` to list the available keyboards.
48804880
)
48814881
)
48824882
----
4883+
4884+
[[macos-only-macos-continue-if-no-devs-found]]
4885+
=== macOS only: macos-continue-if-no-devs-found
4886+
4887+
By default, kanata will exit with an error if no matching input devices are found.
4888+
You can change this behaviour by setting `macos-continue-if-no-devs-found`.
4889+
When enabled, kanata will keep running and automatically capture matching devices
4890+
when they are connected.
4891+
4892+
This is useful when running multiple kanata instances with
4893+
`macos-dev-names-include`, where an external keyboard may not be connected
4894+
at startup.
4895+
4896+
.Example:
4897+
[source]
4898+
----
4899+
(defcfg
4900+
macos-dev-names-include (
4901+
"External Keyboard"
4902+
)
4903+
macos-continue-if-no-devs-found yes
4904+
)
4905+
----
48834906
[[windows-only-windows-altgr]]
48844907
=== Windows only: windows-altgr
48854908

parser/src/cfg/defcfg.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ pub enum LinuxCfgOutputBusType {
6969
pub struct CfgMacosOptions {
7070
pub macos_dev_names_include: Option<Vec<String>>,
7171
pub macos_dev_names_exclude: Option<Vec<String>>,
72+
pub macos_continue_if_no_devs_found: bool,
7273
}
7374

7475
#[cfg(any(
@@ -679,6 +680,13 @@ pub fn parse_defcfg(expr: &[SExpr]) -> Result<CfgOptions> {
679680
cfg.macos_opts.macos_dev_names_exclude = Some(dev_names);
680681
}
681682
}
683+
"macos-continue-if-no-devs-found" => {
684+
#[cfg(any(target_os = "macos", target_os = "unknown"))]
685+
{
686+
cfg.macos_opts.macos_continue_if_no_devs_found =
687+
parse_defcfg_val_bool(val, label)?
688+
}
689+
}
682690
"tray-icon" => {
683691
#[cfg(all(
684692
any(target_os = "windows", target_os = "unknown"),

src/kanata/macos.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ impl Kanata {
2626
let allow_hardware_repeat = k.allow_hardware_repeat;
2727
let include_names = k.include_names.clone();
2828
let exclude_names = k.exclude_names.clone();
29+
let continue_if_no_devices = k.continue_if_no_devices;
2930
drop(k);
3031

31-
let mut kb = match KbdIn::new(include_names, exclude_names) {
32+
let mut kb = match KbdIn::new(include_names, exclude_names, continue_if_no_devices) {
3233
Ok(kbd_in) => kbd_in,
3334
Err(e) => bail!("failed to open keyboard device(s): {}", e),
3435
};

src/kanata/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ pub struct Kanata {
223223
#[cfg(any(target_os = "linux", target_os = "android"))]
224224
/// Tracks the Linux user configuration to continue or abort if no devices are found.
225225
continue_if_no_devices: bool,
226+
#[cfg(target_os = "macos")]
227+
/// Tracks the macOS user configuration to continue or abort if no devices are found.
228+
continue_if_no_devices: bool,
226229
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
227230
/// Tracks the Linux/Macos user configuration for device names (instead of paths) that should be
228231
/// included for interception and processing by kanata.
@@ -474,6 +477,8 @@ impl Kanata {
474477
include_names: cfg.options.macos_opts.macos_dev_names_include,
475478
#[cfg(target_os = "macos")]
476479
exclude_names: cfg.options.macos_opts.macos_dev_names_exclude,
480+
#[cfg(target_os = "macos")]
481+
continue_if_no_devices: cfg.options.macos_opts.macos_continue_if_no_devs_found,
477482
#[cfg(any(target_os = "linux", target_os = "android"))]
478483
kbd_in_paths: cfg.options.linux_opts.linux_dev,
479484
#[cfg(any(target_os = "linux", target_os = "android"))]
@@ -624,6 +629,8 @@ impl Kanata {
624629
include_names: cfg.options.macos_opts.macos_dev_names_include,
625630
#[cfg(target_os = "macos")]
626631
exclude_names: cfg.options.macos_opts.macos_dev_names_exclude,
632+
#[cfg(target_os = "macos")]
633+
continue_if_no_devices: cfg.options.macos_opts.macos_continue_if_no_devs_found,
627634
#[cfg(any(target_os = "linux", target_os = "android"))]
628635
kbd_in_paths: cfg.options.linux_opts.linux_dev,
629636
#[cfg(any(target_os = "linux", target_os = "android"))]

src/oskbd/macos.rs

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ impl From<InputEvent> for DKEvent {
5050
value: event.value,
5151
page: event.page,
5252
code: event.code,
53+
device_hash: 0,
5354
}
5455
}
5556
}
@@ -70,6 +71,7 @@ impl KbdIn {
7071
pub fn new(
7172
include_names: Option<Vec<String>>,
7273
exclude_names: Option<Vec<String>>,
74+
continue_if_no_devices: bool,
7375
) -> Result<Self, anyhow::Error> {
7476
if !driver_activated() {
7577
return Err(anyhow!(
@@ -79,8 +81,9 @@ impl KbdIn {
7981

8082
// Based on the definition of include and exclude names, they should never be used together.
8183
// Kanata config parser should probably enforce this.
84+
let has_device_filter = include_names.is_some() || exclude_names.is_some();
8285
let device_names = if let Some(included_names) = include_names {
83-
validate_and_register_devices(included_names)
86+
validate_and_register_devices(included_names, continue_if_no_devices)
8487
} else if let Some(excluded_names) = exclude_names {
8588
// get all devices
8689
let kb_list = fetch_devices();
@@ -99,12 +102,22 @@ impl KbdIn {
99102
.collect::<Vec<String>>();
100103

101104
// register the remeining devices
102-
validate_and_register_devices(devices_to_include)
105+
validate_and_register_devices(devices_to_include, continue_if_no_devices)
103106
} else {
104107
vec![]
105108
};
106109

107-
if !device_names.is_empty() || register_device("") {
110+
// When an include/exclude list is configured but no devices matched,
111+
// do NOT fall back to registering all devices. Only use the catch-all
112+
// register_device("") when no device filter was specified at all.
113+
if !device_names.is_empty() || (!has_device_filter && register_device("")) {
114+
if grab() {
115+
Ok(Self { grabbed: true })
116+
} else {
117+
Err(anyhow!("grab failed"))
118+
}
119+
} else if continue_if_no_devices && has_device_filter {
120+
log::warn!("no matching devices found; waiting for device to connect");
108121
if grab() {
109122
Ok(Self { grabbed: true })
110123
} else {
@@ -123,6 +136,7 @@ impl KbdIn {
123136
value: 0,
124137
page: 0,
125138
code: 0,
139+
device_hash: 0,
126140
};
127141

128142
let got_event = wait_key(&mut event);
@@ -163,7 +177,10 @@ impl KbdIn {
163177
}
164178
}
165179

166-
fn validate_and_register_devices(include_names: Vec<String>) -> Vec<String> {
180+
fn validate_and_register_devices(
181+
include_names: Vec<String>,
182+
continue_if_no_devices: bool,
183+
) -> Vec<String> {
167184
include_names
168185
.iter()
169186
.filter_map(|dev| {
@@ -179,12 +196,17 @@ fn validate_and_register_devices(include_names: Vec<String>) -> Vec<String> {
179196
return None;
180197
}
181198

182-
match device_matches(dev) {
183-
true => Some(dev.to_string()),
184-
false => {
185-
log::warn!("'{dev}' doesn't match any connected device");
186-
None
187-
}
199+
if device_matches(dev) {
200+
Some(dev.to_string())
201+
} else if continue_if_no_devices {
202+
// Device not connected, but register it anyway so driverkit
203+
// can auto-capture it via IOKit notifications when it appears.
204+
log::warn!("'{dev}' not connected; will capture when it appears");
205+
register_device(dev);
206+
None
207+
} else {
208+
log::warn!("'{dev}' doesn't match any connected device");
209+
None
188210
}
189211
})
190212
.filter_map(|dev| {

0 commit comments

Comments
 (0)