Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions src/grabber/HidSeize.zig
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,38 @@ pub const Event = struct {
/// 1 for keydown / modifier set; 0 for keyup / modifier clear.
/// Booleans abstract over IOHIDValueGetIntegerValue's CFIndex.
pressed: bool,
/// Index into the matches passed to `setMatches` of the device that
/// sent this, so per-device rules only see their own keyboard. Null
/// when the device couldn't be resolved.
device: ?u8 = null,
};

/// Which match a device with this VendorID/ProductID was seized under. A
/// FIFO built-in reports no IDs, so it reads as 0/0 and resolves to the
/// (0,0) alias, the same way `matchPredicate` selects it.
pub fn matchIndex(matches: []const Match, vendor: u32, product: u32) ?u8 {
for (matches, 0..) |m, i| {
if (m.vendor == vendor and m.product == product) return std.math.cast(u8, i);
}
return null;
}

/// Whether a rule bound to `rule_device` should see an event from
/// `event_device`. Either side unknown means deliver, so an unresolved
/// device keeps working rather than going silent.
pub fn sameDevice(rule_device: ?u8, event_device: ?u8) bool {
const want = rule_device orelse return true;
const got = event_device orelse return true;
return want == got;
}

/// Seized devices resolved to their match index. Filled on a device's
/// first input value and emptied on removal, so the per-keystroke cost is
/// a pointer compare. Devices past capacity resolve on every value.
const max_cached_devices = 16;
const CachedDevice = struct {
ref: c.IOHIDDeviceRef,
match: ?u8,
};

pub const Callback = *const fn (ctx: ?*anyopaque, event: Event) void;
Expand All @@ -97,6 +129,8 @@ open_options: u32 = 0,
/// disableCapsLockDelayOnMatches to filter event-system services
/// to just the ones we seized.
owned_matches: []Match = &.{},
device_cache: [max_cached_devices]CachedDevice = undefined,
device_cache_len: usize = 0,

const Self = @This();

