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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: [main]
pull_request:

# Cancel superseded runs on the same ref (e.g. rapid pushes to a PR).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build-test:
name: Build & test (macOS)
runs-on: macos-15
defaults:
run:
working-directory: driver
steps:
- uses: actions/checkout@v4

- name: Swift version
run: swift --version

# Builds every product, including the `nib` app target (AppKit/CoreGraphics),
# so main.swift compile errors are caught, not just the NibCore library.
- name: Build
run: swift build

# NibCore unit tests + the data-driven ProfileConformanceTests, which validate
# each DeviceProfile against its real captured fixtures (see docs/ADDING-A-DEVICE.md).
- name: Test
run: swift test
37 changes: 25 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@

# Nib

**Nib** is an independent, open-source macOS driver for the **Cintiq Pro 24 Touch
(model DTH2420)** pen display. It was built from scratch — via clean-room reverse
engineering — because Wacom's drivers frequently break on new macOS releases. This one
was created specifically because the Wacom drivers **do not work on the macOS 27
developer beta**, leaving the tablet unusable. No dependency on OpenTabletDriver or any
**Nib** is an independent, open-source macOS driver for **Wacom pen displays**, built from
scratch — via clean-room reverse engineering — because Wacom's drivers frequently break on
new macOS releases. This one was created specifically because the Wacom drivers **do not
work on the macOS 27 developer beta**, leaving the tablet unusable. Its reference device is
the **Cintiq Pro 24 Touch (model DTH2420)** — the model it is built on and verified with —
but all model-specific details live in swappable **device profiles**, so other Wacom
tablets can be added as data, not new decode code. No dependency on OpenTabletDriver or any
vendor software at runtime.

**Working today:** pen (position with in-app calibration, an adjustable pressure curve,
tilt, remappable side buttons), multi-touch (2-finger scroll, tap-to-click, 3-finger
swipe, palm rejection), a menu-bar app and a full preferences window, running as a
persistent per-user LaunchAgent.
**Working today:** automatic detection of the connected tablet with hotplug re-resolution;
pen (position with in-app calibration, an adjustable pressure curve, tilt, remappable side
buttons), multi-touch (2-finger scroll, tap-to-click, 3-finger swipe, palm rejection), a
menu-bar app and a full preferences window, running as a persistent per-user LaunchAgent.

## Screenshots

Expand All @@ -28,19 +30,30 @@ In-app calibration overlay — tap the two crosshairs with the pen:

<img src="docs/screenshots/calibration.jpg" alt="Calibration overlay" width="640">

## Supported devices
- **Wacom Cintiq Pro 24 Touch (DTH2420)** — the reference device, fully verified.

Nib resolves the connected tablet at runtime from a set of device profiles and re-resolves
on hotplug. Other Wacom models can be added from a capture bundle **without shipping
hardware** — see `docs/ADDING-A-DEVICE.md`.

## Layout
- `driver/` — the Swift package (`nib` executable + `NibCore` library). See
`driver/README.md` (usage) and `driver/INSTALL.md` (persistent install + code signing).
- `spec/DTH2420-protocol.md` — the reverse-engineered device protocol (USB IDs, pen
report 0x10, touch report 0x81, pen mode-switch, touch enable) — the clean-room deliverable.
- `recon/` — reverse-engineering findings and decompilation notes.
- `captures/` — live HID captures (report descriptors) and the `hidinfo` dump tool.
- `docs/ADDING-A-DEVICE.md` — how to add another tablet from a capture bundle (no code in
the decode path; model support is a `DeviceProfile` + regression fixtures).
- `ROADMAP.md` — planned work and stretch ideas.

## How it works
1. `IOHIDManager` opens the pen (`056a:0351`) and touch (`056a:0355`) HID interfaces.
2. On attach it sends the enable/mode-switch reports needed to put the tablet into its
native high-resolution mode (recovered during reverse engineering).
1. On launch (and on hotplug) Nib detects the connected tablet from its **device profiles**
and opens that model's pen and touch HID interfaces via `IOHIDManager` (the DTH2420's
are `056a:0351` / `056a:0355`).
2. On attach it sends the active profile's enable/mode-switch reports to put the tablet
into its native high-resolution mode (recovered during reverse engineering).
3. Reports are decoded by pure-Swift parsers (unit-tested against real captured reports).
4. Input is injected via CoreGraphics tablet events (pen) and scroll/key events (touch) —
an entirely userspace path, no kernel extension.
Expand Down
12 changes: 11 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@
Nib is fully usable today (see the feature list in `README.md` / `driver/README.md`).
These are the remaining and possible enhancements.

## Done
- [x] **Device-config abstraction** — all model-specific identity, report layouts,
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`.

## 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`).

