A from-scratch, open-source macOS driver for the XP-Pen Deco 01 V3: native arm64, no Rosetta, no vendor code, no third-party dependencies, no telemetry, no network access. Uses only IOKit (to read the tablet) and CoreGraphics (to inject events).
- Requirements: macOS 14+, Xcode command line tools. Apple Silicon or Intel.
- Size: the whole app is under 1 MB; the headless driver is ~420 KB.
- Nothing to configure to get started: it finds the tablet, maps it to your main display and works. Everything else is optional.
| What you get | |
|---|---|
| Pen | position, 14-bit pressure, tilt, both barrel buttons |
| Pressure test | a live view in the app: draw on the tablet, watch line width follow pressure, with raw hardware pressure graphed against what applications receive |
| Express keys | 8 keys, each bindable to any of the vendor's actions, or a shortcut you record |
| Wheel mode | hold a key and move the pen to scroll, 1:1 with no acceleration |
| Mapping | stretch / keep-proportions / custom screen area / all displays, rotation, invert |
| Multiple monitors | per-display settings, switch displays from a bound key |
| Menu bar app | full settings window, JSON config, starts at login |
- The original driver was buggy for me: shortcuts fired unexpectedly and it clashed with other input software.
- I watched it send traffic to XP-Pen servers (
data.tr.x-pen.com.cn,driverinfo.xp-pen.com.cn). The collection path is off by default in 4.0.18, but that is not knowable from outside. - Privacy by construction: no network API is imported at all, so there is no switch to trust. It keeps no log file and no diagnostics file either. The only thing it ever writes is your own configuration.
- I wanted to audit it. 3,541 lines, 48 Apple API entry points, readable in one sitting.
- No source ships with the vendor driver, so nothing can be fixed when it breaks.
- Hardware outlives support. The tablet works; the software around it will not be updated forever.
- I wanted to know what the device actually sends, not what a copied config file claims.
- Settings should be a file I own, not proprietary XML only the vendor's GUI can write.
- 102 MB, three background processes and a Qt runtime, for one tablet.
| XP-Pen 4.0.18 | InkDriver | |
|---|---|---|
| Installer | 54 MB | 272 KB |
| Installed on disk | ~102 MB | 867 KB |
| Background processes | 3 | 1 |
| Network APIs imported | yes | none |
| Apple API entry points used | hundreds, across a 27 MB Qt binary | 48 |
| Source available | no | all 3,541 lines |
The whole API surface the driver can use is CoreGraphics, IOKit's HID manager,
AXIsProcessTrusted, CoreFoundation's run loop, and libc: 48 entry points, no
socket, no URL session, no hostname or serial-number lookup, and no event tap.
That is verifiable in one command:
otool -Iv .build/release/xpdriverd | grep '^0x' | awk '{print $NF}' \
| sed 's/^_//' | grep -vE '^\$|^swift_|^_swift|^__|^_Block|^_NSConcrete|^LOCAL' | sort -uSee COMPARISON.md for the full breakdown, including an honest list of what the vendor driver does that this one does not.
Measured on the same machine, tablet, browser and test page, with the same validated instrument for both drivers. Full method and caveats in docs/BENCHMARKS.md.
| InkDriver | XP-Pen | |
|---|---|---|
| CPU idle | 0.077% of a core | 1.61% |
| CPU while drawing | 77 to 84 ms per second | 98 to 109 ms |
| Memory idle | 84 MB | 202 MB |
| Memory while drawing | 76 MB | 166 to 424 MB |
| App bundle | 1.2 MB | 100 MB |
| Bundled frameworks | none | 10 Qt frameworks |
| Pen events delivered | 62/s, 16 ms median | 62/s, 16 ms median |
Roughly 21x less CPU when idle, about 20% less while drawing, and 2.4x less memory, with an identical delivered event stream: same rate, same interval distribution, no spikes either way. Both drivers run natively, so this is not a Rosetta effect.
The 16 ms delivery cadence is the 60 Hz display refresh, not a driver property: the browser coalesces pointer movement to vsync, which is what makes it a fair common yardstick for the load both drivers were under.
End-to-end input latency is not measured here, and no claim is made about it. Both drivers produce the same interval distribution, which says neither adds jitter the other lacks, and that is the extent of what the data supports.
.build/release/xppen-probe --check-accuracy verifies the transforms arithmetically:
the mapping is exactly affine, covers the target area with no error, never escapes
the display, and keeps all 16384 pressure levels with no loss. Details in
docs/ACCURACY.md.
One real deviation, measured on hardware and inherited from the vendor: tilt is
divided by 84 while Apple's convention makes 1.0 equal 90 degrees, so a maximum
physical tilt of 60 degrees arrives as 64. Set "tiltScale": 90.0 for true degrees,
or leave it at the default 84 for exact vendor parity.
"tiltScale": 84.0, "invertTiltX": false, "invertTiltY": false,Working, and verified against real hardware. The protocol was measured with
xppen-probe and cross-checked against the vendor driver's own code, and the event
path was verified end to end in a plain AppKit application: drawing with the pen
produces ink, at subtype=1, carrying real pressure and tilt.
| Feature | State |
|---|---|
| Pen position, pressure (14-bit), tilt, both barrel buttons | working |
| 8 express keys, each bindable to a catalogue action | working |
| Wheel mode: hold a key and move the pen; 1:1 pixel scroll, no acceleration | working |
| Mapping: stretch / fit (keep proportions) / custom screen area / all displays, rotation, invert | working |
| Screen switching: cycle displays from a bound key, each with its own mapping | working |
| Per-display profiles, keyed by display ID | working |
| Adding/removing/reordering monitors needs no reconfiguration | working |
| Virtual display-layout editor showing where the tablet lands | working |
| Menu-bar UI + JSON config | working |
| Eraser mode | working: this pen has no eraser tip, so a bound key toggles it, and the tool change reaches applications as the pointing device type |
| LED, battery level, touch ring, wireless | none of these are on this model |
See docs/PROTOCOL.md for the measured protocol.
Third-party kernel extensions cannot load on Apple Silicon, and a DriverKit HID extension needs an entitlement only Apple grants, so a native driver is necessarily a userspace process, the same shape the vendor driver uses.
The three HID collections the device exposes, the two separate injection paths, and where macOS asks for permission:
flowchart LR
subgraph device["XP-Pen Deco 01 V3"]
pen["pen collection<br/>usage page 0xFF0A<br/>report ID 2, 12 bytes"]
mouse["mouse collection<br/>usage page 0x01"]
digitizer["digitizer collection<br/>usage page 0x0D"]
end
subgraph core["XPTabletCore<br/>one process, no UI code"]
hid["HIDTablet<br/>IOHIDManager + handshake 02 B0 04"]
decode["ReportDecoder<br/>x, y, 14-bit pressure, tilt, tip, buttons"]
keys["Actions<br/>express key bindings"]
area["AreaMapper<br/>tablet area to display, per-display profile,<br/>rotation, invert, fit or fill or stretch"]
inject["Injector"]
config["Config<br/>JSON, profiles keyed by display ID"]
end
subgraph os["macOS"]
cg["CGEventPost<br/>cursor, clicks, scroll, keys"]
native["IOHIDPostEvent<br/>native tablet proximity + point"]
apps["applications"]
end
pen -->|"seized, so macOS cannot move the cursor<br/>from the same tablet"| hid
mouse -.->|"seized"| hid
digitizer -.->|"seized"| hid
hid --> decode
decode -->|"pen report"| area
decode -->|"status 0xF0"| keys
config --> area
config --> keys
area --> inject
keys --> inject
inject -->|"Input Monitoring<br/>Accessibility"| cg
inject -->|"proximity gates Firefox and other<br/>apps that wait to be told a pen is in range"| native
cg --> apps
native --> apps
Why two injection paths, since it looks redundant: CGEventPost can carry a mouse
event with a tablet subtype and that is enough for Chromium and native AppKit
applications, but it cannot create a real tablet event, and Firefox ignores tablet
data until it has seen one. See docs/POST-MORTEM-FIREFOX-PEN.md.
The pipeline above lives in one target, and every front end runs the same code:
flowchart TB
agent["launchd agent<br/>com.local.inkdriver"] --> menu
menu["InkDriver.app<br/>xppen-menu, menu bar UI,<br/>area editor, key bindings"] --> coreTarget["XPTabletCore"]
daemon["xpdriverd<br/>headless, for launchd or scripting"] --> coreTarget
probe["xppen-probe<br/>read-only protocol probe<br/>and self checks"] --> coreTarget
coreTarget --> device2["the tablet"]
XPTabletCore holds the whole driver and has no UI or CLI concerns, so the
menu-bar app and the headless CLI run exactly the same code:
| Target | Purpose |
|---|---|
XPTabletCore |
protocol, HID, mapping, event injection, config, driver core |
xppen-menu |
the menu-bar app (installed as InkDriver.app) |
xpdriverd |
headless driver, for launchd or scripting |
xppen-probe |
read-only protocol probe |
Requires the Xcode command line tools. No .NET, no Rosetta.
make app # builds InkDriver.app
make install-app # copies to ~/Applications and registers a login agent
make install # CLI tools into ~/.local/binThe app name and bundle id are two variables at the top of the Makefile
(APP_NAME, BUNDLE_ID).
An ad-hoc signed app has no stable code identity: macOS keys the TCC grant (Input Monitoring, Accessibility) on the code hash, so every rebuild looks like a new app and the permissions are silently dropped:
Failed to match existing code requirement for subject … and service kTCCServicePostEvent
Support/make-signing-identity.sh creates a one-time self-signed code-signing
certificate, and the build signs with it. The requirement then becomes
identifier "com.local.inkdriver" and certificate leaf = H"5497…"
which survives rebuilds, so you grant the permission once.
| Permission | Needed for | Symptom without it |
|---|---|---|
| Input Monitoring | opening the tablet's HID interface | failed to open the tablet |
| Accessibility | posting synthetic events | driver runs, cursor never moves |
The app shows its own TCC state in the settings window, so this is never a mystery.
Run xpdriverd from a terminal and the same messages print to your screen; nothing is
written to disk, which is why there is no log file to check.
[09:37:54] seized digitizer[page=0xd usage=0x2]
[09:37:54] handshake sent: 02 B0 04
[09:37:54] tablet connected
## Configuration
Everything lives in one JSON file, written on first run and editable from the
menu bar:
~/Library/Application Support/xppen-driver/config.json
```json
{
"workspace": {
"displayID": 724053248,
"switchDisplayIDs": [],
"profiles": {
"724053249": { "mode": "fit", "rotation": 90, "invertX": false, "invertY": false,
"screenRect": { "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0 },
"tabletRect": { "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0 } }
},
"mode": "stretch",
"screenRect": { "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0 },
"tabletRect": { "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0 },
"rotation": 0,
"invertX": false,
"invertY": false
},
"tiltScale": 84.0, "invertTiltX": false, "invertTiltY": false,
"penDownPressureThreshold": 1,
"sendHandshake": true, "seizeFallbackInterfaces": true,
"scrollSensitivity": 1.0, "scrollInvertX": false, "scrollInvertY": false,
"penButton1": "mouse:right",
"penButton2": "wheel",
"expressKeys": ["none", "none", "none", "none", "none", "none", "none", "none"]
}
mode |
Behaviour |
|---|---|
stretch |
fill the target display; X and Y scale independently, so circles can become ellipses |
fit |
largest rectangle inside the display that keeps the tablet's proportions, centred, so round stays round |
custom |
map onto screenRect, an arbitrary rectangle inside the target display |
allDisplays |
treat every active display as one big surface |
screenRectandtabletRectare normalised (0…1).tabletRectrestricts the active tablet surface;screenRectpicks the target rectangle on screen.rotationis 0 / 90 / 180 / 270.switchDisplayslists the displays the Switch monitor action cycles through. Empty means every active display, in order. Bind any pen button or express key to Switch monitor (vendor action 102) to use it.
profiles holds one entry per display, keyed by CGDirectDisplayID rather than
by position in the active-display list. Anything a display does not override falls
back to the shared top-level fields.
An index is not a stable identity: attaching a monitor before an existing one renumbers everything, so index-keyed settings silently migrate onto the wrong screen. A display ID is stable for as long as that display is connected.
The practical consequences:
- Adding a monitor needs no configuration. It appears in the list and the layout view, uses the shared defaults, and is included in screen switching.
- Removing one is harmless. Its settings are ignored, the others keep theirs, and if it was the targeted display the driver falls back to the first available.
- Reordering does nothing. Settings follow the display, not the slot.
- The app re-reads the display list on
NSApplication.didChangeScreenParametersNotification, and the driver exposesrefreshDisplays().
This matters when displays differ in shape: a landscape 5K beside a portrait 4K
wants a different rotation and mode each. Changing any setting while a display is
targeted gives it its own profile; Use shared defaults removes it again.
switchDisplayIDs is the same idea for the Switch-monitor cycle.
Migration. A config written before display-ID keying is rewritten once, on
startup, using the live display list. The subtlety is that index keys are not
distinguishable from display IDs by inspection alone: real display IDs on macOS
are often small numbers, so an index "1" can look exactly like a display ID
1. A config with no displayID (or with a legacy index list) is therefore
treated as index-keyed, and one that has a displayID is treated as ID-keyed.
Stale entries for displays that are not connected are dropped rather than left to
resurface on an unrelated monitor.
Tolerant decoding: every key is optional, so an older or hand-edited config file still loads and simply contributes fallback values.
The numbers are checkable without hardware:
.build/release/xppen-probe --check-workspacewhich prints the target rectangle and the mapping of all four tablet corners for every mode, plus the rotations.
A pre-workspace config file (flat display / areaScale / areaOffsetX /
areaOffsetY / rotation / invertX / invertY keys) is migrated
automatically and keeps working unchanged.
Bindings accept a canonical form (mouse:right, double:left, scroll:up,
key:8+cmd, wheel, eraser, eraser-hold, panel, monitor, precision,
action:210) or any name from the vendor catalogue. xppen-probe --check-bindings prints the whole table plus the action IDs.
eraser and eraser-hold differ in when eraser mode ends. eraser toggles on
each press and stays until the next press, which suits a button you do not want to
keep holding while drawing. eraser-hold is eraser only while the button is down
and restores whatever the state was on release. The vendor has both, as its W and E
keys respectively (_m_Eraser ^= 1 on a press, _m_Eraser = isDown on a set), and
the tool change is announced to applications the same way either way.
wheel is hold-to-scroll: while the bound key is held, each report scrolls by
exactly the distance the pen moved (1 point of pointer movement = 1 pixel of
scroll, no acceleration), sub-pixel movement is carried to the next report, and
the cursor stays where it was because no move events are posted.
.build/release/xppen-probe --descriptor # HID report descriptors
.build/release/xppen-probe --watch --init # stream decoded reports
.build/release/xppen-probe --check-bindings # config parser + action table
.build/release/xppen-probe --check-workspace # tablet -> screen mapping maths
.build/release/xppen-probe --check-pressure # contact decision, pressure and tilt
.build/release/xpdriverd --dry-run # exercise the parser, inject nothing| Symptom | Cause |
|---|---|
no tablet found |
another driver holds the interface: pgrep -fl 'XPPen|XTouchDriver|PenTabletInfo' |
failed to open the tablet |
Input Monitoring not granted to this binary |
| Cursor moves twice, or jumps back and forth | macOS is also reading the tablet: the fallback mouse/digitizer interfaces must be seized |
| Cursor drifts on its own | same as above: seizeFallbackInterfaces |
| Pen freezes after clicking the menu bar | HID was registered for kCFRunLoopDefaultMode only; opening a menu runs AppKit's event-tracking loop, which is a different mode. Must be scheduled on kCFRunLoopCommonModes |
| Driver runs, cursor never moves | Accessibility not granted, or granted to a stale copy |
| Pen clicks or draws while hovering | contact was derived from pressure. The sensor leaks while hovering (877 measured on this unit) and the tip-down range starts at 13, so the two overlap and only the tip switch can separate them |
| Cursor moves, no pressure in apps | kCGMouseEventSubtype not set to 1 (TabletPoint), or pressure written only to kCGTabletEventPressure. AppKit and the browsers read NSEvent.pressure, which comes from kCGMouseEventPressure. Watch the pressure readout in the pen test window |
| No pen in Firefox, everything else fine | Fixed. See docs/PROTOCOL.md for the layout detail. Previously: Firefox sets its pen flag only from tabletProximity:, which AppKit raises only for a native NSTabletProximity event. A CGEvent with that subtype arrives as a plain mouseMoved instead (measured), and IOHIDPostEvent produces no such event on this macOS (measured). Earlier details: Firefox reports pointerType: "mouse" and pressure 0/0.5 from our events, while the same Firefox instance reports pen with 47 distinct pressure values from the vendor's, measured with an identical harness. Our proximity event reports the same pointingDeviceType, capability mask and vendor/tablet ids as the vendor's, so the difference is not yet identified. See docs/PROTOCOL.md |
| No pressure in Firefox | Firefox ignores tablet data until told a pen is in range, by a mouse event with subtype 2 (TabletProximity) carrying the proximity fields. The driver sends that when the pen enters range, so with Firefox already focused, lift the pen clear of the tablet and bring it back |
| No pressure in a browser, but pressure in a native app | the same thing, from the other direction: browsers always use NSEvent.pressure, so the mouse pressure field is not optional |
| Pressure appears in apps when the pen is nowhere near | zeroPressureOnHover is off, so the hover leakage is passed through |
| Pressure maxes out at half | pressure is 14-bit (report[6] | report[7]<<8); the vendor's & 0x1f mask is for other models |
- Contact comes from the tip switch, never from pressure alone. The tablet
reports pressure while merely hovering: 877 at the top of the range, against a
tip-down minimum of 13. Those overlap, so "pressure above a floor" cannot
distinguish touching from hovering and produces phantom clicks.
penDownPressureThresholdcan only make contact harder (tip switch and pressure), never easier, and defaults to 0. - The tablet is re-armed while idle. It drops out of tablet mode on its own
after a period without pen activity, and then reports nothing at all until the
02 B0 04mode command is sent again. Measured withxppen-probe --watch, with and without--init, on an otherwise idle device. Silence is not treated as proof of a fault, because an idle tablet is silent by design; the handshake is simply repeated every two seconds while nothing is arriving, which costs one small output report. - Pressure is written to two fields.
kCGTabletEventPressurecarries it for consumers that read tablet data, andkCGMouseEventPressurebecause that is the field AppKit turns intoNSEvent.pressure, which is what applications and every browser read. Writing only the first leaves web drawing apps with no pressure. - Hover leakage is not passed to applications.
zeroPressureOnHover(default on) reports zero pressure unless the pen is in contact, so no app sees the sensor reading as pressure while you hover. - MacOS also consumes the tablet's fallback interfaces. Without the seize the pointer moves independently of the driver; measured ~900 stray events in 6 s.
- Tilt is divided by 84.0 to match the vendor exactly; the sensor's own range
is ±60. Set
tiltScaleto 60 for a full-range signal.
GNU General Public License v3.0 or later. See LICENSE. Each source
file carries an SPDX-License-Identifier: GPL-3.0-or-later tag.
In short: use it, study it, change it and share it, but if you distribute a modified version, it has to stay free software under the same terms, with source. That is the point: a driver that a manufacturer can drop should not be one that a user cannot pick up. If you fix something for your own tablet, the next person gets it.
GPL-3.0-or-later was chosen over GPL-2.0-only for compatibility: the main
other implementation for this hardware, OpenTabletDriver, is LGPL-3.0, and LGPL-3.0
code can be combined with GPL-3.0 work. Nothing from it is included today, but the
door stays open.
No vendor code and no OpenTabletDriver code is included. What this project uses are measured facts about the hardware: HID report layouts, a handshake byte sequence, coordinate ranges, and the CoreGraphics event field numbers, all obtained by observation and documented with their evidence in docs/PROTOCOL.md. Facts about a device are not creative expression; the implementation here is independent and freely licensed.
This is the driver as a standalone project. It has no build-time or runtime dependency on anything outside Apple's frameworks.
Renaming: the executable targets are prefixed xppen- because they talk to XP-Pen
hardware, which is ordinary nominative use. If you would rather not use the
manufacturer's name, the Package.swift target names, the Makefile variables
and the Support/*.in templates are the only places it appears.