Skip to content
Merged
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
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ pub fn build(b: *std.Build) void {
});
linkGrabberFrameworks(b, grabber_mod);
grabber_mod.addImport("grabber_protocol", grabber_protocol_mod);
addVersionImport(b, grabber_mod);

const grabber_exe = b.addExecutable(.{
.name = "skhd-grabber",
Expand Down
17 changes: 16 additions & 1 deletion src/agent_grabber_client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ pub const Client = struct {
allocator: std.mem.Allocator,
io: std.Io,
stream: std.Io.net.Stream,
/// Running grabber's build version, captured from the hello-ok reply
/// (null until `hello()` succeeds, or if the grabber is too old to
/// send it). Owned by the client; freed in `close`.
grabber_version: ?[]u8 = null,

pub fn connect(allocator: std.mem.Allocator, io: std.Io, socket_path: []const u8) !Client {
const addr = std.Io.net.UnixAddress.init(socket_path) catch |err| {
Expand All @@ -39,6 +43,7 @@ pub const Client = struct {
}

pub fn close(self: *Client) void {
if (self.grabber_version) |v| self.allocator.free(v);
self.stream.close(self.io);
self.* = undefined;
}
Expand Down Expand Up @@ -106,7 +111,17 @@ fn expectOk(client: *Client) !void {
const t = obj.get("type") orelse return error.BadResponse;
if (t != .string) return error.BadResponse;

if (std.mem.eql(u8, t.string, "ok")) return;
if (std.mem.eql(u8, t.string, "ok")) {
// Capture the grabber's reported version (parsed json is freed on
// return, so dupe into client-owned memory).
if (obj.get("grabber_version")) |v| {
if (v == .string) {
if (client.grabber_version) |old| client.allocator.free(old);
client.grabber_version = client.allocator.dupe(u8, v.string) catch null;
}
}
return;
}

if (std.mem.eql(u8, t.string, "error")) {
const code = if (obj.get("code")) |v| (if (v == .string) v.string else "?") else "?";
Expand Down
9 changes: 6 additions & 3 deletions src/grabber/DeviceNotify.zig
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,12 @@ fn drainAndLog(iter: c.io_iterator_t, kind: []const u8) void {
if (svc == c.IO_OBJECT_NULL) break;
var id: u64 = 0;
_ = c.IORegistryEntryGetRegistryEntryID(svc, &id);
// warn: rare (only on real keyboard enumeration changes), so it
// stays in the ReleaseFast log as the record of why we re-seized.
log.warn("keyboard {s}: entry_id={d}", .{ kind, id });
// info, NOT warn: this fires on every keyboard enumeration change
// (each wake, USB plug, vhidd reconnect) — routine operation, not
// an anomaly. Compiled out of ReleaseFast so a forever-running
// daemon's release log doesn't accumulate per-wake noise; visible
// in a ReleaseSafe diagnostic build.
log.info("keyboard {s}: entry_id={d}", .{ kind, id });
_ = c.IOObjectRelease(svc);
}
}
Expand Down
20 changes: 20 additions & 0 deletions src/grabber/HidSeize.zig
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ pub fn start(self: *Self, mode: Mode) !void {
if (count == 0) {
log.warn("matching dictionary captured 0 devices — vendor/product mismatch?", .{});
}
// Observability: log the registry entry id of each seized device so a
// recurrence shows exactly which device the seize holds (vs the live
// keyboard's id from `ioreg`) — the gap that forced us to infer it.
if (mode == .seize) self.logSeizedEntryIds(matched, count);

// No post-match filtering needed: the matching dicts are exact. The
// (0,0) built-in matches only Transport ∈ {FIFO,SPI} keyboards (the
Expand All @@ -310,6 +314,22 @@ pub fn start(self: *Self, mode: Mode) !void {
}
}

/// Log the IORegistry entry id of each device in the seized set.
/// Best-effort — bails on any allocation/IOKit hiccup.
fn logSeizedEntryIds(self: *Self, set: ?*anyopaque, count: usize) void {
if (set == null or count == 0) return;
const refs = self.allocator.alloc(?*const anyopaque, count) catch return;
defer self.allocator.free(refs);
c.CFSetGetValues(set, refs.ptr);
for (refs) |ref| {
const dev: c.IOHIDDeviceRef = @constCast(ref);
const svc = c.IOHIDDeviceGetService(dev);
var id: u64 = 0;
if (svc != 0) _ = c.IORegistryEntryGetRegistryEntryID(svc, &id);
log.info("seized device entry_id={d}", .{id});
}
}

/// Set HIDKeyboardCapsLockDelayOverride=0 on every event-system
/// service. Disables Apple's firmware-level "hold caps_lock for ~150ms
/// to toggle" behavior — without this the toggle still fires through
Expand Down
9 changes: 8 additions & 1 deletion src/grabber/Ipc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const protocol = @import("grabber_protocol");

const log = std.log.scoped(.grabber_ipc);

/// This grabber's build version, returned in the hello-ok reply so
/// `skhd --status` can show the running grabber's version over IPC.
const grabber_version = std.mem.trimEnd(u8, @embedFile("VERSION"), "\n\r\t ");

/// Owned (deep-copied) rules and remaps from one apply_rules
/// message. Caller takes ownership and is responsible for freeing
/// each Rule's hold_layer slice plus the rules and remaps slices
Expand Down Expand Up @@ -135,7 +139,10 @@ fn handleHello(allocator: std.mem.Allocator, sw: *std.Io.net.Stream.Writer, msg:
return error.VersionMismatch;
}
log.info("hello from uid={d} version={d}", .{ uid, version });
try sendOk(allocator, sw);
// Reply ok with our build version so the status path can read the
// running grabber's version (extra field; older agents ignore it).
try protocol.writeMessage(&sw.interface, allocator, .{ .type = "ok", .grabber_version = grabber_version });
try sw.interface.flush();
return uid;
}

Expand Down
203 changes: 203 additions & 0 deletions src/grabber/PowerNotify.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
//! System sleep/wake hook — releases the seize before sleep and
//! re-acquires it on wake, so the seize never spans the sleep power
//! transition.
//!
//! Root cause it addresses: holding an IOHIDManager seize across sleep
//! leaves it stale on wake — the keyboard powers down mid-sleep while
//! seized, and on wake the manager still reports the device (same
//! registry id, matched_count=1) but the event pipe is dead. Re-seizing
//! the same device in place does NOT revive it; only a full device
//! re-enumeration does. So instead we don't hold the seize across sleep
//! at all (the pattern Karabiner-Elements uses: devices are "ungrabbable
//! while system_sleeping").
//!
//! - kIOMessageSystemWillSleep → on_will_sleep (daemon tears down the
//! seize, marks itself sleeping), THEN ack the sleep after a short
//! delay so the release propagates to the kernel before the device
//! powers down (Karabiner delays its ack ~1s for the same reason).
//! - kIOMessageSystemHasPoweredOn → on_wake (daemon clears sleeping and
//! re-acquires a fresh seize on the now-healthy device).
//! - kIOMessageCanSystemSleep → ack immediately (we never veto sleep).
//!
//! Lifetime: one PowerNotify per Daemon. init registers for system power
//! and schedules its run-loop source; deinit removes it and releases the
//! port + root-domain connection + ack timer.

const std = @import("std");
const c = @import("c.zig");

const log = std.log.scoped(.power);

/// Delay between releasing the seize and acking SystemWillSleep, so the
/// IOHIDManagerClose has propagated in the kernel before the device loses
/// power. Matches Karabiner's 1s. It adds this much latency to sleep,
/// which is imperceptible for a lid close.
const will_sleep_ack_delay_s: f64 = 1.0;

pub const Callback = *const fn (ctx: ?*anyopaque) void;

allocator: std.mem.Allocator,
root_port: c.io_connect_t = 0,
notifier: c.io_object_t = 0,
notify_port: c.IONotificationPortRef = null,
run_loop_source: c.CFRunLoopSourceRef = null,
/// One-shot timer for the delayed SystemWillSleep ack.
ack_timer: c.CFRunLoopTimerRef = null,
/// Notification id captured at WillSleep, acked when ack_timer fires.
pending_ack_id: isize = 0,
on_will_sleep: Callback,
on_wake: Callback,
ctx: ?*anyopaque,

const Self = @This();

/// Singleton — the C callback's refcon carries `self`, but the ack timer
/// callback needs to find us too and CFRunLoopTimerContext is set up the
/// same way. One Daemon → one PowerNotify, so a global is simplest.
var instance: ?*Self = null;

pub fn init(
allocator: std.mem.Allocator,
on_will_sleep: Callback,
on_wake: Callback,
ctx: ?*anyopaque,
) !*Self {
if (instance != null) return error.AlreadyInitialized;

const self = try allocator.create(Self);
errdefer allocator.destroy(self);
self.* = .{
.allocator = allocator,
.on_will_sleep = on_will_sleep,
.on_wake = on_wake,
.ctx = ctx,
};
instance = self;
errdefer instance = null;

var port: c.IONotificationPortRef = null;
var notifier: c.io_object_t = 0;
const root_port = c.IORegisterForSystemPower(self, &port, powerCallback, &notifier);
if (root_port == 0) {
log.err("IORegisterForSystemPower returned MACH_PORT_NULL", .{});
return error.RegisterFailed;
}
errdefer {
_ = c.IODeregisterForSystemPower(&notifier);
if (port) |p| c.IONotificationPortDestroy(p);
}

const source = c.IONotificationPortGetRunLoopSource(port);
if (source == null) {
log.err("IONotificationPortGetRunLoopSource returned null", .{});
return error.RunLoopSourceFailed;
}
c.CFRunLoopAddSource(c.CFRunLoopGetCurrent(), source, c.kCFRunLoopDefaultMode);

self.root_port = root_port;
self.notifier = notifier;
self.notify_port = port;
self.run_loop_source = source;
log.info("registered for system power notifications (release-on-sleep)", .{});
return self;
}

pub fn deinit(self: *Self) void {
self.cancelAckTimer();
if (self.run_loop_source) |src| {
c.CFRunLoopRemoveSource(c.CFRunLoopGetCurrent(), src, c.kCFRunLoopDefaultMode);
self.run_loop_source = null; // owned by the port; don't release
}
if (self.notifier != 0) {
_ = c.IODeregisterForSystemPower(&self.notifier);
self.notifier = 0;
}
if (self.notify_port) |p| {
c.IONotificationPortDestroy(p);
self.notify_port = null;
}
self.root_port = 0;
instance = null;
self.allocator.destroy(self);
}

fn cancelAckTimer(self: *Self) void {
if (self.ack_timer) |t| {
c.CFRunLoopTimerInvalidate(t);
c.CFRelease(t);
self.ack_timer = null;
}
}

fn powerCallback(
refcon: ?*anyopaque,
service: c.io_service_t,
messageType: u32,
messageArgument: ?*anyopaque,
) callconv(.c) void {
_ = service;
const self: *Self = @ptrCast(@alignCast(refcon orelse return));
const arg_id: isize = @bitCast(@intFromPtr(messageArgument));

switch (messageType) {
c.kIOMessageCanSystemSleep => {
// We never veto sleep — ack immediately.
_ = c.IOAllowPowerChange(self.root_port, arg_id);
},
c.kIOMessageSystemWillSleep => {
log.info("system will sleep — releasing seize before sleep", .{});
// Release the seize NOW (synchronous), then ack after a short
// delay so the release lands before the device powers down.
self.on_will_sleep(self.ctx);
self.scheduleWillSleepAck(arg_id);
},
c.kIOMessageSystemWillPowerOn => {
log.info("system will power on (early wake)", .{});
},
c.kIOMessageSystemHasPoweredOn => {
log.info("system has powered on — re-acquiring seize", .{});
self.on_wake(self.ctx);
},
else => {
log.info("unhandled power message: 0x{X:0>8}", .{messageType});
},
}
}

fn scheduleWillSleepAck(self: *Self, notification_id: isize) void {
self.cancelAckTimer();
self.pending_ack_id = notification_id;
var timer_ctx: c.CFRunLoopTimerContext = .{
.version = 0,
.info = self,
.retain = null,
.release = null,
.copyDescription = null,
};
const fire_at = c.CFAbsoluteTimeGetCurrent() + will_sleep_ack_delay_s;
const timer = c.CFRunLoopTimerCreate(
c.kCFAllocatorDefault,
fire_at,
0, // one-shot
0,
0,
willSleepAckTimerCallback,
&timer_ctx,
);
if (timer == null) {
// Couldn't schedule the delayed ack — ack now so we don't block
// sleep indefinitely. The seize is already released.
log.warn("ack timer create failed — acking sleep immediately", .{});
_ = c.IOAllowPowerChange(self.root_port, notification_id);
return;
}
self.ack_timer = timer;
c.CFRunLoopAddTimer(c.CFRunLoopGetCurrent(), timer, c.kCFRunLoopDefaultMode);
}

fn willSleepAckTimerCallback(_: c.CFRunLoopTimerRef, info: ?*anyopaque) callconv(.c) void {
const self: *Self = @ptrCast(@alignCast(info orelse return));
const id = self.pending_ack_id;
self.cancelAckTimer();
_ = c.IOAllowPowerChange(self.root_port, id);
}
55 changes: 55 additions & 0 deletions src/grabber/c.zig
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,61 @@ pub const IONotificationPortRef = ?*anyopaque;
pub extern fn IONotificationPortGetRunLoopSource(notify: IONotificationPortRef) CFRunLoopSourceRef;
pub extern fn IONotificationPortDestroy(notify: IONotificationPortRef) void;

// System sleep/wake notifications via the root power-management domain.
// PowerNotify uses these to RELEASE the seize on will-sleep and
// re-acquire it on power-on, so the seize never spans the sleep power
// transition (a seize held across sleep goes stale: same device id,
// matched_count=1, but the event pipe is dead). Can/WillSleep must be
// acked with IOAllowPowerChange or sleep is blocked. messageArgument is
// a void* on the API but used as a `long` notification id for the ack.
// IOMessage.h: iokit_common_msg(x) = 0xE0000000 | x.
pub const kIOMessageCanSystemSleep: u32 = 0xE0000270;
pub const kIOMessageSystemWillSleep: u32 = 0xE0000280;
pub const kIOMessageSystemWillPowerOn: u32 = 0xE0000320;
pub const kIOMessageSystemHasPoweredOn: u32 = 0xE0000300;

pub const IOServiceInterestCallback = ?*const fn (
refcon: ?*anyopaque,
service: io_service_t,
messageType: u32,
messageArgument: ?*anyopaque,
) callconv(.c) void;

pub extern fn IORegisterForSystemPower(
refcon: ?*anyopaque,
thePortRef: *IONotificationPortRef,
callback: IOServiceInterestCallback,
notifier: *io_object_t,
) io_connect_t;
pub extern fn IODeregisterForSystemPower(notifier: *io_object_t) IOReturn;
pub extern fn IOAllowPowerChange(kernelPort: io_connect_t, notificationID: isize) IOReturn;

// Map a seized IOHIDDevice back to its IORegistry node so we can log the
// seized device's registry entry id (observability: which device the
// seize actually holds, vs the live keyboard).
pub extern fn IOHIDDeviceGetService(device: IOHIDDeviceRef) io_service_t;

// libc time — for local-time log timestamps so grabber events correlate
// with `pmset -g log` wake/sleep times. We only read the first three
// `struct tm` fields (sec/min/hour), so the rest of the layout being
// approximate is harmless.
pub const time_t = c_long;
pub const Tm = extern struct {
tm_sec: c_int,
tm_min: c_int,
tm_hour: c_int,
tm_mday: c_int,
tm_mon: c_int,
tm_year: c_int,
tm_wday: c_int,
tm_yday: c_int,
tm_isdst: c_int,
tm_gmtoff: c_long,
tm_zone: ?[*:0]const u8,
};
pub extern fn time(t: ?*time_t) time_t;
pub extern fn localtime_r(timep: *const time_t, result: *Tm) ?*Tm;

// SystemConfiguration — read the active console user uid. D5 uses
// this to apply rules only from the foreground user's agent (so
// fast-user-switching doesn't get caps_lock-as-ctrl set up by a
Expand Down
Loading
Loading