## Stretch / ideas
- [ ] **Safari swipe-navigation** — phased/gesture scroll so a 2-finger horizontal swipe
triggers back/forward (currently plain horizontal scroll; a phased attempt felt janky).
- [ ] **Pinch-to-zoom** — needs private CoreGraphics gesture events.
- [ ] More configurable gesture and pen-button mappings.
- [ ] A device-config abstraction so other Wacom models can be added.
- [ ] **Descriptor auto-parsing** — derive a `DeviceProfile` layout from a submitted HID
report descriptor (offline generator first; runtime parsing only if a generic
"plug-any-Wacom" fallback becomes a goal).
173 changes: 173 additions & 0 deletions docs/ADDING-A-DEVICE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Adding support for another tablet

Nib was built for one device (the Cintiq Pro 24 Touch, DTH2420) but is structured so that
**adding a model is data, not new decode code**. Everything model-specific lives in a
`DeviceProfile` (`driver/Sources/NibCore/DeviceProfile.swift`): USB IDs, report byte
layouts, the pen mode-switch, the touch-enable sequence, and the panel size. The parser,
mode-switch, touch-enable and display match all read from the active profile, chosen at
runtime from whatever tablet is plugged in.

The key consequence: **someone who owns the hardware can contribute a capture, and a
maintainer who does not own it can turn that capture into a tested profile.** This doc is
the workflow for both roles.

---

## What a contribution needs (the "capture bundle")

If you have a tablet Nib doesn't support yet, you can produce everything needed by running
two tools. A complete bundle is:

1. **The HID report descriptor dump** — `hidinfo` output for your device.
2. **Labeled raw report captures** — `nib capture` output while you perform specific
gestures, each clearly labeled (see the gesture list below).
3. **Basic device facts** — model name/number, and the native panel resolution in pixels
(for a pen *display*; skip for an opaque tablet).

Attach these to an issue/PR. That's enough for a maintainer to author the profile and the
regression fixtures — no shipping the hardware.

---

## Step 1 — capture the descriptor

Build the little enumerator (once) and dump descriptors for all connected Wacom devices:

```
cd captures
clang -o hidinfo hidinfo.c -framework IOKit -framework CoreFoundation
./hidinfo > my-device-descriptors.txt
```

