Skip to content
Merged
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ These are the remaining and possible enhancements.
mode-switch and touch-enable live in a `DeviceProfile`, chosen at runtime from the
connected hardware (with hotplug re-resolution). Adding a model is data, not decode
code. Contribution workflow: `docs/ADDING-A-DEVICE.md`.
- [x] **Developer ID signing + notarization** — a tagged release builds a signed,
notarized DMG (`driver/RELEASING.md`), so Nib installs on other Macs without a
self-signed certificate.

## Planned
- [ ] **ExpressKeys / touch-ring** — the tablet's physical buttons and ring (pen reports
`0x11` / `0x13`), mapped to shortcuts.
- [ ] **Developer ID signing + notarization** — so Nib can be installed on other Macs
without setting up a self-signed certificate.
- [ ] **More device profiles** — bring up additional Wacom models from contributed capture
bundles (`docs/ADDING-A-DEVICE.md`).

Expand Down
24 changes: 18 additions & 6 deletions docs/ADDING-A-DEVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,25 @@ Precision-Touchpad-style digitizer). Note both PIDs.

## Step 2 — capture labeled gestures

`nib capture` opens every connected interface of the known vendors, sends the best-guess
Wacom enable sequences, and prints every report as raw hex tagged by PID:
`nib capture` opens every connected interface of the known vendors and prints every report
as raw hex tagged by PID. By default it only **listens** — it sends nothing to the tablet:

```
nib capture > my-device-capture.txt
```

If the pen produces no high-res report this way, run it again with `--send-enable`, which
also sends the DTH2420's mode-switch and touch-enable reports to every interface (these
work across much of the Wacom family):

```
nib capture --send-enable > my-device-capture-enabled.txt
```

`--send-enable` writes vendor-specific reports whose meaning on another model is unknown,
so only use it when the passive capture shows no pen report, and say in the bundle which
mode produced each file. Unplugging and replugging the tablet resets it.

Then perform each gesture below **one at a time**, pausing between them, and keep a note of
which lines correspond to which gesture (timestamps or "I did X now" markers help):