Expand Down Expand Up @@ -198,6 +232,7 @@ pub fn setMatches(self: *Self, matches: []const Match) !void {
if (self.running) return error.AlreadyRunning;

if (self.owned_matches.len > 0) self.allocator.free(self.owned_matches);
self.device_cache_len = 0;
self.owned_matches = try self.allocator.dupe(Match, matches);
errdefer {
self.allocator.free(self.owned_matches);
Expand Down Expand Up @@ -389,6 +424,7 @@ pub fn stop(self: *Self) void {
_ = c.IOHIDManagerClose(self.manager, self.open_options);
c.IOHIDManagerUnscheduleFromRunLoop(self.manager, c.CFRunLoopGetCurrent(), c.kCFRunLoopDefaultMode);
self.running = false;
self.device_cache_len = 0;
log.info("released seize", .{});
}

Expand All @@ -413,6 +449,31 @@ fn deviceI32Property(device: c.IOHIDDeviceRef, key_cstr: [*:0]const u8) u32 {
return @bitCast(out);
}

fn deviceIndex(self: *Self, device: c.IOHIDDeviceRef) ?u8 {
for (self.device_cache[0..self.device_cache_len]) |d| {
if (d.ref == device) return d.match;
}
const match = matchIndex(
self.owned_matches,
deviceI32Property(device, c.kIOHIDVendorIDKey),
deviceI32Property(device, c.kIOHIDProductIDKey),
);
if (self.device_cache_len < max_cached_devices) {
self.device_cache[self.device_cache_len] = .{ .ref = device, .match = match };
self.device_cache_len += 1;
}
return match;
}

fn forgetDevice(self: *Self, device: c.IOHIDDeviceRef) void {
for (self.device_cache[0..self.device_cache_len], 0..) |d, i| {
if (d.ref != device) continue;
self.device_cache[i] = self.device_cache[self.device_cache_len - 1];
self.device_cache_len -= 1;
return;
}
}

fn deviceMatchedCallback(
ctx: ?*anyopaque,
result: c.IOReturn,
Expand All @@ -431,10 +492,11 @@ fn deviceRemovedCallback(
sender: ?*anyopaque,
device: c.IOHIDDeviceRef,
) callconv(.c) void {
_ = ctx;
_ = sender;
if (result != c.kIOReturnSuccess) return;
deviceIdsLog("device removed", device);
const self: *Self = @ptrCast(@alignCast(ctx orelse return));
self.forgetDevice(device);
}

fn valueCallback(
Expand All @@ -443,7 +505,6 @@ fn valueCallback(
sender: ?*anyopaque,
value: c.IOHIDValueRef,
) callconv(.c) void {
_ = sender;
if (result != c.kIOReturnSuccess) return;
const self: *Self = @ptrCast(@alignCast(ctx orelse return));

Expand All @@ -458,6 +519,8 @@ fn valueCallback(
.usage_page = usage_page,
.usage = usage,
.pressed = int_value != 0,
// The device passes itself as sender (IOKitUser IOHIDDevice.c).
.device = if (sender) |dev| self.deviceIndex(dev) else null,
});
}

Expand All @@ -473,6 +536,30 @@ test "matchPredicate: FIFO built-in (0,0) scopes to internal transports, omits V
try testing.expectEqualSlices([:0]const u8, &builtin_transports, p.transports);
}

test "matchIndex: a device resolves to the match it was seized under" {
const matches = [_]Match{
.{ .vendor = 0, .product = 0 }, // built-in
.{ .vendor = 0x046D, .product = 0xC548 },
.{ .vendor = 0x046D, .product = 0xC52B },
};
// A FIFO built-in exposes no VendorID/ProductID, so it reads as 0/0.
try testing.expectEqual(@as(?u8, 0), matchIndex(&matches, 0, 0));
try testing.expectEqual(@as(?u8, 1), matchIndex(&matches, 0x046D, 0xC548));
try testing.expectEqual(@as(?u8, 2), matchIndex(&matches, 0x046D, 0xC52B));
try testing.expectEqual(@as(?u8, null), matchIndex(&matches, 0x16C0, 0x27DB));
}

test "sameDevice: a rule only hears its own device, unknowns hear everything" {
// Two keyboards each with a caps rule: without this, one caps tap
// ran both rules and typed the tap key twice.
try testing.expect(sameDevice(1, 1));
try testing.expect(!sameDevice(1, 2));
// An unresolved device or a rule without a device keeps the old
// deliver-to-all behaviour rather than dropping input.
try testing.expect(sameDevice(1, null));
try testing.expect(sameDevice(null, 2));
}

test "matchPredicate: external device matches VID/PID, no transport constraint" {
// NEO ERGO WIRED — the keyboard that was wrongly seized. An explicit
// (vendor,product) alias targets that exact device and sets no
Expand Down
10 changes: 10 additions & 0 deletions src/grabber/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,7 @@ const Daemon = struct {
};
slots[i] = .{
.seize_ctx = &self.seize_ctx,
.device = if (rule.device) |d| HidSeize.matchIndex(matches.items, d.vendor, d.product) else null,
.engine = TapHold.initWithLayerSink(
th_rule,
emitToVhidd,
Expand Down Expand Up @@ -1534,6 +1535,9 @@ fn injectTestKey(allocator: std.mem.Allocator, io: std.Io) !void {
const EngineSlot = struct {
seize_ctx: *SeizeCtx,
engine: TapHold,
/// Index into the seize matches of the device this slot's rule is
/// bound to. Null hears every device (the --seize-test harness).
device: ?u8 = null,
timer: c.CFRunLoopTimerRef = null,
/// Profile-only: ns-since-Timer-start when this slot's hold timer
/// is supposed to fire. Set in applyTapHoldTimer when scheduling
Expand Down Expand Up @@ -2014,12 +2018,18 @@ fn seizeInputCallback(ctx: ?*anyopaque, ev: HidSeize.Event) void {
// space-down arrives, space_slot hasn't yet transitioned to
// pending (this event is what triggers it), so the pending-
// state-filtered version of the check would miss this case.
//
// Both loops only consider slots bound to the device that sent the
// event. Rules are per device: without this, two keyboards each with
// a caps_lock rule both ran on one caps tap and emitted the tap twice.
const event_is_some_slot_source = blk: for (cx.slots) |*slot| {
if (!HidSeize.sameDevice(slot.device, ev.device)) continue;
if (slot.engine.rule.src_usage == usage16) break :blk true;
} else false;

var any_consumed = false;
for (cx.slots) |*slot| {
if (!HidSeize.sameDevice(slot.device, ev.device)) continue;
if (event_is_some_slot_source and slot.engine.rule.src_usage != usage16) {
continue;
}
Expand Down