This lists each HID interface with its **VID/PID/usagePage/usage** and the raw report
descriptor bytes. Identify the **pen** interface (a digitizer, usagePage `0x0d` or a
vendor page with a high-res coordinate report) and the **touch** interface (a
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 > my-device-capture.txt
```

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):

**Pen (find the pen PID's `id=0x..` lines):**
- Pen tip touching the **top-left** corner, then **center**, then **bottom-right** — this
pins down the X/Y byte offsets and their logical max.
- **Light press** and **hard press** at center — pressure offset and its max.
- **Hover** (in range, not touching) and **lift** (fully away) — the status/proximity bits.
- Optional: **tilt** the pen each way; press each **side button**; flip to the **eraser**.

**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
> bundle; it needs a person with the device to help recover (see "What still needs the
> hardware").

---

## Step 3 — author the profile (maintainer)

Read the offsets and logical maxima straight out of the descriptor (the DTH2420 worked
example is `spec/DTH2420-protocol.md`), confirm them against the captured gestures, and add
a `DeviceProfile` literal. Template:

```swift
public extension DeviceProfile {
static let myModel = DeviceProfile(
name: "Vendor Model Name (MODELNO)",
vendorID: 0x____, // from hidinfo
penPID: 0x____, // pen interface PID
touchPID: 0x____, // touch interface PID
pen: PenLayout(
reportID: 0x__, // the high-res pen report's ID
reportLength: __, // full report length in bytes, incl. the ID byte
offsets: PenLayout.Offsets(
status: 1, x: 2, y: 5, pressure: 8, // byte offsets into the full report
tiltX: 10, tiltY: 11, twist: 12,
distance: 16, serial: 17, toolID: 21),
xLogicalMax: _____, // logical max from the descriptor's X usage
yLogicalMax: _____,
pressureLogicalMax: ____),
touch: TouchLayout(
reportID: 0x__,
minReportLength: 64,
fingerRecordSize: 6, // bytes per finger record
trailerSize: 3, // scanTime(2) + contactCount(1)
xLogicalMax: _____,
yLogicalMax: _____),
initSequence: [ // pen mode-switch; [] if the pen needs no poking
FeatureWrite(reportID: 0x02, [0x02, 0x02]),
],
touchEnable: [ // touch enable; [] if touch works unprompted
OutputWrite(reportID: 0x06, /* 64-byte vendor buffer */ []),
],
panelSize: CGSize(width: ____, height: ____)) // nil for an opaque tablet
}
```

Then register it so runtime detection and the tests pick it up:

```swift
static let all: [DeviceProfile] = [.dth2420, .myModel]
```

## Step 4 — add regression fixtures

Turn the labeled captures into a fixture bundle in
`driver/Tests/NibCoreTests/DeviceFixtures.swift` and append it to `DeviceFixtures.all`.
Assert only the values you can confidently label (leave the rest `nil`):

```swift
static let myModel = DeviceFixtureBundle(
profile: .myModel,
pen: [
PenFixture(name: "center press", hex: "…captured hex…",
tipDown: true, inRange: true, x: 0x____, y: 0x____, pressure: 0x____),
// top-left, bottom-right, hover, lift …
],
touch: [
TouchFixture(name: "one finger", hex: "…", contactCount: 1, firstX: 0x____, firstY: 0x____),
])
```

`ProfileConformanceTests` then validates the new profile automatically — no per-device test
code. Run:

```
cd driver && swift test
```

Green means the profile decodes real hardware output correctly. This is the check a
maintainer relies on to accept a device they can't physically test.

## Step 5 — verify on hardware (contributor)

Build and run (`driver/INSTALL.md`), plug in the device, and confirm from
`~/Library/Logs/nib.log` that Nib logs `now driving <your model>` and that pen tracking,
pressure, and touch behave. Report the result on the PR.

---

## What still needs the hardware

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).
- **The touch-enable sequence** (`touchEnable`) — likewise for the multitouch sensor.
- **Final sign-off** — that pen tracking, pressure curve and gestures actually feel right.

So the split is: maintainers own the abstraction and the profile authoring; contributors
run two capture commands and do the final on-device confirmation. Neither needs to ship the
tablet anywhere.
53 changes: 50 additions & 3 deletions driver/Sources/CNibHID/CNibHID.c
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ struct NibHIDContext {
uint8_t inputBuffer[64];
};

// 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
int v = 0;
if (p && CFGetTypeID(p) == CFNumberGetTypeID())
CFNumberGetValue((CFNumberRef)p, kCFNumberIntType, &v);
return (uint16_t)v;
}

static void inputReportCB(void *context, IOReturn result, void *sender,
IOHIDReportType type, uint32_t reportID,
uint8_t *report, CFIndex reportLength) {
Expand All @@ -31,14 +40,19 @@ static void matchCB(void *context, IOReturn result, void *sender, IOHIDDeviceRef
IOHIDDeviceRegisterInputReportCallback(dev, ctx->inputBuffer,
sizeof(ctx->inputBuffer),
inputReportCB, ctx);
if (ctx->onAttach) ctx->onAttach(ctx->userContext, ctx->vendorID, ctx->productID);
// 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)),
readDeviceID(dev, CFSTR(kIOHIDProductIDKey)));
}

static void removalCB(void *context, IOReturn result, void *sender, IOHIDDeviceRef dev) {
(void)result; (void)sender; (void)dev;
(void)result; (void)sender;
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;
if (ctx->onDetach) ctx->onDetach(ctx->userContext, ctx->vendorID, ctx->productID);
if (ctx->onDetach) ctx->onDetach(ctx->userContext, vid, pid);
}

static CFDictionaryRef makeMatch(uint16_t vid, uint16_t pid) {
Expand All @@ -58,6 +72,39 @@ static CFDictionaryRef makeMatch(uint16_t vid, uint16_t pid) {
return d;
}

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 (vendorID) {
CFDictionaryRef m = makeMatch(vendorID, 0);
IOHIDManagerSetDeviceMatching(mgr, m);
CFRelease(m);
} else {
IOHIDManagerSetDeviceMatching(mgr, NULL); // match everything
}
IOHIDManagerOpen(mgr, kIOHIDOptionsTypeNone);
CFSetRef devices = IOHIDManagerCopyDevices(mgr);
size_t n = 0;
if (devices) {
CFIndex count = CFSetGetCount(devices);
const void **arr = calloc((size_t)count, sizeof(void *));
if (arr) {
CFSetGetValues(devices, arr);
for (CFIndex i = 0; i < count && n < maxOut; i++) {
IOHIDDeviceRef dev = (IOHIDDeviceRef)arr[i];
out[n].vendorID = readDeviceID(dev, CFSTR(kIOHIDVendorIDKey));
out[n].productID = readDeviceID(dev, CFSTR(kIOHIDProductIDKey));
n++;
}
free(arr);
}
CFRelease(devices);
}
IOHIDManagerClose(mgr, kIOHIDOptionsTypeNone);
CFRelease(mgr);
return n;
}

NibHIDContext *nib_hid_create(uint16_t vendorID, uint16_t productID,
NibHIDReportCallback onReport,
NibHIDDeviceCallback onAttach,
Expand Down
11 changes: 11 additions & 0 deletions driver/Sources/CNibHID/include/CNibHID.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ typedef void (*NibHIDDeviceCallback)(void *ctx, uint16_t vendorID, uint16_t prod

typedef struct NibHIDContext NibHIDContext;

// A connected HID device's USB identity, as returned by nib_hid_enumerate().
typedef struct {
uint16_t vendorID;
uint16_t productID;
} NibHIDDeviceInfo;

// Enumerate currently-connected HID devices matching `vendorID` (pass 0 for any
// vendor). Writes up to `maxOut` entries into `out`; returns the number written.
// Synchronous — opens a throwaway manager, does not require a run loop.
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().
NibHIDContext *nib_hid_create(uint16_t vendorID, uint16_t productID,
Expand Down
Loading
Loading