Commit Graph
10 Commits
Author SHA1 Message Date
enricobuehler 42a0dd52be refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

**The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense,
DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim
copy of the kernel's constants plus its own `put_cstr`, and they had already drifted:
`switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a
FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a
bug in both directions — a longer report was truncated, and a shorter one had the parser
reading whatever the reused event buffer still held past the payload, i.e. acting on
rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the
two accessors that are easy to get subtly wrong, with tests on exactly that.

**A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect
slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1`
branch could never run — and allocating from a local counter would have been the wrong
answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where
it stood.

**Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no
non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has
always floored it at 1; this was the odd one out. That converter also existed twice
byte-identically inside one Gradle module — now one `wireAmplitudeToByte`.

Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`)
documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the
differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it
and `pf-inject` do not depend on each other, and a DualSense layout has no business in
`punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by
explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes
the struct it describes instead of a second literal 11 — the header now emits
`uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms`
and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets
`min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served
by the quirk, because that renderer skips writes whose levels are unchanged and would
swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is
marked as staged scaffolding (the tag is on a shipped wire; removing the variant would
not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine,
both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors
at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides,
with `MAX_PADS <= 16` now a compile-time assertion rather than a comment.

Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12).

S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs`
calls `set_forwarding` unconditionally on every params-build (its own comment explains
why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely
arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI
cycle each launch.

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00
enricobuehlerandClaude Opus 5 020306b5ac fix(packaging,host): a fresh Linux install can start, and the comments stop lying
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 2m11s
ci / rust-arm64 (push) Successful in 2m41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 4m48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
arch / build-publish (push) Successful in 7m20s
ci / rust (push) Successful in 7m21s
deb / build-publish-host (push) Successful in 7m4s
docker / builders-arm64cross (push) Successful in 8s
deb / build-publish (push) Successful in 5m31s
docker / deploy-docs (push) Successful in 34s
android / android (push) Successful in 8m17s
windows-host / package (push) Successful in 10m43s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 17m31s
apple / screenshots (push) Successful in 20m57s
Fallout from the documentation sweep: verifying doc claims against the code
turned up defects in the code and the shipped templates. Mostly comments that
describe behaviour we no longer have — which is how the docs went wrong in the
first place, since someone reads the comment and writes the page.

The one that mattered: a fresh deb/RPM/Arch install could not start the host at
all. The unit's `EnvironmentFile=` had no `-`, making host.env mandatory, and no
package creates it — all three ship only the templates under /usr/share and the
postinst merely prints the copy command. So `systemctl --user enable --now
punktfunk-host` died on "Failed to load environment files". Every field in
HostConfig::from_env resolves through unwrap_or/filter/None, so absent means all
defaults, exactly like a hand-run `serve`; the Nix module already wrote it as
`-${environmentFile}`. The Deck installer's own generated unit gets the same
prefix for the case where an operator later removes the file.

Shipped templates: PUNKTFUNK_SECURE_DDA is read by nothing (DDA/WGC are gone;
IDD-push is the sole Windows capture path and the secure desktop is
unconditional), so it stops being written into a fresh host.env;
PUNKTFUNK_INPUT_BACKEND offered a `uinput` value that does not exist and omitted
`kwin`, which is what a KDE session actually resolves to; PUNKTFUNK_RENDER_ADAPTER
no longer claims to pick a "Desktop-Duplication" GPU.

Comments corrected rather than deleted, since each explains a real why:
PUNKTFUNK_10BIT is default-on with explicit-off grammar, not an operator opt-in;
GNOME reaches EIS through Mutter's direct RemoteDesktop API, so it needs no portal
approval and is headless-capable; and the host does not run in session 0 — the
service is the session-0 supervisor and the host runs as SYSTEM in the interactive
console session, which is why game_term has to bind the input desktop at all.

packaging/bazzite/update-punktfunk.sh is now installed to /usr/share/punktfunk/,
so the command the docs promised exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:46:38 +02:00
enricobuehler f40c90aa23 refactor(host/inject/win-display): the Win32 unsafe surface is the FFI call, not the function
`spawn_in_active_session` existed only to launder `spawn_inner`'s `unsafe fn` marker — and its
own comment said why that marker was empty: the callee "has no caller-side preconditions, it
validates the session/token itself and owns every handle it opens". A marker that documents the
absence of a contract is not a contract. The two collapse into one safe fn whose `unsafe` now sits
on the eight Win32 calls, each with the proof it actually needs.

Kept `unsafe fn` exactly where a caller CAN break something: `merged_env_block` (a `*const u16`
block it must trust), `spawn_host` (a borrowed job `HANDLE`), `desktop_name` (an `HDESK`),
`inject_following_desktop` (a live synthetic-pointer device). Those bodies gain explicit blocks
instead — `merged_env_block`'s pointer walk is the one place here where the proof is load-bearing
rather than restating a signature, and it now says why the scan cannot run off the block's end.

With those four files at zero, all three crates take `deny(unsafe_op_in_unsafe_fn)` at the root,
next to the `undocumented_unsafe_blocks` deny they already had. The pair matters: alone, the older
deny is satisfied by an `unsafe fn` body, which needs no blocks at all and so can hide an unproven
FFI call. The workspace lint stays `warn` — it is a ratchet over the encoder backends still to be
cleared, and a crate that reaches zero should not be able to drift back silently.

Windows E0133 214 -> 182 (the remainder is the ash/AMF-dense encode backends). Verified on the
Intel box .47 and on Linux .21: no errors, no `unused_unsafe`, no dead-code fallout.
2026-07-28 22:31:11 +02:00
enricobuehlerandClaude Opus 5 560e663aef fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
ci / rust (push) Failing after 12s
windows-drivers / probe-and-proto (push) Successful in 48s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Failing after 10s
decky / build-publish (push) Successful in 47s
windows-drivers / driver-build (push) Successful in 1m40s
apple / swift (push) Successful in 3m6s
ci / bench (push) Successful in 7m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
android / android (push) Successful in 12m28s
deb / build-publish (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 12m31s
arch / build-publish (push) Successful in 12m40s
deb / build-publish-host (push) Successful in 12m17s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 23m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m24s
A LocalService principal could take over a virtual pad's shared input section and
forge HID input into the interactive desktop.

The host duplicates each pad's unnamed DATA section into the driver's WUDFHost, and
through gamepad proto v2 it learned that process from `driver_pid` in the named
bootstrap mailbox. That mailbox has to be LocalService-writable — that is what the
driver's own WUDFHost runs as — and the delivery gate, verify_is_wudfhost, only checks
that the target's IMAGE is %SystemRoot%\System32\WUDFHost.exe. That image is
world-executable. So anything running as LocalService — notably the deliberately
de-privileged plugin runner — could spawn its own WUDFHost (CREATE_SUSPENDED parks it
indefinitely with the right image path), publish that pid, and be handed
SECTION_MAP_READ|WRITE on a live section. For pf-mouse that section drives a real
absolute pointer, so it was desktop control; for the pads it was forged gamepad input
plus a read of the remote user's controller state.

The module docs claimed mailbox tampering "yields at worst a gamepad DoS, never a read
or an injection". That was wrong, and the reasoning behind it — that a LocalService
token is DACL-denied OpenProcess on a UMDF WUDFHost — only covers the REAL host, not
one the attacker spawned itself.

The pid now comes from the device stack (ChannelProof, proto 2 -> 3). The host asks the
devnode it SwDeviceCreate'd who is serving it, looked up by the instance id PnP handed
back, so a planted look-alike devnode is not a candidate and the kernel — not anything
the attacker supplies — does the routing. Only the driver PnP actually bound to that
device can answer. `driver_pid` survives as a liveness hint; a tamperer can still deny a
pad, which squatting the name always allowed, but can no longer choose the recipient.
Two rules keep the state machine honest around it: a delivery stands until its target
process EXITS (judged on a retained SYNCHRONIZE handle, so a recycled pid cannot fake
it, and UMDF's restart-after-driver-crash still re-attaches), and a pad with no
SwDeviceCreate devnode refuses to deliver rather than fall back — unless an operator
sets PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX, which says so loudly.

Three transports, because Windows carries different things to different driver shapes,
and the obvious two did not survive contact with hidclass. Measured on .173 (Win11
26200): HidD_GetIndexedString is NOT forwarded to a UMDF HID minidriver at all — it
failed for every index including ones the driver demonstrably serves through the named
wrappers; and a private device interface registers and enumerates but cannot be OPENED
(ERROR_GEN_FAILURE), because hidclass owns IRP_MJ_CREATE on a devnode it is the FDO for.
That is exactly why pf-xusb was never affected: it is not a HID minidriver, so nothing
sits above it. What works:

  * pf-xusb   — a private IOCTL on its own GUID_DEVINTERFACE_XUSB.
  * pf-mouse  — the HID serial string. Verified: PFCP:3:0:7296, and 7296 was a genuine
                service-spawned WUDFHost.exe. Safe here alone: nothing reads the virtual
                mouse's serial, whereas a pad's is SDL/Steam dedup material.
  * pf-gamepad — a HID feature report, and it cost NO report-descriptor change. The
                captured descriptors already declare far more Feature ids than the driver
                ever served: 0x85 is declared on DualSense, DualShock 4 and Edge alike and
                used to fail with STATUS_INVALID_PARAMETER, so hidclass lets it through and
                nothing can have depended on the old failure. The Deck's one feature report
                is unnumbered and Steam drives it command->response, so its proof rides that
                existing contract via a private two-byte command. Verified: feature 0x85
                returned magic "PFCP", proto 3, pad_index 0, wudf_pid 18456 — and 18456 was
                a WUDFHost — with the product string still 'DualSense Wireless Controller'.

Also renamed pf-dualsense -> pf-gamepad. One driver has always served four identities, so
the old name read as if the other three lived elsewhere. ONLY the package identity moved
(crate, INF/CAT/DLL, UMDF service, build script, CI lines, log file, env var). The four
HARDWARE IDS are deliberately unchanged — they bind every devnode the host creates and
every installed system — as are the Global\pfds-boot-<i> mailbox and PAD_MAGIC, which are
wire contract. `driver install --gamepad` now retires the pre-rename store package first,
matched on pf_dualsense.dll because that string appears only in the OLD inf; matching on
the hardware ids would delete what we are about to install. On .173 that separated 14
stale packages from the 1 new one with 0 ambiguous, and the renamed package binds the old
hwid (devgen root\pf_dualsense -> oem143.inf = pf_gamepad.inf).

The repo's own pre-commit/pre-push rustfmt hooks named the old crate, so they caught the
rename before the commit did — they now check pf-gamepad, and pf-mouse alongside it, which
they had been missing relative to the CI line.

Host and drivers MUST ship together: v2<->v3 fails closed in both directions by design,
with the existing "update host + drivers together" diagnostic.

The rename moved files that also carry the security change, so splitting this into two
commits would mean reconstructing an intermediate state that was never gated. It is one
commit on purpose.

Gated on the windows-amd64 runner with cargo clean first (the box's clock lags, so stale
artifacts would read as a vacuous green): clippy -D warnings clean for pf-inject,
pf-capture and pf-driver-proto, drivers workspace build + the CI clippy line clean,
cargo check --release -p punktfunk-host clean, 19 + 58 tests green. Also fixes pf-mouse
still writing its debug log to world-writable C:\Users\Public, which the 2026-07-17
review moved for the other three drivers and missed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:54:40 +02:00
enricobuehlerandClaude Opus 5 2eeee650b9 fix(inject/libei): absolute coordinates resolve by identity, not by size
libei hands the injector one region per logical monitor with no output
name attached, so picking one meant matching the streamed mode's SIZE —
a coin flip the moment two heads share a mode, and it already resolved
wrong on-glass once (GNOME, a dummy HDMI beside the virtual primary: the
seat cursor never entered the streamed monitor, so neither cursor path
could see it).

The ladder is now mapping_id -> origin -> size -> first. Two outputs can
share a size but never a top-left, and a mirrored head's region is not the
client's size at all, so the size rung cannot find it — this is what makes
per-monitor capture land its input on the monitor it is showing.

An anchor that matches nothing falls back down the ladder rather than
stranding input: the region set is the truth and the anchor is our belief
about it. It warns once per distinct anchor, because the failure this
exists to prevent is a pointer that silently lands elsewhere.

P1 of design/per-monitor-portal-capture.md — mechanism and tests only.
Nothing sets an anchor yet; the pin wires it in P2, and the doc comment
records why it is host-level and not per-session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Fable 5 087e7d0405 fix(inject/windows): map absolute input over the streamed output's rect, not the whole desktop
audit / cargo-audit (push) Successful in 2m13s
audit / bun-audit (push) Successful in 15s
arch / build-publish (push) Successful in 12m46s
windows-host / package (push) Successful in 12m53s
android / android (push) Successful in 13m48s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 15m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
ci / bench (push) Successful in 5m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m10s
ci / rust (push) Successful in 19m38s
decky / build-publish (push) Successful in 27s
deb / build-publish (push) Successful in 8m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 1m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 1m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m54s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 10m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m26s
flatpak / build-publish (push) Successful in 6m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
release / apple (push) Successful in 56m19s
apple / screenshots (push) Successful in 23m11s
Pen, touch, and absolute mouse arrive normalized to the STREAMED display's
frame, but pointer_windows::to_screen and sendinput's MouseMoveAbs mapped
them over the whole virtual desktop — correct only when the virtual display
is the sole active display (Exclusive topology). In Extend — a physical
monitor kept on beside the virtual output, or an Exclusive isolate degraded
to the 0x57 keep-physicals fallback — the streamed output sits at a non-zero
origin, so every sample landed shifted and mis-scaled. The pen exposed it
first (field report 2026-07-24): a stylus is strictly absolute, with no
closed-loop correction onto the target like a cursor.

New pf-inject::stream_target (Windows): the host publishes the streamed
output's CCD target id at capture bring-up (one central site —
capture_virtual_output covers the native and GameStream planes); mapping
sites resolve its current desktop rect through pf-win-display's
source_desktop_rect — the same resolver the cursor-readback poller uses, so
inject and readback always agree — TTL-cached (250 ms) because a
group-layout re-arrange moves a live output's origin mid-session. No target
set / never resolved falls back to the whole virtual desktop (the historical
mapping, still right for Exclusive topology and devtest).

One change in to_screen covers pen + touch; MouseMoveAbs converts the same
desktop pixel into the 0..65535 MOUSEEVENTF_VIRTUALDESK coordinate, closing
the identical latent absolute-mouse bug (apollo-comparison open item #14/#30).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:38:36 +02:00
enricobuehlerandClaude Fable 5 45c9799aa6 feat(inject/windows): pen P3 — PT_PEN + PT_TOUCH synthetic pointer injection
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 54s
release / apple (push) Successful in 9m40s
arch / build-publish (push) Successful in 12m4s
ci / bench (push) Successful in 7m5s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
apple / screenshots (push) Successful in 6m50s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
android / android (push) Successful in 14m47s
ci / rust (push) Canceled after 13m52s
deb / build-publish (push) Canceled after 7m26s
deb / build-publish-host (push) Canceled after 5m38s
flatpak / build-publish (push) Canceled after 1m55s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1m59s
windows-host / package (push) Canceled after 8m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The Windows leg of design/pen-tablet-input.md §6, following Apollo's recipe:
a per-session PT_PEN device (pressure rescaled 0..1024, polar tilt→tiltX/Y,
barrel roll on rotation — Windows Ink renders Pencil Pro roll natively, barrel
button, eraser via INVERTED/ERASER flags, hover) with a 40ms refresh thread
against the ~100ms synthetic-pointer staleness auto-lift, plus a PT_TOUCH
device closing the historical SendInput wire-touch no-op (full active-contact
frames, per-id DOWN/UP edges, self-healing lost-DOWN synthesis). pen_supported
now probes PT_PEN creation on Windows (1809+), which lights up HOST_CAP_PEN
and the GameStream featureFlags there — Moonlight iPad + Pencil can ink on a
Windows host. Frame grouping mirrors the Linux uinput backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:18:31 +02:00
enricobuehlerandClaude Fable 5 aca5aa9993 feat(host+inject): pen P1 — per-session uinput virtual tablet, HOST_CAP_PEN live
The Linux injection leg of design/pen-tablet-input.md: 0xCC/0x05 pen batches now
route through the per-session PenTracker into a lazily-created 'Punktfunk Pen'
uinput tablet (BTN_TOOL_PEN/RUBBER, pressure, tilt-from-polar, ABS_Z barrel
roll, hover distance, INPUT_PROP_DIRECT) — compositors pick it up via libinput
and hand apps zwp_tablet_v2 with full fidelity. The host now advertises
HOST_CAP_PEN when /dev/uinput is accessible (PUNKTFUNK_PEN=0 kill-switch);
transitions group into SYN frames so proximity-enter carries its position.
Stroke failsafe: clients heartbeat ≤100ms while in range (documented wire
contract — capture APIs are silent for a stationary pen); 200ms of silence
force-releases. 'punktfunk-host pen-test' draws a pressure-ramped sine stroke
through the real tracker→uinput chain, no client needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:18:31 +02:00
enricobuehlerandClaude Fable 5 abe6228b42 feat(core+host+android): committed-text input plane (IME path) — InputKind::TextInput
The VK key-event vocabulary cannot express text an input method COMMITS
(autocorrect, gesture typing, non-Latin scripts, emoji). Add a first-class
text event and negotiate it:

- punktfunk-core: InputKind::TextInput (= 15) carries one Unicode scalar per
  event in `code`; HOST_CAP_TEXT_INPUT (0x04) in Welcome::host_caps.
- Host advertises the cap only where the session's inject backend can type
  text: Windows SendInput (KEYEVENTF_UNICODE, surrogate-pair aware) and the
  Linux wlroots backend — a dedicated second zwp_virtual_keyboard whose xkb
  keymap grows Unicode keysyms on demand (the wtype model), so keymap
  re-uploads never disturb the main device's layout/modifier state. The
  KWin-fake-input/libei/gamescope backends can only press layout keycodes, so
  those sessions don't set the bit and clients keep the VK fallback.
- GameStream plane: Moonlight's UTF-8 text packet (MAGIC_UTF8, previously
  recognized-and-dropped) now decodes to the same TextInput events.
- Android: KeyCaptureView picks a real editable InputConnection when the host
  has the cap — the IME runs its full machinery, mirrored to the host live via
  common-prefix diffs of the composition (backspaces + new suffix), with
  setComposingRegion adopting committed text so autocorrect-revert flows diff
  instead of retyping; newline→Enter, deleteSurroundingText→Backspace/Delete.
  Older hosts keep the TYPE_NULL raw-key path unchanged.
- keymap: media VKs (0xB0-0xB3) → evdev so the Android media keys land on
  Linux hosts too.

Verified: punktfunk-core + host gamestream + pf-inject tests green on Linux
(Ubuntu box), clippy clean; Android app+native builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:19:11 +02:00
enricobuehlerandClaude Opus 4.8 f6c6e4e594 refactor(host/W6.2): extract the input-injection backends into the pf-inject crate
inject.rs + inject/* (the per-OS injectors — wlroots virtual-input, KWin
fake_input, libei/reis, gamescope-EI on Linux; SendInput on Windows — plus the
virtual-gamepad HID stack: DualSense/DualShock4/Switch Pro/Steam Controller/Deck
over uhid/usbip and the Windows UMDF drivers, the proto codecs, the injector
service, and the uhid manager) move into crates/pf-inject behind the
InputInjector trait (plan §W6). It consumes punktfunk_core::input (the neutral
GamepadEvent/InputEvent vocabulary, moved to core in W5) + the pf-driver-proto
wire contract, and reaches pf-capture only for the Windows gamepad-channel
WUDFHost check + the resident-mouse compose-kick hook.

The one inject->vdisplay coupling (the libei gamescope-EI backend needs the EIS
relay socket path) is broken via a leaf: gamescope_ei_socket_file moves to
pf-paths as the shared contract — the gamescope producer (host vdisplay) keeps
its session-env-lock wrapper around it, the libei consumer (pf-inject) reads it
directly post-retarget. The host keeps a `mod inject { pub use pf_inject::* }`
shim so every crate::inject::* path (the native/gamestream input planes + devtest)
is unchanged; the heavy input deps (wayland/reis/xkbcommon/usbip + the KWin
fake-input protocol XML) moved with the crate.

Verified: Linux clippy -D warnings (pf-inject + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-inject 69/69 + host 230/230 tests; Windows clippy -D warnings
(pf-inject --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:52:02 +02:00