Expand All @@ -66,8 +78,8 @@ which lines correspond to which gesture (timestamps or "I did X now" markers hel
**Touch (find the touch PID's lines):**
- **One finger** down at a known spot, **two fingers**, **three fingers**.

> If the pen produces **no** high-res report under `nib capture`, its mode-switch differs
> from the DTH2420's. That's the one part the descriptor can't give you — note it in the
> If the pen produces **no** high-res report even under `nib capture --send-enable`, its
> mode-switch differs from the DTH2420's. That's the one part the descriptor can't give you — note it in the
> bundle; it needs a person with the device to help recover (see "What still needs the
> hardware").

Expand Down Expand Up @@ -163,8 +175,8 @@ The descriptor gives the **decode layout** for free. It does **not** give:
- **The pen mode-switch** (`initSequence`) — some models only emit their high-res report
after a vendor feature write. For the DTH2420 this was recovered by decompiling Wacom's
driver (`recon/`), not from any descriptor. If `nib capture` shows no pen report, this is
why, and recovering it needs someone with the device (try the DTH2420 sequence first; it
covers much of the Wacom family).
why, and recovering it needs someone with the device (try `nib capture --send-enable`
first; the DTH2420 sequence covers much of the Wacom family).
- **The touch-enable sequence** (`touchEnable`) — likewise for the multitouch sensor.
- **Final sign-off** — that pen tracking, pressure curve and gestures actually feel right.

Expand Down
56 changes: 50 additions & 6 deletions driver/Sources/CNibHID/CNibHID.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,34 @@
#include <stdlib.h>
#include <string.h>

// One matched device and its own input-report buffer (IOKit writes each report into the
// buffer registered for that device, so devices must not share one).
typedef struct NibHIDDeviceNode {
IOHIDDeviceRef device;
uint8_t *buffer; // NULL when the context has no report callback
struct NibHIDDeviceNode *next;
} NibHIDDeviceNode;

struct NibHIDContext {
IOHIDManagerRef manager;
IOHIDDeviceRef device; // first matched device
IOHIDDeviceRef device; // device used for feature/output writes
NibHIDDeviceNode *devices; // every currently matched device
uint16_t vendorID, productID;
NibHIDReportCallback onReport;
NibHIDDeviceCallback onAttach, onDetach;
void *userContext;
uint8_t inputBuffer[64];
};

enum { kNibDefaultInputReportSize = 64 };

static size_t maxInputReportSize(IOHIDDeviceRef dev) {
CFTypeRef p = IOHIDDeviceGetProperty(dev, CFSTR(kIOHIDMaxInputReportSizeKey));
int v = 0;
if (p && CFGetTypeID(p) == CFNumberGetTypeID())
CFNumberGetValue((CFNumberRef)p, kCFNumberIntType, &v);
return v > 0 ? (size_t)v : kNibDefaultInputReportSize;
}

// Read a numeric IOHIDDevice property (e.g. VendorID/ProductID). Returns 0 if absent.
static uint16_t readDeviceID(IOHIDDeviceRef dev, CFStringRef key) {
CFTypeRef p = IOHIDDeviceGetProperty(dev, key); // "get" rule: not owned, no release
Expand All @@ -36,10 +54,21 @@ static void inputReportCB(void *context, IOReturn result, void *sender,
static void matchCB(void *context, IOReturn result, void *sender, IOHIDDeviceRef dev) {
(void)result; (void)sender;
NibHIDContext *ctx = (NibHIDContext *)context;
NibHIDDeviceNode *node = calloc(1, sizeof(*node));
if (!node) return;
node->device = dev;
// Watch-only contexts (no report callback) skip report delivery entirely, so they cost
// nothing per report.
if (ctx->onReport) {
size_t len = maxInputReportSize(dev);
node->buffer = calloc(1, len);
if (node->buffer)
IOHIDDeviceRegisterInputReportCallback(dev, node->buffer, (CFIndex)len,
inputReportCB, ctx);
}
node->next = ctx->devices;
ctx->devices = node;
if (!ctx->device) ctx->device = dev;
IOHIDDeviceRegisterInputReportCallback(dev, ctx->inputBuffer,
sizeof(ctx->inputBuffer),
inputReportCB, ctx);
// Report the device's real IDs (the match filter may be vendor-only, productID = 0).
if (ctx->onAttach) ctx->onAttach(ctx->userContext,
readDeviceID(dev, CFSTR(kIOHIDVendorIDKey)),
Expand All @@ -51,7 +80,17 @@ static void removalCB(void *context, IOReturn result, void *sender, IOHIDDeviceR
NibHIDContext *ctx = (NibHIDContext *)context;
uint16_t vid = readDeviceID(dev, CFSTR(kIOHIDVendorIDKey));
uint16_t pid = readDeviceID(dev, CFSTR(kIOHIDProductIDKey));
if (ctx->device == dev) ctx->device = NULL;
for (NibHIDDeviceNode **pp = &ctx->devices; *pp; pp = &(*pp)->next) {
if ((*pp)->device == dev) {
NibHIDDeviceNode *gone = *pp;
*pp = gone->next;
free(gone->buffer);
free(gone);
break;
}
}
// Fall back to another still-matched device for writes, if any.
if (ctx->device == dev) ctx->device = ctx->devices ? ctx->devices->device : NULL;
if (ctx->onDetach) ctx->onDetach(ctx->userContext, vid, pid);
}

Expand All @@ -75,6 +114,7 @@ static CFDictionaryRef makeMatch(uint16_t vid, uint16_t pid) {
size_t nib_hid_enumerate(uint16_t vendorID, NibHIDDeviceInfo *out, size_t maxOut) {
if (!out || maxOut == 0) return 0;
IOHIDManagerRef mgr = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone);
if (!mgr) return 0;
if (vendorID) {
CFDictionaryRef m = makeMatch(vendorID, 0);
IOHIDManagerSetDeviceMatching(mgr, m);
Expand Down Expand Up @@ -111,10 +151,12 @@ NibHIDContext *nib_hid_create(uint16_t vendorID, uint16_t productID,
NibHIDDeviceCallback onDetach,
void *userContext) {
NibHIDContext *ctx = calloc(1, sizeof(*ctx));
if (!ctx) return NULL;
ctx->vendorID = vendorID; ctx->productID = productID;
ctx->onReport = onReport; ctx->onAttach = onAttach; ctx->onDetach = onDetach;
ctx->userContext = userContext;
ctx->manager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone);
if (!ctx->manager) { free(ctx); return NULL; }
CFDictionaryRef m = makeMatch(vendorID, productID);
IOHIDManagerSetDeviceMatching(ctx->manager, m);
CFRelease(m);
Expand Down Expand Up @@ -159,5 +201,7 @@ void nib_hid_stop(NibHIDContext *ctx) {
void nib_hid_free(NibHIDContext *ctx) {
if (!ctx) return;
if (ctx->manager) CFRelease(ctx->manager);
NibHIDDeviceNode *n = ctx->devices;
while (n) { NibHIDDeviceNode *next = n->next; free(n->buffer); free(n); n = next; }
free(ctx);
}
4 changes: 3 additions & 1 deletion driver/Sources/CNibHID/include/CNibHID.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ typedef struct {
size_t nib_hid_enumerate(uint16_t vendorID, NibHIDDeviceInfo *out, size_t maxOut);

// Create a manager that matches (vendorID, productID). Pass productID = 0 to
// match any product for the vendor. Does not open until nib_hid_run().
// match any product for the vendor. Pass onReport = NULL for a watch-only manager
// (attach/detach callbacks, no report delivery). Does not open until nib_hid_start().
// Returns NULL if allocation fails.
NibHIDContext *nib_hid_create(uint16_t vendorID, uint16_t productID,
NibHIDReportCallback onReport,
NibHIDDeviceCallback onAttach,
Expand Down
107 changes: 107 additions & 0 deletions driver/Sources/NibCore/CalibrationSolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import CoreGraphics

/// Two-point pen calibration, shared by the `calibrate` CLI and the in-app overlay.
///
/// The user holds the pen tip on target 1, lifts, then holds it on target 2. A hold counts
/// once `holdSamples` consecutive tip-down samples stay within `tolerance` raw units. The two
/// captured raw points and the targets' known positions (as fractions of the display) give
/// the raw pen range that spans the display.
public struct CalibrationSolver {
public enum Event: Equatable {
case none
/// Target 1 captured; waiting for the pen to lift.
case firstCaptured
/// Pen lifted after target 1; now waiting on target 2.
case awaitingSecond
/// Both targets captured and the result is plausible.
case finished(Calibration)
/// The two points were too close or in the wrong order; restarted at target 1.
case rejected
}

/// Target positions as fractions of the display (0...1), target 1 before target 2.
public let target1: CGPoint
public let target2: CGPoint
public var holdSamples = 40
public var tolerance = 1200
/// The two raw points must be at least this fraction of the logical range apart on
/// each axis (in the direction of the targets).
public var minSpanFraction = 0.1
private let xLogicalMax: Double
private let yLogicalMax: Double

private enum Stage { case holdFirst, lift, holdSecond, done }
private var stage = Stage.holdFirst
private var buf: [(Int, Int)] = []
private var raw1 = (0.0, 0.0)

public init(target1: CGPoint, target2: CGPoint, pen: PenLayout) {
self.target1 = target1
self.target2 = target2
self.xLogicalMax = Double(pen.xLogicalMax)
self.yLogicalMax = Double(pen.yLogicalMax)
}

/// Fraction position of the target currently being asked for.
public var currentTarget: CGPoint { stage == .holdFirst ? target1 : target2 }

public mutating func reset() { stage = .holdFirst; buf.removeAll() }

public mutating func feed(_ s: PenSample) -> Event {
guard stage != .done else { return .none }
if !s.tipDown || !s.inRange {
buf.removeAll()
if stage == .lift { stage = .holdSecond; return .awaitingSecond }
return .none
}
guard stage == .holdFirst || stage == .holdSecond else { buf.removeAll(); return .none }
buf.append((s.x, s.y))
if buf.count > holdSamples { buf.removeFirst() }
guard buf.count == holdSamples, isSteady() else { return .none }

let m = mean()
buf.removeAll()
if stage == .holdFirst {
raw1 = m; stage = .lift
return .firstCaptured
}
if let cal = Self.solve(raw1: raw1, raw2: m, target1: target1, target2: target2,
xLogicalMax: xLogicalMax, yLogicalMax: yLogicalMax,
minSpanFraction: minSpanFraction) {
stage = .done
return .finished(cal)
}
stage = .holdFirst
return .rejected
}

/// Solve raw = a·fraction + b on each axis and return the raw range at fractions 0 and 1,
/// or nil if the points are implausible (too close, or ordered against the targets).
public static func solve(raw1: (Double, Double), raw2: (Double, Double),
target1: CGPoint, target2: CGPoint,
xLogicalMax: Double, yLogicalMax: Double,
minSpanFraction: Double = 0.1) -> Calibration? {
func axis(_ r1: Double, _ r2: Double, _ f1: Double, _ f2: Double, _ logicalMax: Double) -> (Double, Double)? {
guard f1 != f2 else { return nil }
let towardTarget2 = (r2 - r1) * (f2 > f1 ? 1 : -1)
guard towardTarget2 >= minSpanFraction * logicalMax else { return nil }
let a = (r2 - r1) / (f2 - f1)
let b = r1 - a * f1
return (b, a + b)
}
guard let (xMin, xMax) = axis(raw1.0, raw2.0, Double(target1.x), Double(target2.x), xLogicalMax),
let (yMin, yMax) = axis(raw1.1, raw2.1, Double(target1.y), Double(target2.y), yLogicalMax)
else { return nil }
return Calibration(xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax)
}

private func isSteady() -> Bool {
let xs = buf.map { $0.0 }, ys = buf.map { $0.1 }
return xs.max()! - xs.min()! < tolerance && ys.max()! - ys.min()! < tolerance
}

private func mean() -> (Double, Double) {
(buf.reduce(0.0) { $0 + Double($1.0) } / Double(buf.count),
buf.reduce(0.0) { $0 + Double($1.1) } / Double(buf.count))
}
}
32 changes: 32 additions & 0 deletions driver/Sources/NibCore/ClickCounter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation
import CoreGraphics

/// Computes the click count (`.mouseEventClickState`) for synthesized mouse-downs. The
/// window server does not derive it for posted events, so without this every click is a
/// single click and apps never see a double-click.
public struct ClickCounter: Sendable {
/// Max time between downs to continue a multi-click (set from NSEvent.doubleClickInterval).
public var interval: TimeInterval
/// Max distance (points) between downs to continue a multi-click.
public var maxDistance: Double

private var lastDown: (time: TimeInterval, point: CGPoint)?
private var count = 0

public init(interval: TimeInterval = 0.5, maxDistance: Double = 6) {
self.interval = interval
self.maxDistance = maxDistance
}

/// Register a mouse-down and return its click count (1 = single, 2 = double, …).
public mutating func down(at point: CGPoint, time: TimeInterval) -> Int {
if let last = lastDown, time - last.time <= interval,
hypot(point.x - last.point.x, point.y - last.point.y) <= maxDistance {
count += 1
} else {
count = 1
}
lastDown = (time, point)
return count
}
}
13 changes: 11 additions & 2 deletions driver/Sources/NibCore/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public struct NibConfig: Codable, Sendable, Equatable {

public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
var def = NibConfig()
let def = NibConfig()
touchEnabled = try c.decodeIfPresent(Bool.self, forKey: .touchEnabled) ?? def.touchEnabled
palmRejection = try c.decodeIfPresent(Bool.self, forKey: .palmRejection) ?? def.palmRejection
tapToClick = try c.decodeIfPresent(Bool.self, forKey: .tapToClick) ?? def.tapToClick
Expand All @@ -28,7 +28,16 @@ public struct NibConfig: Codable, Sendable, Equatable {
lowerBarrel = try c.decodeIfPresent(PenButtonAction.self, forKey: .lowerBarrel) ?? def.lowerBarrel
upperBarrel = try c.decodeIfPresent(PenButtonAction.self, forKey: .upperBarrel) ?? def.upperBarrel
pressureCurve = try c.decodeIfPresent(PressureCurve.self, forKey: .pressureCurve) ?? def.pressureCurve
_ = def
}

/// Natural scrolling to apply: the explicit override if set, else the system setting
/// (`com.apple.swipescrolldirection`, read from `defaults`; macOS defaults to true).
public func effectiveNaturalScrolling(defaults: UserDefaults = .standard) -> Bool {
if let v = naturalScrolling { return v }
if defaults.object(forKey: "com.apple.swipescrolldirection") != nil {
return defaults.bool(forKey: "com.apple.swipescrolldirection")
}
return true
}

public static var fileURL: URL {
Expand Down
3 changes: 1 addition & 2 deletions driver/Sources/NibCore/DeviceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ public final class DeviceManager {
let watcher = HIDTransport(
vendorID: v, productID: 0, // any product for this vendor
onAttach: { [weak self] in self?.reconcile() },
onDetach: { [weak self] in self?.reconcile() },
onReport: { _, _ in }) // watch-only; no report decoding
onDetach: { [weak self] in self?.reconcile() }) // watch-only: no reports
watcher.start()
watchers.append(watcher)
}
Expand Down
Loading
Loading