2 Commits

Author SHA1 Message Date
enricobuehler 0205c7b8d6 ci(release): split canary/stable tracks + unified Gitea Releases
ci / rust (push) Failing after 37s
apple / swift (push) Successful in 56s
ci / web (push) Successful in 42s
ci / docs-site (push) Failing after 27m33s
android / android (push) Failing after 28m53s
windows-host / package (push) Failing after 28m55s
deb / build-publish (push) Successful in 2m28s
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 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m4s
flatpak / build-publish (push) Successful in 4m19s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m38s
release / apple (push) Successful in 4m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m6s
A push to main publishes canary builds to canary channels (fast iteration,
unchanged); a single vX.Y.Z tag releases every platform at one version to the
stable channels and attaches all artifacts (.deb/.rpm/.msix/.apk/.aab/.dmg +
flatpak/decky/host-installer) to one Gitea Release. Collapses the
host-v*/win-v*/host-win-v* tag namespaces into v* — the channel split makes the
version-shadow bug structurally impossible (canary and stable are separate repos,
never a shared version line).

- scripts/ci/gitea-release.{sh,ps1}: one idempotent release helper
  (create-or-fetch + delete-before-upload), replacing 3 copy-pasted inline blocks
  and fixing their latent 409-on-reupload bug; prerelease flag auto-derived from
  the tag (an -rc tag won't shadow "Latest")
- channels: apt canary/stable distributions; rpm *-canary/base groups; flatpak
  canary/stable OSTree branches + a 2nd .Canary.flatpakref; generic-registry
  canary/ vs latest/ aliases; Play internal/alpha; Apple TestFlight vs notarized DMG
- android versionName threaded through gradle (versionCode stays run_number);
  Apple canary = TestFlight-only (no DMG/tvOS); canary base bumped to 0.3.0
- docs: new docs-site channels.md (subscribe table + cut-a-release runbook +
  box migration), refreshed ci.md workflow table + packaging READMEs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:26:38 +00:00
enricobuehler 3e6c9f6060 feat(gamepad): add virtual Xbox One/Series + DualShock 4 pad types
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a
physical Xbox One or PS4 pad on the client gets a near-native matching virtual
pad on the host, auto-resolved from the controller type.

Protocol/core:
- GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/
  from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants
  (compile-time guard ties them to the enum). Single-byte wire form is
  unchanged, so it's forward-compatible (older peers degrade to Auto).

Host (Linux):
- New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation:
  lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers /
  player LEDs / mute. Reuses the DualSense pure state + button mapping; only the
  report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake
  (0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad
  resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane,
  lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane.
- Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S
  USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise.
- PadBackend dispatch + resolver handle both; off Linux the UHID pads and
  One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred.

Clients (auto-resolve physical pad -> virtual type, plus manual settings):
- Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE ->
  Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture +
  lightbar already type-agnostic. Linux settings combo + label updated.
- Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4
  touchpad capture, settings picker entries.
- Android (Kotlin): InputDevice VID/PID auto-detect (matching the other
  clients) + settings entries.
- probe: --gamepad help/aliases.

Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in
catch_unwind so a panic degrades to a logged no-op instead of aborting the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:34:44 +00:00
47 changed files with 1877 additions and 397 deletions
+49 -13
View File
@@ -12,6 +12,10 @@ name: android
on: on:
push: push:
branches: [main] branches: [main]
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
# push is canary (Play `internal`).
tags: ['v*']
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -69,11 +73,24 @@ jobs:
VERSION_CODE: ${{ github.run_number }} VERSION_CODE: ${{ github.run_number }}
run: ./gradlew :app:assembleDebug --stacktrace run: ./gradlew :app:assembleDebug --stacktrace
# Single source of the version name + the Play track for the release steps below. versionCode
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
- name: Version + channel
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: |
case "$GITHUB_REF" in
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
echo "android version $VN -> Play track '$TRACK'"
- name: Build Release (signed AAB + universal APK) - name: Build Release (signed AAB + universal APK)
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
working-directory: clients/android working-directory: clients/android
env: env:
VERSION_CODE: ${{ github.run_number }} VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
RELEASE_KEYSTORE_FILE: "../release.jks" RELEASE_KEYSTORE_FILE: "../release.jks"
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
@@ -85,33 +102,52 @@ jobs:
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing. # Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler). # Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
- name: Publish AAB + APK to Gitea generic registry # main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
if: github.event_name == 'push' && github.ref == 'refs/heads/main' # to the unified Gitea Release.
- name: Publish to generic registry + attach to Gitea release
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env: env:
REGISTRY: git.unom.io REGISTRY: git.unom.io
OWNER: unom OWNER: unom
PKG: punktfunk-android PKG: punktfunk-android
VERSION: ${{ github.run_number }} VERSION: ${{ github.run_number }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
AAB=clients/android/app/build/outputs/bundle/release/app-release.aab AAB=clients/android/app/build/outputs/bundle/release/app-release.aab
APK=clients/android/app/build/outputs/apk/release/app-release.apk APK=clients/android/app/build/outputs/apk/release/app-release.apk
base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG/$VERSION" base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG"
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/punktfunk-android-r$VERSION.aab" # 1) immutable, run-number-versioned store (sideload + provenance)
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/punktfunk-android-r$VERSION.apk" curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/$VERSION/punktfunk-android-r$VERSION.aab"
echo "Published artifacts (versionCode=$VERSION):" curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$VERSION/punktfunk-android-r$VERSION.apk"
echo " $base/punktfunk-android-r$VERSION.aab" echo "published store version $VERSION (versionCode)"
echo " $base/punktfunk-android-r$VERSION.apk" # 2) channel alias for a predictable sideload URL: stable -> latest/, canary -> canary/
case "$GITHUB_REF" in refs/tags/v*) ALIAS=latest ;; *) ALIAS=canary ;; esac
curl -fsS -o /dev/null --user "enricobuehler:$REGISTRY_TOKEN" -X DELETE "$base/$ALIAS/punktfunk-android.apk" || true
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$ALIAS/punktfunk-android.apk"
echo "sideload alias: $base/$ALIAS/punktfunk-android.apk"
# 3) on a real release, attach the .aab + .apk to the unified Gitea Release (X.Y.Z names)
case "$GITHUB_REF" in
refs/tags/v*)
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$AAB" "punktfunk-${VERSION_NAME}.aab"
upsert_asset "$RID" "$APK" "punktfunk-${VERSION_NAME}.apk"
;;
esac
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the # Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no # real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview. # pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
- name: Upload to Google Play (Internal Testing) # Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
if: github.event_name == 'push' && github.ref == 'refs/heads/main' # promotion to production in the Play console.
- name: Upload to Google Play
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env: env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }} SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
run: | run: |
echo "uploading to Play track '$PLAY_TRACK'"
python3 clients/android/ci/play-upload.py \ python3 clients/android/ci/play-upload.py \
--package io.unom.punktfunk \ --package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \ --aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track internal --status completed --track "$PLAY_TRACK" --status completed
+32 -14
View File
@@ -13,16 +13,16 @@ name: deb
on: on:
push: push:
branches: [main] branches: [main]
# HOST-scoped tags only. The Apple client uses `v*` (release.yml); those must NOT trigger a # Single project version: a `vX.Y.Z` tag is THE release for every platform (see
# host publish — a `v0.1.1` client tag previously shipped a host package versioned 0.1.1 that # docs-site channels.md). The old version-shadow (a client tag shipping a host package
# outranked every rolling build (the version-shadow). Host releases use `host-v*`. # that outranked rolling builds) is now structurally impossible — main publishes to the
tags: ['host-v*'] # `canary` apt distribution, tags to `stable`, so the two never share a version line.
tags: ['v*']
workflow_dispatch: workflow_dispatch:
env: env:
REGISTRY: git.unom.io REGISTRY: git.unom.io
OWNER: unom OWNER: unom
DISTRIBUTION: stable
COMPONENT: main COMPONENT: main
jobs: jobs:
@@ -34,19 +34,22 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Version - name: Version + channel
# host-vX.Y.Z tag -> X.Y.Z (a real host release). A main push -> 0.2.0~ciN.g<sha>: the '~' # vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
# sorts it BELOW the eventual 0.2.0 tag, it climbs monotonically by run number, AND it sits # A main push -> 0.3.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
# ABOVE the stray 0.1.1, so `apt upgrade` truly moves boxes forward. Computed BEFORE the # below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
# build so it's stamped into the binary (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version). # stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
run: | run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}" ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="0.2.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;; *) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "package version $V" echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "package version $V -> apt distribution '$DIST'"
# dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link # dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link
# deps are also baked into the rust-ci image, but this job runs against the image # deps are also baked into the rust-ci image, but this job runs against the image
@@ -55,7 +58,8 @@ jobs:
- name: dpkg-dev + client link deps - name: dpkg-dev + client link deps
run: | run: |
apt-get update apt-get update
apt-get install -y --no-install-recommends dpkg-dev \ # python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
apt-get install -y --no-install-recommends dpkg-dev python3 \
libgtk-4-dev libadwaita-1-dev libsdl3-dev libgtk-4-dev libadwaita-1-dev libsdl3-dev
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts. # Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
@@ -124,3 +128,17 @@ jobs:
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload" "https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done done
echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT" echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT"
# On a real release, also attach the .debs to the unified Gitea Release so they're on the
# downloads page next to every other platform's artifact (canary builds live in the apt
# `canary` distribution above — no release page for those).
- name: Attach .debs to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
+20 -27
View File
@@ -56,19 +56,20 @@ jobs:
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm run build # rollup -> clients/decky/dist/index.js pnpm run build # rollup -> clients/decky/dist/index.js
- name: Version - name: Version + channel
# Tag v1.2.3 -> 1.2.3; main push -> 0.0.1-ciN.g<sha>. Used only for the registry # Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.0-ciN.g<sha>
# version path + the zip name (the plugin.json version is the source of truth Decky # (`canary/` alias). Used for the registry version path + the zip name (the plugin.json
# reads after install). # version is the source of truth Decky reads after install — bump it in the release commit).
working-directory: ${{ gitea.workspace }} working-directory: ${{ gitea.workspace }}
run: | run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;; *) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; ALIAS=canary ;;
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "decky version $V" echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
echo "decky version $V -> alias '$ALIAS'"
- name: Assemble store-layout zip - name: Assemble store-layout zip
working-directory: ${{ gitea.workspace }} working-directory: ${{ gitea.workspace }}
@@ -102,29 +103,21 @@ jobs:
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
"$BASE/$VERSION/punktfunk.zip" "$BASE/$VERSION/punktfunk.zip"
echo "published $BASE/$VERSION/punktfunk.zip" echo "published $BASE/$VERSION/punktfunk.zip"
# 2) Stable `latest/punktfunk.zip` — this is the link to paste into Decky's # 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the link
# "install from URL". The generic registry rejects re-uploading an existing # to paste into Decky's "install from URL". The generic registry rejects re-uploading
# version/file (409), so delete the prior `latest` first (ignore 404 on run #1). # an existing version/file (409), so delete the prior alias first (ignore 404 on run #1).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \ curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"$BASE/latest/punktfunk.zip" || true "$BASE/$ALIAS/punktfunk.zip" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
"$BASE/latest/punktfunk.zip" "$BASE/$ALIAS/punktfunk.zip"
echo "install-from-URL link: $BASE/latest/punktfunk.zip" echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip"
- name: Attach zip to the Gitea release (tags only) - name: Attach zip to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/') if: startsWith(gitea.ref, 'refs/tags/v')
working-directory: ${{ gitea.workspace }} working-directory: ${{ gitea.workspace }}
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" . scripts/ci/gitea-release.sh
ID=$(curl -sf -X POST "$API/releases" \ RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ upsert_asset "$RID" "$RUNNER_TEMP/punktfunk.zip" "punktfunk-${VERSION}.zip"
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -sf -X POST "$API/releases/$ID/assets?name=punktfunk-${VERSION}.zip" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$RUNNER_TEMP/punktfunk.zip" >/dev/null
echo "attached punktfunk-${VERSION}.zip to release $GITHUB_REF_NAME"
+5
View File
@@ -58,16 +58,21 @@ jobs:
- name: Build - name: Build
run: | run: |
# On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images.
EXTRA=""
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
docker build --pull ${{ matrix.buildargs }} \ docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \ -f "${{ matrix.dockerfile }}" \
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \ -t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \ -t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
$EXTRA \
"${{ matrix.context }}" "${{ matrix.context }}"
- name: Push - name: Push
run: | run: |
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" docker push "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}"
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest" docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to # Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set # (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
+46 -39
View File
@@ -71,19 +71,23 @@ jobs:
https://dl.flathub.org/repo/flathub.flatpakrepo https://dl.flathub.org/repo/flathub.flatpakrepo
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
- name: Version - name: Version + channel
# Tag v1.2.3 -> 1.2.3; a main push -> 0.0.1-ciN.g<sha> (sorts before a real release, # Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
# increases by run number — newest main build always wins). The generic registry # 0.3.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
# version string allows letters/dots/hyphens. # (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
# on a stable box never jumps to a canary build. The generic-registry version string allows
# letters/dots/hyphens.
run: | run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;; *) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV" echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
echo "flatpak version $V" echo "FLATPAK_BRANCH=$BRANCH" >> "$GITHUB_ENV"
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
echo "flatpak version $V -> branch '$BRANCH' alias '$ALIAS'"
- name: Generate offline cargo sources - name: Generate offline cargo sources
# flatpak builds with no network; vendor every crate from Cargo.lock into # flatpak builds with no network; vendor every crate from Cargo.lock into
@@ -108,19 +112,20 @@ jobs:
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus # runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the # the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
# container-safe path (no FUSE). # container-safe path (no FUSE).
# --default-branch=stable pins the ref to app/io.unom.Punktfunk/x86_64/stable so the # --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
# hosted .flatpakref (Branch=stable) matches deterministically (manifest sets no branch). # (canary or stable) so the matching hosted .flatpakref resolves deterministically
# (manifest sets no branch).
flatpak-builder --user --force-clean --disable-rofiles-fuse \ flatpak-builder --user --force-clean --disable-rofiles-fuse \
--default-branch=stable \ --default-branch="$FLATPAK_BRANCH" \
--install-deps-from=flathub \ --install-deps-from=flathub \
--repo="$PWD/repo" \ --repo="$PWD/repo" \
"$PWD/build-dir" "$MANIFEST" "$PWD/build-dir" "$MANIFEST"
- name: Export single-file bundle - name: Export single-file bundle
run: | run: |
# Branch must be passed explicitly now that the repo ref is `stable` (--default-branch # Branch must be passed explicitly (matches --default-branch above); build-bundle
# above); build-bundle otherwise defaults to `master` and errors "Refspec … not found". # otherwise defaults to `master` and errors "Refspec … not found".
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" stable flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" "$FLATPAK_BRANCH"
ls -lh "$BUNDLE" ls -lh "$BUNDLE"
- name: Publish to the Gitea generic registry - name: Publish to the Gitea generic registry
@@ -132,14 +137,14 @@ jobs:
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
"$BASE/$VERSION/$BUNDLE" "$BASE/$VERSION/$BUNDLE"
echo "published $BASE/$VERSION/$BUNDLE" echo "published $BASE/$VERSION/$BUNDLE"
# 2) Stable `latest/punktfunk-client.flatpak` alias for the Decky fallback + scripts. # 2) Channel alias (stable release -> latest/, canary main build -> canary/) for the
# The generic registry rejects re-uploading an existing version/file (409), so # Decky fallback + scripts. The generic registry rejects re-uploading an existing
# delete the prior `latest` file first (ignore 404 on the first ever run). # version/file (409), so delete the prior alias file first (ignore 404 on run #1).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \ curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"$BASE/latest/punktfunk-client.flatpak" || true "$BASE/$ALIAS/punktfunk-client.flatpak" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
"$BASE/latest/punktfunk-client.flatpak" "$BASE/$ALIAS/punktfunk-client.flatpak"
echo "published $BASE/latest/punktfunk-client.flatpak" echo "published $BASE/$ALIAS/punktfunk-client.flatpak"
# Sign the OSTree repo flatpak-builder already produced and publish it to flatpak.unom.io on # Sign the OSTree repo flatpak-builder already produced and publish it to flatpak.unom.io on
# unom-1, so users get `flatpak update` (the single-file bundle above has no remote). Mirrors # unom-1, so users get `flatpak update` (the single-file bundle above has no remote). Mirrors
@@ -165,7 +170,7 @@ jobs:
# build-sign signs the COMMIT objects; build-update-repo signs the SUMMARY. Both are # build-sign signs the COMMIT objects; build-update-repo signs the SUMMARY. Both are
# required — clients with gpg-verify=true verify the commit, so summary-only signing # required — clients with gpg-verify=true verify the commit, so summary-only signing
# fails the pull with "GPG verification enabled, but no signatures found". # fails the pull with "GPG verification enabled, but no signatures found".
flatpak build-sign "$PWD/repo" "$APP_ID" stable \ flatpak build-sign "$PWD/repo" "$APP_ID" "$FLATPAK_BRANCH" \
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" --gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
flatpak build-update-repo --generate-static-deltas \ flatpak build-update-repo --generate-static-deltas \
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo" --gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
@@ -180,23 +185,33 @@ jobs:
Comment=unom Flatpak applications Comment=unom Flatpak applications
GPGKey=$GPGKEY GPGKey=$GPGKEY
EOF EOF
cat > "site/${APP_ID}.flatpakref" <<EOF # Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
# the server always offers both (the stable ref only resolves once a release has built the
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
write_ref() { # <filename> <branch> <title>
cat > "site/$1" <<EOF
[Flatpak Ref] [Flatpak Ref]
Name=$APP_ID Name=$APP_ID
Branch=stable Branch=$2
Url=$REPO_URL/repo/ Url=$REPO_URL/repo/
Title=Punktfunk Title=$3
Homepage=https://punktfunk.unom.io Homepage=https://punktfunk.unom.io
IsRuntime=false IsRuntime=false
GPGKey=$GPGKEY GPGKey=$GPGKEY
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
EOF EOF
}
write_ref "${APP_ID}.flatpakref" stable "Punktfunk"
write_ref "${APP_ID}.Canary.flatpakref" canary "Punktfunk (Canary)"
cat > site/index.html <<EOF cat > site/index.html <<EOF
<!doctype html><meta charset=utf-8><title>unom flatpak repo</title> <!doctype html><meta charset=utf-8><title>unom flatpak repo</title>
<h1>unom Flatpak repository</h1> <h1>unom Flatpak repository</h1>
<p>Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates):</p> <p>Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates).</p>
<p><b>Stable</b> (recommended — only moves on releases):</p>
<pre>flatpak install --user $REPO_URL/${APP_ID}.flatpakref <pre>flatpak install --user $REPO_URL/${APP_ID}.flatpakref
flatpak run $APP_ID</pre> flatpak run $APP_ID</pre>
<p><b>Canary</b> (latest main build, unstable):</p>
<pre>flatpak install --user $REPO_URL/${APP_ID}.Canary.flatpakref</pre>
<p>Or add the whole remote: <code>flatpak remote-add --user --if-not-exists unom $REPO_URL/unom.flatpakrepo</code></p> <p>Or add the whole remote: <code>flatpak remote-add --user --if-not-exists unom $REPO_URL/unom.flatpakrepo</code></p>
EOF EOF
# 3) Ship to unom-1 and (re)start the static server. rsync WITHOUT --delete keeps old # 3) Ship to unom-1 and (re)start the static server. rsync WITHOUT --delete keeps old
@@ -207,24 +222,16 @@ jobs:
DEST="${DEPLOY_USER}@${DEPLOY_HOST}" DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo" $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/" rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/" rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/" rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d" $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
echo "deployed → $REPO_URL/${APP_ID}.flatpakref" echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
- name: Attach bundle to the Gitea release (tags only) - name: Attach bundle to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/') if: startsWith(gitea.ref, 'refs/tags/v')
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" . scripts/ci/gitea-release.sh
ID=$(curl -sf -X POST "$API/releases" \ RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ upsert_asset "$RID" "$BUNDLE"
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -sf -X POST "$API/releases/$ID/assets?name=$BUNDLE" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$BUNDLE" >/dev/null
echo "attached $BUNDLE to release $GITHUB_REF_NAME"
+37 -20
View File
@@ -46,6 +46,19 @@ name: release
on: on:
push: push:
# Canary: a relevant main push uploads the iOS + macOS builds to TestFlight (Apple's own
# canary channel) — no notarized DMG, no tvOS (those are stable-only; see the per-step gates).
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
branches: [main]
paths:
- 'clients/apple/**'
- 'crates/punktfunk-core/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.lock'
- '.gitea/workflows/release.yml'
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store.
tags: ['v*'] tags: ['v*']
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -87,8 +100,8 @@ jobs:
- name: Version from tag - name: Version from tag
run: | run: |
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="0.0.${GITHUB_RUN_NUMBER}" ;; *) V="0.3.0" ;; # canary marketing version; the build number disambiguates
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
@@ -105,8 +118,16 @@ jobs:
"$RUSTUP" toolchain install nightly --profile minimal "$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly "$RUSTUP" component add rust-src --toolchain nightly
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS) - name: Build PunktfunkCore.xcframework (mac + iOS; + tvOS on stable tags)
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh # tvOS uses nightly -Zbuild-std (slow) — build it only for a real release, not on every
# canary main push.
run: |
TV=""
case "$GITHUB_REF" in refs/tags/v*) TV="BUILD_TVOS=1" ;; esac
# `env` (not a bare prefix): a $TV-expanded `NAME=val` word is NOT re-promoted to a shell
# assignment, so `BUILD_IOS=1 $TV bash …` would try to RUN `BUILD_TVOS=1` (exit 127). env
# treats its leading NAME=val args as assignments post-expansion; empty $TV is a no-op.
env BUILD_IOS=1 $TV bash scripts/build-xcframework.sh
- name: Stage App Store Connect API key - name: Stage App Store Connect API key
env: env:
@@ -116,6 +137,9 @@ jobs:
chmod 600 "$RUNNER_TEMP/asc.p8" chmod 600 "$RUNNER_TEMP/asc.p8"
- name: macOS — archive, codesign Developer ID, notarize, DMG - name: macOS — archive, codesign Developer ID, notarize, DMG
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
if: startsWith(gitea.ref, 'refs/tags/v')
run: | run: |
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the # Archive UNSIGNED, then codesign with the Developer ID Application identity from the
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups # login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
@@ -154,23 +178,14 @@ jobs:
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG" DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
echo "DMG=$DMG" >> "$GITHUB_ENV" echo "DMG=$DMG" >> "$GITHUB_ENV"
- name: Attach DMG to Gitea release - name: Attach DMG to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/') if: startsWith(gitea.ref, 'refs/tags/v')
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" . scripts/ci/gitea-release.sh
# Create the release (409 -> already exists, fetch it instead). RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
ID=$(curl -sf -X POST "$API/releases" \ upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -sf -X POST "$API/releases/$ID/assets?name=Punktfunk-$VERSION.dmg" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$DMG" >/dev/null
echo "attached Punktfunk-$VERSION.dmg to release $GITHUB_REF_NAME"
- name: macOS App Store — archive + upload to TestFlight - name: macOS App Store — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
@@ -278,7 +293,9 @@ jobs:
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: tvOS — archive + upload to TestFlight - name: tvOS — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' # Stable only — the tvOS xcframework slice is built just for releases (above), and the
# App Store Connect record + runner platform are tvOS prerequisites.
if: startsWith(gitea.ref, 'refs/tags/v') && (gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true')
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed # Needs tvOS added to the App Store Connect app record + the tvOS platform installed
# on the runner (xcodebuild -downloadPlatform tvOS). # on the runner (xcodebuild -downloadPlatform tvOS).
continue-on-error: true continue-on-error: true
+32 -13
View File
@@ -13,9 +13,10 @@ name: rpm
on: on:
push: push:
branches: [main] branches: [main]
# HOST-scoped tags only — the Apple client's `v*` tags (release.yml) must NOT publish a host # Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
# RPM (a `v0.1.1` client tag previously shipped a host 0.1.1 that shadowed every rolling build). # groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
tags: ['host-v*'] # version-shadow (a release outranking rolling builds in one group) is structurally gone.
tags: ['v*']
workflow_dispatch: workflow_dispatch:
env: env:
@@ -66,20 +67,22 @@ jobs:
key: cargo-home-${{ hashFiles('Cargo.lock') }} key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home- restore-keys: cargo-home-
- name: Version - name: Version + channel
# host-vX.Y.Z tag -> X.Y.Z-1 (a real host release); main push -> 0.2.0-0.ciN.g<sha>, whose # vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g<sha>
# "0." release sorts BELOW the eventual 0.2.0-1 yet climbs by run number AND outranks the # in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
# stray 0.1.1, so `rpm-ostree upgrade` truly moves to the newest build. The spec %build # climbs by run number. The canary base stays one minor ahead of the latest stable so a
# stamps PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance). # stable->canary box re-point still moves forward. The spec %build stamps
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
run: | run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}"; R="1" ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
*) V="0.2.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;; *) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
esac esac
echo "PF_VERSION=$V" >> "$GITHUB_ENV" echo "PF_VERSION=$V" >> "$GITHUB_ENV"
echo "PF_RELEASE=$R" >> "$GITHUB_ENV" echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
echo "rpm $V-$R" echo "GROUP=$GROUP" >> "$GITHUB_ENV"
echo "rpm $V-$R -> group '$GROUP'"
- name: Build RPM - name: Build RPM
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below # PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
@@ -101,6 +104,22 @@ jobs:
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
echo "uploading $rpm" echo "uploading $rpm"
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/${{ matrix.group }}/upload" "https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done
echo "published to $OWNER/rpm/$GROUP"
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
- name: Attach .rpms to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for rpm in dist/*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
base="$(basename "$rpm" .rpm)"
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
done done
echo "published to $OWNER/rpm/${{ matrix.group }}"
+29 -17
View File
@@ -11,10 +11,10 @@
# #
# Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group) # Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group)
# #
# Versioning (free-form; not MSIX's 4-part rule): # Versioning (free-form; not MSIX's 4-part rule) — single project version:
# host-win-vX.Y.Z tag -> X.Y.Z (a real host release; own tag namespace, off host-v*/win-v*/v* # vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
# to avoid the version-shadow bug class — see deb.yml). # unified Gitea Release).
# main push / dispatch -> 0.2.<run_number> (rolling; climbs monotonically by run number). # main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
# #
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them # Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer # an ephemeral self-signed cert is generated and its public .cer published next to the installer
@@ -36,7 +36,7 @@ on:
- 'Cargo.lock' - 'Cargo.lock'
- 'Cargo.toml' - 'Cargo.toml'
- '.gitea/workflows/windows-host.yml' - '.gitea/workflows/windows-host.yml'
tags: ['host-win-v*'] tags: ['v*']
workflow_dispatch: workflow_dispatch:
env: env:
@@ -59,10 +59,10 @@ jobs:
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean). # (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$v = if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') { $v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
$env:GITHUB_REF_NAME -replace '^host-win-v', '' $env:GITHUB_REF_NAME -replace '^v', ''
} else { } else {
"0.2.$($env:GITHUB_RUN_NUMBER)" "0.3.$($env:GITHUB_RUN_NUMBER)"
} }
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
@@ -116,13 +116,25 @@ jobs:
if (-not $files) { throw "pack produced no artifacts to publish" } if (-not $files) { throw "pack produced no artifacts to publish" }
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)" $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" } foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" }
# On a tagged release, also refresh the stable `latest/` alias (delete-then-reupload, like # Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
# flatpak.yml/decky.yml) so there's a predictable download URL. # predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') { $alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
$aliases = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' } $aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
foreach ($f in $files) { foreach ($f in $files) {
$alias = $aliases[$f]; if (-not $alias) { continue } $an = $aliasNames[$f]; if (-not $an) { continue }
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/latest/$alias" 2>$null curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
Publish-File $f "$base/latest/$alias" Publish-File $f "$base/$alias/$an"
} }
# On a real release, also attach the signed installer (+ its .cer) to the unified Gitea Release.
- name: Attach host installer to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
} }
+46 -12
View File
@@ -11,11 +11,12 @@
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group) # Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md. # Packaging internals: clients/windows/packaging/README.md.
# #
# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so: # Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
# win-vX.Y.Z tag -> X.Y.Z.0 (a real Windows-client release; `win-v*` is its own tag namespace, # vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
# kept off the host's `host-v*` and the Apple `v*` to avoid the # Published to the generic registry + the stable `latest/` alias + attached to the
# version-shadow class of bug — see deb.yml). # unified Gitea Release alongside every other platform's artifact.
# main push / dispatch -> 0.2.<run_number>.0 (rolling; climbs monotonically by run number). # main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
# Published to the generic registry + the `canary/` alias.
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). # Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
# #
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets # Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
@@ -34,7 +35,7 @@ on:
- 'Cargo.lock' - 'Cargo.lock'
- 'Cargo.toml' - 'Cargo.toml'
- '.gitea/workflows/windows-msix.yml' - '.gitea/workflows/windows-msix.yml'
tags: ['win-v*'] tags: ['v*']
workflow_dispatch: workflow_dispatch:
env: env:
@@ -72,10 +73,11 @@ jobs:
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }}
$parts = if ($env:GITHUB_REF -like 'refs/tags/win-v*') { $parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.') # MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
} else { } else {
@('0', '2', $env:GITHUB_RUN_NUMBER) @('0', '3', $env:GITHUB_RUN_NUMBER)
} }
while ($parts.Count -lt 4) { $parts += '0' } while ($parts.Count -lt 4) { $parts += '0' }
$v = ($parts[0..3] -join '.') $v = ($parts[0..3] -join '.')
@@ -101,11 +103,43 @@ jobs:
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
$PSNativeCommandUseErrorActionPreference = $false
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
$aliasNames = @{
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
}
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } $files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" } if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
Write-Output "published $url"
}
foreach ($f in $files) { foreach ($f in $files) {
$name = Split-Path $f -Leaf $name = Split-Path $f -Leaf
$url = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)/$($env:MSIX_VERSION)/$name" # 1) immutable, versioned path
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url" Put $f "$base/$($env:MSIX_VERSION)/$name"
Write-Output "published $name -> $url" # 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
$an = $aliasNames["$f"]
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
Put $f "$base/$alias/$an"
}
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
# the race, and x64/arm64 filenames differ so the assets don't collide.
- name: Attach MSIX to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
} }
+9 -1
View File
@@ -65,7 +65,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad `send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD` echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
env > uinput Xbox 360; DualSense (UHID) only on Linux hosts. env > uinput Xbox 360. Backends: **Xbox 360** (uinput / ViGEm), **Xbox One/Series** (the same
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
LEDs / mute). The UHID pads need a Linux host; off Linux they (and One/Series) fold into Xbox 360.
Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4,
Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for
DS4 get Xbox 360 for now.
- **Windows host: implemented and shipping (NVIDIA-only, x64-only).** `#[cfg(windows)]` backends - **Windows host: implemented and shipping (NVIDIA-only, x64-only).** `#[cfg(windows)]` backends
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA** behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
virtual display per session (`vdisplay/sudovda.rs`), NVENC encode (`--features nvenc`), SendInput + virtual display per session (`vdisplay/sudovda.rs`), NVENC encode (`--features nvenc`), SendInput +
+3 -1
View File
@@ -26,7 +26,9 @@ android {
targetSdk = 36 targetSdk = 36
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE")) val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
versionCode = vCode?.toInt() ?: 1 versionCode = vCode?.toInt() ?: 1
versionName = "0.0.2" // bumped for first Play Store release // versionName is the single project version, threaded from CI (a vX.Y.Z release or a
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
ndk { abiFilters += listOf("arm64-v8a", "x86_64") } ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
} }
@@ -63,6 +63,7 @@ import androidx.core.content.ContextCompat
import io.unom.punktfunk.components.EmptyHostsState import io.unom.punktfunk.components.EmptyHostsState
import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.SectionLabel import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery import io.unom.punktfunk.kit.discovery.HostDiscovery
@@ -143,11 +144,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
// Advertise HDR only when this device's display can present it (else the host sends a // Advertise HDR only when this device's display can present it (else the host sends a
// proper SDR stream rather than PQ the panel would mis-tone-map). // proper SDR stream rather than PQ the panel would mis-tone-map).
val hdrEnabled = displaySupportsHdr(context) val hdrEnabled = displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
// explicit choice is passed through unchanged.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
val handle = withContext(Dispatchers.IO) { val handle = withContext(Dispatchers.IO) {
NativeBridge.nativeConnect( NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz, targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex ?: "", id.certPem, id.privateKeyPem, pinHex ?: "",
settings.bitrateKbps, settings.compositor, settings.gamepad, settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, hdrEnabled,
) )
} }
@@ -142,9 +142,11 @@ val COMPOSITOR_OPTIONS = listOf(
"gamescope", "gamescope",
) )
/** index = GamepadPref wire byte. */ /** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
val GAMEPAD_OPTIONS = listOf( val GAMEPAD_OPTIONS = listOf(
"Automatic", "Automatic",
"Xbox 360", "Xbox 360",
"DualSense", "DualSense",
"Xbox One",
"DualShock 4",
) )
@@ -44,6 +44,71 @@ object Gamepad {
const val AXIS_LT = 4 const val AXIS_LT = 4
const val AXIS_RT = 5 const val AXIS_RT = 5
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
const val PREF_AUTO = 0
const val PREF_XBOX360 = 1
const val PREF_DUALSENSE = 2
const val PREF_XBOXONE = 3
const val PREF_DUALSHOCK4 = 4
// USB vendor ids of the controllers we can identify by VID/PID.
private const val VID_SONY = 0x054C
private const val VID_MICROSOFT = 0x045E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
private val PID_XBOXONE = setOf(
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
)
/**
* Resolve a connected controller's [GamepadPref] wire byte from its USB VID/PID, mirroring the
* Linux client's `pref_for_type` (SDL3 `GamepadType`) and the Apple client's GameController type
* auto-resolution. Android exposes no controller-type enum, so we match `getVendorId()` /
* `getProductId()`. Used only when the user picked "Automatic" — an explicit choice is honored as
* is. An unrecognized pad (or none) falls back to [PREF_XBOX360], the safe XInput default the
* host always supports. Never returns [PREF_AUTO] (the host would then decide) — once we have a
* physical pad we resolve it concretely, matching the other native clients.
*/
fun prefFor(dev: InputDevice?): Int {
if (dev == null) return PREF_XBOX360
val vid = dev.vendorId
val pid = dev.productId
return when {
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
else -> PREF_XBOX360
}
}
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
fun firstPad(): InputDevice? {
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
val s = d.sources
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
) {
return d
}
}
return null
}
/**
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
* type from the first connected controller via [prefFor] (so the host gets the right pad even
* though Android can't tell it the controller type any other way).
*/
fun resolvePref(setting: Int): Int =
if (setting == PREF_AUTO) prefFor(firstPad()) else setting
/** /**
* Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are * Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are
* positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred). * positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred).
@@ -81,8 +81,16 @@ class GamepadFeedback(private val handle: Long) {
rumbleThread?.interrupt() rumbleThread?.interrupt()
hidoutThread?.interrupt() hidoutThread?.interrupt()
runCatching { vm?.cancel() } // drop any held rumble immediately runCatching { vm?.cancel() } // drop any held rumble immediately
runCatching { rumbleThread?.join(200) } // Join WITHOUT a timeout. These poll threads dereference the native session handle on every
runCatching { hidoutThread?.join(200) } // pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
// and rendering is a quick best-effort binder call, so each thread observes running=false and
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
runCatching { rumbleThread?.join() }
runCatching { hidoutThread?.join() }
rumbleThread = null rumbleThread = null
hidoutThread = null hidoutThread = null
runCatching { lightsSession?.close() } runCatching { lightsSession?.close() }
@@ -94,18 +102,7 @@ class GamepadFeedback(private val handle: Long) {
} }
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */ /** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
private fun resolvePad(): InputDevice? { private fun resolvePad(): InputDevice? = Gamepad.firstPad()
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
val s = d.sources
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
) {
return d
}
}
return null
}
// ---- Rumble ---- // ---- Rumble ----
+66 -60
View File
@@ -7,7 +7,7 @@
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these //! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
//! compile on the host build too (parity with the input shims in [`crate::session`]). //! compile on the host build too (parity with the input shims in [`crate::session`]).
use crate::session::SessionHandle; use crate::session::{jni_guard, SessionHandle};
use jni::objects::{JByteBuffer, JObject}; use jni::objects::{JByteBuffer, JObject};
use jni::sys::{jint, jlong}; use jni::sys::{jint, jlong};
use jni::JNIEnv; use jni::JNIEnv;
@@ -32,17 +32,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_this: JObject, _this: JObject,
handle: jlong, handle: jlong,
) -> jlong { ) -> jlong {
if handle == 0 { // Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
return -1; jni_guard(-1, || {
} if handle == 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the return -1;
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll }
// threads (and joins them) before nativeClose frees the handle. // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
let h = unsafe { &*(handle as *const SessionHandle) }; // Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
match h.client.next_rumble(PULL_TIMEOUT) { // threads (and joins them — unbounded) before nativeClose frees the handle.
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high), let h = unsafe { &*(handle as *const SessionHandle) };
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag match h.client.next_rumble(PULL_TIMEOUT) {
} Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
})
} }
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense /// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
@@ -58,57 +61,60 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
handle: jlong, handle: jlong,
buf: JByteBuffer, buf: JByteBuffer,
) -> jint { ) -> jint {
if handle == 0 { // Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
return -1; jni_guard(-1, || {
} if handle == 0 {
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector. return -1;
let h = unsafe { &*(handle as *const SessionHandle) }; }
let ev = match h.client.next_hidout(PULL_TIMEOUT) { // SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
Ok(ev) => ev, let h = unsafe { &*(handle as *const SessionHandle) };
Err(_) => return -1, // timeout or closed — Kotlin loops let ev = match h.client.next_hidout(PULL_TIMEOUT) {
}; Ok(ev) => ev,
Err(_) => return -1, // timeout or closed — Kotlin loops
};
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly. // The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
let cap = match env.get_direct_buffer_capacity(&buf) { let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c, Ok(c) => c,
Err(_) => return -1, Err(_) => return -1,
}; };
let ptr = match env.get_direct_buffer_address(&buf) { let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p, Ok(p) if !p.is_null() => p,
_ => return -1, _ => return -1,
}; };
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call. // SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) }; let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
let n = match ev { let n = match ev {
HidOutput::Led { r, g, b, .. } => { HidOutput::Led { r, g, b, .. } => {
if cap < 4 { if cap < 4 {
return -1; return -1;
}
out[0] = TAG_LED;
out[1] = r;
out[2] = g;
out[3] = b;
4
} }
out[0] = TAG_LED; HidOutput::PlayerLeds { bits, .. } => {
out[1] = r; if cap < 2 {
out[2] = g; return -1;
out[3] = b; }
4 out[0] = TAG_PLAYER_LEDS;
} out[1] = bits;
HidOutput::PlayerLeds { bits, .. } => { 2
if cap < 2 {
return -1;
} }
out[0] = TAG_PLAYER_LEDS; HidOutput::Trigger { which, effect, .. } => {
out[1] = bits; let n = 2 + effect.len();
2 if cap < n {
} return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
HidOutput::Trigger { which, effect, .. } => { }
let n = 2 + effect.len(); out[0] = TAG_TRIGGER;
if cap < n { out[1] = which;
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64 out[2..n].copy_from_slice(&effect);
n
} }
out[0] = TAG_TRIGGER; };
out[1] = which; n as jint
out[2..n].copy_from_slice(&effect); })
n
}
};
n as jint
} }
+76 -49
View File
@@ -19,11 +19,28 @@ use jni::JNIEnv;
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread::JoinHandle; use std::thread::JoinHandle;
use std::time::Duration; use std::time::Duration;
/// Run a JNI body, catching any panic at the FFI boundary and returning `default` instead.
///
/// A panic unwinding out of an `extern "system"` function aborts the whole process on Rust ≥ 1.81 —
/// a hard crash of the embedding Android app with no logcat trace. This mirrors the discipline the C
/// ABI already enforces (`punktfunk_core::abi` wraps every entry point in `catch_unwind`); the
/// `panic = "unwind"` profile in the workspace `Cargo.toml` exists precisely so these guards work.
/// We apply it to the teardown + background-thread shims (the "leaving a stream" path), where an
/// unexpected panic (e.g. a poisoned `Mutex` during concurrent teardown) must degrade to a logged
/// no-op rather than kill the app.
pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| {
log::error!("punktfunk JNI: caught a panic at the FFI boundary (returning default)");
default
})
}
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds. /// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
pub(crate) struct SessionHandle { pub(crate) struct SessionHandle {
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host // Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
@@ -231,10 +248,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_this: JObject, _this: JObject,
handle: jlong, handle: jlong,
) { ) {
if handle != 0 { jni_guard((), || {
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer. if handle != 0 {
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) }; // SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
} unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
}
})
} }
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host /// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
@@ -367,11 +386,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
_this: JObject, _this: JObject,
handle: jlong, handle: jlong,
) { ) {
if handle != 0 { jni_guard((), || {
// SAFETY: live handle per the contract. if handle != 0 {
let h = unsafe { &*(handle as *const SessionHandle) }; // SAFETY: live handle per the contract.
h.stop_video(); let h = unsafe { &*(handle as *const SessionHandle) };
} h.stop_video();
}
})
} }
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD. /// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
@@ -386,36 +407,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
_this: JObject, _this: JObject,
handle: jlong, handle: jlong,
) -> jdoubleArray { ) -> jdoubleArray {
if handle == 0 { jni_guard(std::ptr::null_mut(), || {
return std::ptr::null_mut(); if handle == 0 {
} return std::ptr::null_mut();
// SAFETY: live handle per the nativeConnect/nativeClose contract. }
let h = unsafe { &*(handle as *const SessionHandle) }; // SAFETY: live handle per the nativeConnect/nativeClose contract.
let snap = match h.video.lock().unwrap().as_ref() { let h = unsafe { &*(handle as *const SessionHandle) };
Some(vt) => vt.stats.drain(), let snap = match h.video.lock().unwrap().as_ref() {
None => return std::ptr::null_mut(), // not streaming → no stats Some(vt) => vt.stats.drain(),
}; None => return std::ptr::null_mut(), // not streaming → no stats
let mode = h.client.mode(); };
let buf: [f64; 10] = [ let mode = h.client.mode();
snap.fps, let buf: [f64; 10] = [
snap.mbps, snap.fps,
snap.lat_p50_ms, snap.mbps,
snap.lat_p95_ms, snap.lat_p50_ms,
if snap.lat_valid { 1.0 } else { 0.0 }, snap.lat_p95_ms,
if snap.skew_corrected { 1.0 } else { 0.0 }, if snap.lat_valid { 1.0 } else { 0.0 },
mode.width as f64, if snap.skew_corrected { 1.0 } else { 0.0 },
mode.height as f64, mode.width as f64,
mode.refresh_hz as f64, mode.height as f64,
h.client.frames_dropped() as f64, mode.refresh_hz as f64,
]; h.client.frames_dropped() as f64,
let arr = match env.new_double_array(buf.len() as jsize) { ];
Ok(a) => a, let arr = match env.new_double_array(buf.len() as jsize) {
Err(_) => return std::ptr::null_mut(), Ok(a) => a,
}; Err(_) => return std::ptr::null_mut(),
if env.set_double_array_region(&arr, 0, &buf).is_err() { };
return std::ptr::null_mut(); if env.set_double_array_region(&arr, 0, &buf).is_err() {
} return std::ptr::null_mut();
arr.into_raw() }
arr.into_raw()
})
} }
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already /// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
@@ -451,11 +474,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
_this: JObject, _this: JObject,
handle: jlong, handle: jlong,
) { ) {
if handle != 0 { jni_guard((), || {
// SAFETY: live handle per the contract. if handle != 0 {
let h = unsafe { &*(handle as *const SessionHandle) }; // SAFETY: live handle per the contract.
h.stop_audio(); let h = unsafe { &*(handle as *const SessionHandle) };
} h.stop_audio();
}
})
} }
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`). /// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
@@ -492,11 +517,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
_this: JObject, _this: JObject,
handle: jlong, handle: jlong,
) { ) {
if handle != 0 { jni_guard((), || {
// SAFETY: live handle per the contract. if handle != 0 {
let h = unsafe { &*(handle as *const SessionHandle) }; // SAFETY: live handle per the contract.
h.stop_mic(); let h = unsafe { &*(handle as *const SessionHandle) };
} h.stop_mic();
}
})
} }
// ---- Input plane: Kotlin capture → NativeClient::send_input ---------------------------------- // ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
@@ -511,15 +511,18 @@ struct SettingsView: View {
private static let padTypes: [(label: String, tag: Int)] = [ private static let padTypes: [(label: String, tag: Int)] = [
("Automatic", 0), ("Automatic", 0),
("Xbox 360", 1), ("Xbox 360", 1),
("Xbox One", 3),
("DualSense", 2), ("DualSense", 2),
("DualShock 4", 4),
] ]
private static let controllersFooter = private static let controllersFooter =
"One controller is forwarded to the host, as player 1 — Automatic picks the most " "One controller is forwarded to the host, as player 1 — Automatic picks the most "
+ "recently connected one. The type is the virtual pad the host creates: Automatic " + "recently connected one. The type is the virtual pad the host creates: Automatic "
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad " + "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
+ "and motion), and changes apply from the next session. Two identical controllers " + "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
+ "may swap a manual selection after reconnecting." + "from the next session. Two identical controllers may swap a manual selection "
+ "after reconnecting."
/// "Use controller" choices: Automatic, every forwardable controller, and so a stale /// "Use controller" choices: Automatic, every forwardable controller, and so a stale
/// pin stays visible instead of leaving the Picker selection tag-less any pinned id /// pin stays visible instead of leaving the Picker selection tag-less any pinned id
@@ -537,7 +540,7 @@ struct SettingsView: View {
private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View { private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: controller.isDualSense ? "playstation.logo" : "gamecontroller.fill") Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(controller.name) Text(controller.name)
@@ -6,12 +6,14 @@
// full GCExtendedGamepad state on every valueChanged and diff against the previous // full GCExtendedGamepad state on every valueChanged and diff against the previous
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. // snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
// //
// DualSense extras ride the rich-input plane (0xCC): touchpad contacts normalized // PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
// 0...65535 (origin top-left, +y down GC's ±1/+y-up is converted here) and motion // 0...65535 (origin top-left, +y down GC's ±1/+y-up is converted here) and motion
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g // samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g
// derived from the host's fixed calibration blob; the conversion lives in ONE place, // derived from the host's fixed calibration blob; the conversion lives in ONE place,
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both // `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
// unless the session's virtual pad is a DualSense. // unless the session's virtual pad is a DualSense or DualShock 4 both carry a touchpad
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
// `touchpad*` surface as `GCDualSenseGamepad`).
// //
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture // Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
// toggle a controller can't click local UI, so it always drives the host while the app // toggle a controller can't click local UI, so it always drives the host while the app
@@ -154,8 +156,9 @@ public final class GamepadCapture {
releaseAll() releaseAll()
if let ext = bound?.extendedGamepad { if let ext = bound?.extendedGamepad {
ext.valueChangedHandler = nil ext.valueChangedHandler = nil
(ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil let tp = Self.touchpad(ext)
(ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil tp?.primary.valueChangedHandler = nil
tp?.secondary.valueChangedHandler = nil
} }
if let motion = bound?.motion { if let motion = bound?.motion {
motion.valueChangedHandler = nil motion.valueChangedHandler = nil
@@ -186,11 +189,11 @@ public final class GamepadCapture {
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0)) connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
sync(ext) sync(ext)
if let ds = ext as? GCDualSenseGamepad { if let tp = Self.touchpad(ext) {
ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in tp.primary.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) } MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
} }
ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in tp.secondary.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) } MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
} }
} }
@@ -257,12 +260,29 @@ public final class GamepadCapture {
if g.buttonB.isPressed { b |= GamepadWire.b } if g.buttonB.isPressed { b |= GamepadWire.b }
if g.buttonX.isPressed { b |= GamepadWire.x } if g.buttonX.isPressed { b |= GamepadWire.x }
if g.buttonY.isPressed { b |= GamepadWire.y } if g.buttonY.isPressed { b |= GamepadWire.y }
if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true { if Self.touchpad(g)?.button.isPressed == true {
b |= GamepadWire.touchpadClick b |= GamepadWire.touchpadClick
} }
return b return b
} }
/// The touchpad surface of a PlayStation pad present on both `GCDualSenseGamepad` and
/// `GCDualShockGamepad` (DualShock 4), which don't share a common touchpad type, so we
/// downcast either and project the identical `touchpad*` properties. `nil` for any other
/// controller (Xbox, MFi).
private static func touchpad(
_ g: GCExtendedGamepad
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)? {
if let ds = g as? GCDualSenseGamepad {
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
}
if let ds4 = g as? GCDualShockGamepad {
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
}
return nil
}
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on /// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
/// lift treated as the lift signal (a real finger landing on the precise center /// lift treated as the lift signal (a real finger landing on the precise center
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence). /// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
@@ -8,8 +8,9 @@
// trigger FX DualSenseTriggerEffect.parse GCDualSenseAdaptiveTrigger. // trigger FX DualSenseTriggerEffect.parse GCDualSenseAdaptiveTrigger.
// //
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists // Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
// only on DualSense sessions the drain always polls both planes with short timeouts and // only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) the
// never spins, so an Xbox session just renders rumble. GameController profile mutation // drain always polls both planes with short timeouts and never spins, so an Xbox session
// just renders rumble. GameController profile mutation
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself // happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
// touches neither. When GamepadManager switches the active controller mid-session, the // touches neither. When GamepadManager switches the active controller mid-session, the
// old pad is reset (triggers off, player index unset) and the last known feedback state // old pad is reset (triggers off, player index unset) and the last known feedback state
@@ -248,9 +249,12 @@ public final class GamepadFeedback {
public func start() { public func start() {
guard !drainStarted else { return } guard !drainStarted else { return }
drainStarted = true drainStarted = true
// No hidout traffic can exist on a non-DualSense session poll that plane // Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
// nonblocking there and let rumble own the wait. // session a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0 // let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
let hasHidout = connection.resolvedGamepad == .dualSense
|| connection.resolvedGamepad == .dualShock4
let hidTimeout: UInt32 = hasHidout ? 10 : 0
let thread = Thread { [connection, flag, drainDone, weak self] in let thread = Thread { [connection, flag, drainDone, weak self] in
while !flag.isStopped { while !flag.isStopped {
do { do {
@@ -30,11 +30,22 @@ public final class GamepadManager: ObservableObject {
public let productCategory: String public let productCategory: String
/// The full extended profile exists only these are forwardable. /// The full extended profile exists only these are forwardable.
public let isExtended: Bool public let isExtended: Bool
public let isDualSense: Bool /// The virtual-pad type a physical match resolves to under `.auto`: DualSense
/// `.dualSense`, DualShock 4 `.dualShock4`, an Xbox pad `.xboxOne`, anything
/// else `.xbox360`. (`.auto` is never stored here.)
public let kind: PunktfunkConnection.GamepadType
public let hasLight: Bool public let hasLight: Bool
public let hasHaptics: Bool public let hasHaptics: Bool
public let hasMotion: Bool public let hasMotion: Bool
public let hasAdaptiveTriggers: Bool public let hasAdaptiveTriggers: Bool
/// Specifically a DualSense gates the DualSense-only feedback (adaptive triggers,
/// player LEDs) and the PlayStation glyph in Settings.
public var isDualSense: Bool { kind == .dualSense }
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) gates
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
public var hasTouchpadAndMotion: Bool {
kind == .dualSense || kind == .dualShock4
}
/// 0...1, nil when the controller doesn't report a battery (e.g. wired). /// 0...1, nil when the controller doesn't report a battery (e.g. wired).
public let batteryLevel: Float? public let batteryLevel: Float?
public let isCharging: Bool public let isCharging: Bool
@@ -102,7 +113,8 @@ public final class GamepadManager: ObservableObject {
/// Connect-time resolution of the user's controller-type setting: an explicit choice /// Connect-time resolution of the user's controller-type setting: an explicit choice
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense /// wins; `.auto` matches the virtual pad to the active physical controller (DualSense
/// DualSense, anything else Xbox 360); no controller at all defers to the host. /// DualSense, DualShock 4 DualShock 4, an Xbox pad Xbox One, anything else Xbox
/// 360); no controller at all defers to the host.
public func resolveType( public func resolveType(
setting: PunktfunkConnection.GamepadType setting: PunktfunkConnection.GamepadType
) -> PunktfunkConnection.GamepadType { ) -> PunktfunkConnection.GamepadType {
@@ -113,7 +125,7 @@ public final class GamepadManager: ObservableObject {
// pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race. // pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race.
rebuild() rebuild()
guard let active else { return .auto } guard let active else { return .auto }
return active.isDualSense ? .dualSense : .xbox360 return active.kind
} }
private func noteConnected(_ c: GCController) { private func noteConnected(_ c: GCController) {
@@ -152,20 +164,38 @@ public final class GamepadManager: ObservableObject {
private static func describe(_ c: GCController, id: String) -> DiscoveredController { private static func describe(_ c: GCController, id: String) -> DiscoveredController {
let extended = c.extendedGamepad let extended = c.extendedGamepad
let ds = extended as? GCDualSenseGamepad let kind = padKind(extended)
return DiscoveredController( return DiscoveredController(
id: id, id: id,
name: c.vendorName ?? c.productCategory, name: c.vendorName ?? c.productCategory,
productCategory: c.productCategory, productCategory: c.productCategory,
isExtended: extended != nil, isExtended: extended != nil,
isDualSense: ds != nil, kind: kind,
hasLight: c.light != nil, hasLight: c.light != nil,
hasHaptics: c.haptics != nil, hasHaptics: c.haptics != nil,
hasMotion: c.motion != nil, hasMotion: c.motion != nil,
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration. // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
hasAdaptiveTriggers: ds != nil, // DualShock 4 has none.
hasAdaptiveTriggers: kind == .dualSense,
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil }, batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
isCharging: c.battery?.batteryState == .charging, isCharging: c.battery?.batteryState == .charging,
controller: c) controller: c)
} }
/// Resolve a physical controller's matching virtual-pad type from its GameController
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
private static func padKind(
_ extended: GCExtendedGamepad?
) -> PunktfunkConnection.GamepadType {
guard let extended else { return .xbox360 }
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
// here, so no `@available` guard is needed matching the unguarded
// `GCDualSenseGamepad` use elsewhere in the package.
if extended is GCDualSenseGamepad { return .dualSense }
if extended is GCDualShockGamepad { return .dualShock4 }
if extended is GCXboxGamepad { return .xboxOne }
return .xbox360
}
} }
@@ -170,13 +170,18 @@ public final class PunktfunkConnection {
/// Which virtual gamepad the host creates for this session's pads (the /// Which virtual gamepad the host creates for this session's pads (the
/// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else /// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else
/// X-Box 360); `.dualSense` is honored only on hosts with UHID (Linux) games then see /// X-Box 360); `.dualSense` / `.dualShock4` are honored only on hosts with UHID (Linux)
/// a real DualSense and their lightbar / adaptive-trigger writes come back on the /// games then see a real PlayStation pad and its lightbar (and, on a DualSense,
/// HID-output plane (`nextHidOutput`). The host's actual choice is `resolvedGamepad`. /// adaptive-trigger / player-LED) writes come back on the HID-output plane
/// (`nextHidOutput`). `.xboxOne` is an X-Box-Series-glyph variant of `.xbox360` (same
/// buttons/sticks/triggers + rumble, no touchpad/motion/lightbar). The host's actual
/// choice is `resolvedGamepad`.
public enum GamepadType: UInt32, CaseIterable, Sendable { public enum GamepadType: UInt32, CaseIterable, Sendable {
case auto = 0 case auto = 0
case xbox360 = 1 case xbox360 = 1
case dualSense = 2 case dualSense = 2
case xboxOne = 3
case dualShock4 = 4
/// Loose name parsing for env/dev hooks, mirroring the host's /// Loose name parsing for env/dev hooks, mirroring the host's
/// `GamepadPref::from_name`. /// `GamepadPref::from_name`.
@@ -184,7 +189,9 @@ public final class PunktfunkConnection {
switch name.lowercased() { switch name.lowercased() {
case "auto", "default": self = .auto case "auto", "default": self = .auto
case "xbox", "xbox360", "x360", "uinput": self = .xbox360 case "xbox", "xbox360", "x360", "uinput": self = .xbox360
case "dualsense", "ds", "ps5": self = .dualSense case "dualsense", "ds", "ds5", "ps5": self = .dualSense
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
default: return nil default: return nil
} }
} }
@@ -497,10 +504,11 @@ public final class PunktfunkConnection {
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8]) case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
} }
/// Pull the next DualSense feedback event (lightbar / player LEDs / adaptive triggers); /// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive
/// nil on timeout, throws `.closed` once the session ended. Drain from the (single) /// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the
/// feedback thread, alongside `nextRumble`. Nothing ever arrives unless /// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's
/// `resolvedGamepad == .dualSense` poll with a short timeout, never spin. /// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) poll with a
/// short timeout, never spin.
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? { public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
feedbackLock.lock() feedbackLock.lock()
defer { feedbackLock.unlock() } defer { feedbackLock.unlock() }
+36 -6
View File
@@ -39,7 +39,39 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
pub struct PadInfo { pub struct PadInfo {
pub id: u32, pub id: u32,
pub name: String, pub name: String,
pub is_dualsense: bool, /// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
pub pref: GamepadPref,
}
impl PadInfo {
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
fn is_dualsense(&self) -> bool {
self.pref == GamepadPref::DualSense
}
/// A short controller-kind label for the Settings list (`""` for a plain Xbox/standard pad).
pub fn kind_label(&self) -> &'static str {
match self.pref {
GamepadPref::DualSense => "DualSense",
GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One",
_ => "",
}
}
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T;
match t {
T::PS5 => GamepadPref::DualSense,
T::PS4 => GamepadPref::DualShock4,
T::XboxOne => GamepadPref::XboxOne,
_ => GamepadPref::Xbox360,
}
} }
enum Ctl { enum Ctl {
@@ -120,8 +152,7 @@ impl GamepadService {
/// (Swift parity); no pad connected leaves the host's own default. /// (Swift parity); no pad connected leaves the host's own default.
pub fn auto_pref(&self) -> GamepadPref { pub fn auto_pref(&self) -> GamepadPref {
match self.active() { match self.active() {
Some(p) if p.is_dualsense => GamepadPref::DualSense, Some(p) => p.pref,
Some(_) => GamepadPref::Xbox360,
None => GamepadPref::Auto, None => GamepadPref::Auto,
} }
} }
@@ -247,10 +278,9 @@ impl Worker {
Some(PadInfo { Some(PadInfo {
id, id,
name: pad.name().unwrap_or_else(|| "Controller".into()), name: pad.name().unwrap_or_else(|| "Controller".into()),
is_dualsense: matches!( pref: pref_for_type(
self.subsystem self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
sdl3::gamepad::GamepadType::PS5
), ),
}) })
} }
@@ -552,7 +582,7 @@ fn run(
} }
while let Ok(hid) = connector.next_hidout(Duration::ZERO) { while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = w.active_id() else { continue }; let Some(id) = w.active_id() else { continue };
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense); let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
let Some(pad) = w.opened.get_mut(&id) else { let Some(pad) = w.opened.get_mut(&id) else {
continue; continue;
}; };
+7 -4
View File
@@ -16,7 +16,7 @@ const RESOLUTIONS: &[(u32, u32)] = &[
]; ];
/// `0` = the monitor's native refresh, resolved at connect. /// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"]; const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
pub fn show( pub fn show(
@@ -85,10 +85,11 @@ pub fn show(
let pads = gamepads.pads(); let pads = gamepads.pads();
let mut pad_names = vec!["Automatic (most recent)".to_string()]; let mut pad_names = vec!["Automatic (most recent)".to_string()];
pad_names.extend(pads.iter().map(|p| { pad_names.extend(pads.iter().map(|p| {
if p.is_dualsense { let kind = p.kind_label();
format!("{} · DualSense", p.name) if kind.is_empty() {
} else {
p.name.clone() p.name.clone()
} else {
format!("{} · {kind}", p.name)
} }
})); }));
let forward_row = adw::ComboRow::builder() let forward_row = adw::ComboRow::builder()
@@ -126,6 +127,8 @@ pub fn show(
"Automatic", "Automatic",
"Xbox 360", "Xbox 360",
"DualSense", "DualSense",
"Xbox One",
"DualShock 4",
])) ]))
.build(); .build();
let inhibit_row = adw::SwitchRow::builder() let inhibit_row = adw::SwitchRow::builder()
+7 -4
View File
@@ -27,9 +27,10 @@
//! `gamescope`); the host honors it if available, else auto-detects and reports the resolved //! `gamescope`); the host honors it if available, else auto-detects and reports the resolved
//! choice in its Welcome (logged as `session offer … compositor=…`). //! choice in its Welcome (logged as `session offer … compositor=…`).
//! //!
//! `--gamepad NAME` requests a host virtual-pad backend (`auto`|`xbox360`|`dualsense`); the //! `--gamepad NAME` requests a host virtual-pad backend
//! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360, //! (`auto`|`xbox360`|`dualsense`|`xboxone`|`dualshock4`); the host honors it where available (the
//! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`). //! UHID pads — DualSense, DualShock 4 — need Linux), else falls back to X-Box 360, and reports the
//! resolved choice in its Welcome (logged as `session offer … gamepad=…`).
//! //!
//! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises //! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and //! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
@@ -178,7 +179,9 @@ fn parse_args() -> Args {
Some(s) => match GamepadPref::from_name(s) { Some(s) => match GamepadPref::from_name(s) {
Some(g) => g, Some(g) => g,
None => { None => {
eprintln!("--gamepad must be one of: auto, xbox360, dualsense"); eprintln!(
"--gamepad must be one of: auto, xbox360, dualsense, xboxone, dualshock4"
);
std::process::exit(2); std::process::exit(2);
} }
}, },
+5 -4
View File
@@ -4,7 +4,8 @@ The Windows client ships as **signed MSIX** packages so Windows boxes get a real
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's [`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that **generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
touches the client and on `win-v*` release tags. touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
[Release Channels](https://punktfunk.unom.io/docs/channels).
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single **Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
@@ -39,9 +40,9 @@ because it owns raw D3D11, Win32 low-level input hooks, WASAPI and SDL3.
## Versioning ## Versioning
MSIX requires a strictly 4-part numeric version. The workflow computes: MSIX requires a strictly 4-part numeric version. The workflow computes:
- `win-vX.Y.Z` tag → `X.Y.Z.0` (a real client release; `win-v*` is its own tag namespace, kept off - `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to
the host's `host-v*` and Apple's `v*` to avoid the version-shadow bug). the stable `latest/` alias and attached to the unified Gitea Release.
- `main` push / `workflow_dispatch``0.2.<run_number>.0` (rolling, climbs by run number). - `main` push / `workflow_dispatch``0.3.<run_number>.0` (canary, climbs by run number; `canary/` alias).
## Signing & install ## Signing & install
+26 -7
View File
@@ -32,12 +32,33 @@ const G: f32 = 9.80665;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PadInfo { pub struct PadInfo {
// `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only // `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only
// reads `is_dualsense` (via `auto_pref`), so they're unused in reachable code for now. // reads `pref` (via `auto_pref`), so they're unused in reachable code for now.
#[allow(dead_code)] #[allow(dead_code)]
pub id: u32, pub id: u32,
#[allow(dead_code)] #[allow(dead_code)]
pub name: String, pub name: String,
pub is_dualsense: bool, /// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
pub pref: GamepadPref,
}
impl PadInfo {
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
fn is_dualsense(&self) -> bool {
self.pref == GamepadPref::DualSense
}
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T;
match t {
T::PS5 => GamepadPref::DualSense,
T::PS4 => GamepadPref::DualShock4,
T::XboxOne => GamepadPref::XboxOne,
_ => GamepadPref::Xbox360,
}
} }
enum Ctl { enum Ctl {
@@ -112,8 +133,7 @@ impl GamepadService {
/// (Swift parity); no pad connected leaves the host's own default. /// (Swift parity); no pad connected leaves the host's own default.
pub fn auto_pref(&self) -> GamepadPref { pub fn auto_pref(&self) -> GamepadPref {
match self.active() { match self.active() {
Some(p) if p.is_dualsense => GamepadPref::DualSense, Some(p) => p.pref,
Some(_) => GamepadPref::Xbox360,
None => GamepadPref::Auto, None => GamepadPref::Auto,
} }
} }
@@ -235,10 +255,9 @@ impl Worker {
Some(PadInfo { Some(PadInfo {
id, id,
name: pad.name().unwrap_or_else(|| "Controller".into()), name: pad.name().unwrap_or_else(|| "Controller".into()),
is_dualsense: matches!( pref: pref_for_type(
self.subsystem self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
sdl3::gamepad::GamepadType::PS5
), ),
}) })
} }
@@ -515,7 +534,7 @@ fn run(
} }
while let Ok(hid) = connector.next_hidout(Duration::ZERO) { while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = w.active_id() else { continue }; let Some(id) = w.active_id() else { continue };
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense); let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
let Some(pad) = w.opened.get_mut(&id) else { let Some(pad) = w.opened.get_mut(&id) else {
continue; continue;
}; };
+20
View File
@@ -687,6 +687,16 @@ pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored /// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
/// only where available (Linux hosts); otherwise the host falls back to X-Box 360. /// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2; pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
/// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a
/// physical X-Box One/Series controller on the client.
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
/// hosts); otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. /// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to /// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
@@ -706,6 +716,16 @@ const _: () = {
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR); assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
}; };
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
const _: () = {
use crate::config::GamepadPref;
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
};
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's /// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
/// certificate — a mismatching host is rejected. NULL = trust on first use; persist the /// certificate — a mismatching host is rejected. NULL = trust on first use; persist the
/// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and /// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
+25 -7
View File
@@ -135,10 +135,10 @@ impl CompositorPref {
/// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the /// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the
/// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the /// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the
/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is /// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is
/// honored only if that backend is available on the host (DualSense needs Linux UHID); otherwise /// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
/// the host falls back and reports the real choice in `Welcome`. The wire form is a single byte /// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
/// (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`), appended to `Hello`/`Welcome` — older peers /// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`), appended to
/// simply omit/ignore it. /// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GamepadPref { pub enum GamepadPref {
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
@@ -148,15 +148,24 @@ pub enum GamepadPref {
Xbox360, Xbox360,
/// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion. /// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion.
DualSense, DualSense,
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity
/// (VID/PID/name), so games show One/Series glyphs. XInput-identical otherwise (impulse-trigger
/// rumble is unreachable through any virtual pad, so there's no game-visible gain over `Xbox360`).
XboxOne,
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
DualShock4,
} }
impl GamepadPref { impl GamepadPref {
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`. /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`.
pub fn to_u8(self) -> u8 { pub const fn to_u8(self) -> u8 {
match self { match self {
GamepadPref::Auto => 0, GamepadPref::Auto => 0,
GamepadPref::Xbox360 => 1, GamepadPref::Xbox360 => 1,
GamepadPref::DualSense => 2, GamepadPref::DualSense => 2,
GamepadPref::XboxOne => 3,
GamepadPref::DualShock4 => 4,
} }
} }
@@ -166,6 +175,8 @@ impl GamepadPref {
match v { match v {
1 => GamepadPref::Xbox360, 1 => GamepadPref::Xbox360,
2 => GamepadPref::DualSense, 2 => GamepadPref::DualSense,
3 => GamepadPref::XboxOne,
4 => GamepadPref::DualShock4,
_ => GamepadPref::Auto, _ => GamepadPref::Auto,
} }
} }
@@ -177,16 +188,23 @@ impl GamepadPref {
"auto" | "default" => GamepadPref::Auto, "auto" | "default" => GamepadPref::Auto,
"xbox" | "xbox360" | "x360" | "uinput" => GamepadPref::Xbox360, "xbox" | "xbox360" | "x360" | "uinput" => GamepadPref::Xbox360,
"dualsense" | "ds" | "ps5" => GamepadPref::DualSense, "dualsense" | "ds" | "ps5" => GamepadPref::DualSense,
"xboxone" | "xbox-one" | "xone" | "xbox1" | "series" | "xboxseries" => {
GamepadPref::XboxOne
}
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
_ => return None, _ => return None,
}) })
} }
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`). /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
/// `"dualshock4"`).
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
GamepadPref::Auto => "auto", GamepadPref::Auto => "auto",
GamepadPref::Xbox360 => "xbox360", GamepadPref::Xbox360 => "xbox360",
GamepadPref::DualSense => "dualsense", GamepadPref::DualSense => "dualsense",
GamepadPref::XboxOne => "xboxone",
GamepadPref::DualShock4 => "dualshock4",
} }
} }
} }
+12
View File
@@ -1883,13 +1883,25 @@ mod tests {
GamepadPref::Auto, GamepadPref::Auto,
GamepadPref::Xbox360, GamepadPref::Xbox360,
GamepadPref::DualSense, GamepadPref::DualSense,
GamepadPref::XboxOne,
GamepadPref::DualShock4,
] { ] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p); assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p)); assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
} }
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
// Aliases + unknowns. // Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense)); assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360)); assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
assert_eq!(
GamepadPref::from_name("xbox-one"),
Some(GamepadPref::XboxOne)
);
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
assert_eq!(GamepadPref::from_name("nope"), None); assert_eq!(GamepadPref::from_name("nope"), None);
// Unknown wire byte degrades to Auto (forward-compatible). // Unknown wire byte degrades to Auto (forward-compatible).
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto); assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
+2
View File
@@ -424,6 +424,8 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod dualsense; pub mod dualsense;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod dualshock4;
#[cfg(target_os = "linux")]
pub mod gamepad; pub mod gamepad;
/// Windows: virtual Xbox 360 pads via ViGEmBus. /// Windows: virtual Xbox 360 pads via ViGEmBus.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -0,0 +1,629 @@
//! Virtual Sony DualShock 4 (PS4) via UHID — the PS4 sibling of the DualSense backend
//! ([`super::dualsense`]). A UHID device presents a *real* DualShock 4 HID interface to the kernel:
//! `hid-playstation` binds it (matched by VID `054C`/PID `09CC`, since Linux 6.2) and exposes the
//! full controller — gamepad, motion sensors, touchpad, lightbar, rumble — to games. We write HID
//! **input** reports (report `0x01`, our controller state) and read HID **output** reports (report
//! `0x05`, a game's rumble/lightbar feedback) back, forwarding them to the client.
//!
//! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute
//! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal
//! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is
//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; only the
//! report *byte layout*, the report descriptor, the feature-report handshake and the touchpad
//! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout
//! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse.
use super::dualsense::{DsState, Touch};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::time::{Duration, Instant};
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03;
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
// non-fatal (the kernel warns + falls back to identity IMU calibration), but we answer them for
// correct motion scaling. Each array's first byte is the report id (the kernel hard-checks it).
#[rustfmt::skip]
const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01)
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
0x02,
0x00, 0x00, // gyro_pitch_bias = 0
0x00, 0x00, // gyro_yaw_bias = 0
0x00, 0x00, // gyro_roll_bias = 0
0x10, 0x00, // gyro_pitch_plus = +16
0xF0, 0xFF, // gyro_pitch_minus = -16
0x10, 0x00, // gyro_yaw_plus = +16
0xF0, 0xFF, // gyro_yaw_minus = -16
0x10, 0x00, // gyro_roll_plus = +16
0xF0, 0xFF, // gyro_roll_minus = -16
0x20, 0x00, // gyro_speed_plus = +32
0x20, 0x00, // gyro_speed_minus = +32
0x00, 0x20, // acc_x_plus = +8192
0x00, 0xE0, // acc_x_minus = -8192
0x00, 0x20, // acc_y_plus = +8192
0x00, 0xE0, // acc_y_minus = -8192
0x00, 0x20, // acc_z_plus = +8192
0x00, 0xE0, // acc_z_minus = -8192
0x00, 0x00, // trailing pad (descriptor declares 36 data bytes)
];
#[rustfmt::skip]
const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic)
0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013"
0x00, 0x00, 0x00, 0x00, 0x00,
0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12"
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xA0, // hw_version = 0xA000 (buf[35])
0x00, 0x00, 0x00, 0x00,
0x00, 0x01, // fw_version = 0x0100 (buf[41])
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total
];
/// Sony DualShock 4 v2 USB HID report descriptor (507 bytes) — a verbatim real-device capture
/// (CUH-ZCT2E, `054C:09CC`). Declares input `0x01` (64 B), output `0x05` (32 B), and the feature
/// reports `0x02`/`0x12`/`0xa3` so the kernel's GET_REPORTs route. The kernel binds DS4 by VID/PID,
/// but HID core still needs these reports declared.
#[rustfmt::skip]
const DS4_RDESC: &[u8] = &[
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46,
0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00,
0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0x85, 0x08, 0x09,
0x25, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x10, 0x09, 0x26, 0x95, 0x04, 0xB1,
0x02, 0x85, 0x11, 0x09, 0x27, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x12, 0x06,
0x02, 0xFF, 0x09, 0x21, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0x13, 0x09, 0x22,
0x95, 0x16, 0xB1, 0x02, 0x85, 0x14, 0x06, 0x05, 0xFF, 0x09, 0x20, 0x95,
0x10, 0xB1, 0x02, 0x85, 0x15, 0x09, 0x21, 0x95, 0x2C, 0xB1, 0x02, 0x06,
0x80, 0xFF, 0x85, 0x80, 0x09, 0x20, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x81,
0x09, 0x21, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x22, 0x95, 0x05,
0xB1, 0x02, 0x85, 0x83, 0x09, 0x23, 0x95, 0x01, 0xB1, 0x02, 0x85, 0x84,
0x09, 0x24, 0x95, 0x04, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x25, 0x95, 0x06,
0xB1, 0x02, 0x85, 0x86, 0x09, 0x26, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x87,
0x09, 0x27, 0x95, 0x23, 0xB1, 0x02, 0x85, 0x88, 0x09, 0x28, 0x95, 0x3F,
0xB1, 0x02, 0x85, 0x89, 0x09, 0x29, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x90,
0x09, 0x30, 0x95, 0x05, 0xB1, 0x02, 0x85, 0x91, 0x09, 0x31, 0x95, 0x03,
0xB1, 0x02, 0x85, 0x92, 0x09, 0x32, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x93,
0x09, 0x33, 0x95, 0x0C, 0xB1, 0x02, 0x85, 0x94, 0x09, 0x34, 0x95, 0x3F,
0xB1, 0x02, 0x85, 0xA0, 0x09, 0x40, 0x95, 0x06, 0xB1, 0x02, 0x85, 0xA1,
0x09, 0x41, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA2, 0x09, 0x42, 0x95, 0x01,
0xB1, 0x02, 0x85, 0xA3, 0x09, 0x43, 0x95, 0x30, 0xB1, 0x02, 0x85, 0xA4,
0x09, 0x44, 0x95, 0x0D, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x47, 0x95, 0x3F,
0xB1, 0x02, 0x85, 0xF1, 0x09, 0x48, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2,
0x09, 0x49, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xA7, 0x09, 0x4A, 0x95, 0x01,
0xB1, 0x02, 0x85, 0xA8, 0x09, 0x4B, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA9,
0x09, 0x4C, 0x95, 0x08, 0xB1, 0x02, 0x85, 0xAA, 0x09, 0x4E, 0x95, 0x01,
0xB1, 0x02, 0x85, 0xAB, 0x09, 0x4F, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAC,
0x09, 0x50, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAD, 0x09, 0x51, 0x95, 0x0B,
0xB1, 0x02, 0x85, 0xAE, 0x09, 0x52, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xAF,
0x09, 0x53, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB0, 0x09, 0x54, 0x95, 0x3F,
0xB1, 0x02, 0x85, 0xE0, 0x09, 0x57, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB3,
0x09, 0x55, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xB4, 0x09, 0x55, 0x95, 0x3F,
0xB1, 0x02, 0x85, 0xB5, 0x09, 0x56, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD0,
0x09, 0x58, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD4, 0x09, 0x59, 0x95, 0x3F,
0xB1, 0x02, 0xC0,
];
const DS4_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
const DS4_PRODUCT: u32 = 0x09CC; // DualShock 4 v2 (CUH-ZCT2)
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
const DS4_INPUT_REPORT_LEN: usize = 64;
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
/// than the DualSense's 1920×1080.
pub const DS4_TOUCH_W: u16 = 1920;
pub const DS4_TOUCH_H: u16 = 942;
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
fn pack_touch(dst: &mut [u8], t: &Touch) {
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
dst[1] = (x & 0xFF) as u8;
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
dst[3] = ((y >> 4) & 0xFF) as u8;
}
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without `/dev/uhid`). Field
/// offsets per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch;
/// touch[3]; rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp;
/// gyro[3] le16; accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common`
/// field at struct offset N sits at report byte N+1.
fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
r[0] = 0x01; // report id
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
r[8] = st.l2; // L2 analog (z)
r[9] = st.r2; // R2 analog (rz)
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
// r[12] temperature stays 0
for (i, v) in st.gyro.iter().enumerate() {
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
}
for (i, v) in st.accel.iter().enumerate() {
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
}
// r[25..30] reserved2.
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
r[30] = 0x10 | 0x0B;
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
r[34] = ts as u8; // touch_reports[0].timestamp
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
}
/// What one [`DualShock4Pad::service`] pass extracted from the device's HID output reports. Rumble
/// rides the universal 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player
/// LEDs or adaptive triggers, so those never appear).
#[derive(Default)]
pub struct Ds4Feedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
pub rumble: Option<(u16, u16)>,
/// Lightbar RGB, if the report carried it (deduped by the manager).
pub led: Option<(u8, u8, u8)>,
}
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
/// doesn't masquerade as a lightbar change.
fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
if data.first() != Some(&0x05) || data.len() < 11 {
return; // not the USB output report (BT 0x11 is shifted) / too short
}
let flag0 = data[1];
if flag0 & 0x01 != 0 {
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
let low = (data[5] as u16) << 8;
let high = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
}
if flag0 & 0x02 != 0 {
fb.led = Some((data[6], data[7], data[8]));
}
}
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
pub struct DualShock4Pad {
fd: File,
counter: u8,
ts: u16,
}
impl DualShock4Pad {
/// Create the UHID DualShock 4 for pad `index` (used only to make the device name/uniq unique).
pub fn open(index: u8) -> Result<DualShock4Pad> {
let fd = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(UHID_PATH)
.with_context(|| {
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
})?;
let mut ds = DualShock4Pad {
fd,
counter: 0,
ts: 0,
};
ds.send_create2(index).context("UHID_CREATE2 DualShock4")?;
Ok(ds)
}
fn send_create2(&mut self, index: u8) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
// union (uhid_create2_req) starts at byte 4.
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64]
// A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's
// duplicate-device check itself keys off the per-pad MAC in the pairing feature report).
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(DS4_RDESC.len() as u16).to_ne_bytes()); // rd_size
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
ev[264..268].copy_from_slice(&DS4_VENDOR.to_ne_bytes());
ev[268..272].copy_from_slice(&DS4_PRODUCT.to_ne_bytes());
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
ev[280..280 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
Ok(())
}
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
self.counter = self.counter.wrapping_add(1);
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.counter, self.ts);
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
ev[6..6 + r.len()].copy_from_slice(&r); // input2.data
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
Ok(())
}
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing /
/// calibration / firmware — the pairing reply is required during `hid-playstation` init, or no
/// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a
/// [`Ds4Feedback`]. Call frequently — especially right after [`open`] so the init handshake
/// completes.
pub fn service(&mut self) -> Ds4Feedback {
let mut fb = Ds4Feedback::default();
let mut ev = [0u8; UHID_EVENT_SIZE];
while let Ok(n) = self.fd.read(&mut ev) {
if n < UHID_EVENT_SIZE {
break;
}
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
UHID_OUTPUT => {
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
parse_ds4_output(&ev[4..end], &mut fb);
}
UHID_GET_REPORT => {
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
let data: &[u8] = match ev[8] {
0x12 => DS4_FEATURE_PAIRING,
0x02 => DS4_FEATURE_CALIBRATION,
0xA3 => DS4_FEATURE_FIRMWARE,
_ => &[],
};
let _ = self.reply_get_report(id, data);
}
_ => {} // Start/Stop/Open/Close/SetReport — ignore
}
}
fb
}
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12], data [12..].
ev[4..8].copy_from_slice(&id.to_ne_bytes());
let err: u16 = if data.is_empty() { 5 } else { 0 }; // EIO if we don't know the report
ev[8..10].copy_from_slice(&err.to_ne_bytes());
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
ev[12..12 + data.len()].copy_from_slice(data);
self.fd
.write_all(&ev)
.context("write UHID_GET_REPORT_REPLY")?;
Ok(())
}
}
impl Drop for DualShock4Pad {
fn drop(&mut self) {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
let _ = self.fd.write_all(&ev);
}
}
/// All virtual DualShock 4 pads of a session — the PS4 analog of
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
/// Like the DualSense it keeps each pad's full [`DsState`] and re-emits the merged report whenever
/// buttons/sticks ([`handle`](Self::handle)) or touchpad/motion ([`apply_rich`](Self::apply_rich))
/// change. [`pump`](Self::pump) services the kernel handshake and routes a game's feedback back:
/// motor rumble on the universal plane, the lightbar on the HID-output plane.
pub struct DualShock4Manager {
pads: Vec<Option<DualShock4Pad>>,
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
state: Vec<DsState>,
/// Last rumble forwarded per pad, so a report that only changes the lightbar doesn't re-send it.
last_rumble: Vec<(u16, u16)>,
/// Last lightbar RGB forwarded per pad — the kernel bundles the lightbar into every output
/// report (incl. rumble-only writes), so dedup here to avoid flooding the HID-output plane.
last_led: Vec<Option<(u8, u8, u8)>>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>,
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
broken: bool,
}
impl Default for DualShock4Manager {
fn default() -> DualShock4Manager {
DualShock4Manager::new()
}
}
impl DualShock4Manager {
pub fn new() -> DualShock4Manager {
DualShock4Manager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_led: vec![None; MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
}
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
pub fn handle(&mut self, ev: &GamepadEvent) {
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (DualShock 4)");
self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged (DualShock 4)");
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
self.last_led[i] = None;
}
}
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
self.ensure(idx);
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the
// rich-input plane and must survive a button-only frame).
let prev = self.state[idx];
let mut s = DsState::from_gamepad(
f.buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
self.state[idx] = s;
self.write(idx);
}
}
}
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
/// preserving its button/stick state. Rich events never create a pad; they're dropped if the
/// pad isn't present.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
// The DS4 touchpad carries two contacts; clamp to a valid slot and keep the
// reported contact id consistent (the wire `finger` is untrusted).
let slot = (finger as usize).min(1);
let t = &mut self.state[idx].touch[slot];
t.active = active;
t.id = slot as u8;
// Normalized 0..=65535 → the DS4 touchpad range (0..=W-1 / 0..=H-1).
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
}
RichInput::Motion { gyro, accel, .. } => {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
}
self.write(idx);
}
fn write(&mut self, idx: usize) {
let st = self.state[idx];
if let Some(pad) = self.pads[idx].as_mut() {
let _ = pad.write_state(&st);
}
self.last_write[idx] = Instant::now();
}
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` — a real DS4 streams
/// report `0x01` continuously, and `hid-playstation` / SDL treat a multi-second silence (a
/// held-steady stick) as an unplugged controller. Idempotent (a stale-but-correct frame);
/// `write_state` bumps the counter + timestamp so each is a fresh, well-formed report.
pub fn heartbeat(&mut self, max_gap: Duration) {
let now = Instant::now();
for i in 0..self.pads.len() {
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match DualShock4Pad::open(idx as u8) {
Ok(p) => {
tracing::info!(
index = idx,
"virtual DualShock 4 created (UHID hid-playstation)"
);
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_led[idx] = None;
self.last_write[idx] = Instant::now();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled");
self.broken = true;
}
}
}
/// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble`
/// is invoked `(index, low, high)` only when the motor level *changes* (universal 0xCA plane);
/// `hidout` carries the lightbar (0xCD `Led`), deduped. Call frequently — the kernel blocks
/// `hid-playstation` init until its GET_REPORTs are answered.
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
let fb = pad.service();
if let Some(r) = fb.rumble {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
if let Some(rgb) = fb.led {
if self.last_led[i] != Some(rgb) {
self.last_led[i] = Some(rgb);
hidout(HidOutput::Led {
pad: i as u8,
r: rgb.0,
g: rgb.1,
b: rgb.2,
});
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
#[test]
fn serialize_offsets() {
use punktfunk_core::input::gamepad as gs;
let mut st = DsState::from_gamepad(
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
16384, // lx (right)
0,
0,
-32768, // ry (down) — inverted to 0xFF
200, // L2
0,
);
st.gyro = [0x0102, 0x0304, 0x0506];
st.accel = [0x1112, 0x1314, 0x1516];
st.touch[0] = Touch {
active: true,
id: 0,
x: 100,
y: 200,
};
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &st, 0, 0);
assert_eq!(r[0], 0x01); // report id
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
assert_eq!(r[6] & 0x01, 0x01); // L1
// gyro le16 at 13..19, accel le16 at 19..25.
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
assert_eq!(r[33], 1); // one touch frame
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
assert_eq!(r[35] & 0x7F, 0); // contact id 0
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
}
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
#[test]
fn parse_output_rumble_and_lightbar() {
let mut report = [0u8; 32];
report[0] = 0x05;
report[1] = 0x01 | 0x02; // MOTOR | LED
report[4] = 0x40; // motor_right (weak/high)
report[5] = 0x80; // motor_left (strong/low)
report[6] = 0x11; // R
report[7] = 0x22; // G
report[8] = 0x33; // B
let mut fb = Ds4Feedback::default();
parse_ds4_output(&report, &mut fb);
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
let mut motor_only = [0u8; 32];
motor_only[0] = 0x05;
motor_only[1] = 0x01; // MOTOR only
motor_only[5] = 0x10;
let mut fb2 = Ds4Feedback::default();
parse_ds4_output(&motor_only, &mut fb2);
assert!(fb2.rumble.is_some());
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
}
/// Feature-report arrays carry the right report id + length the kernel expects.
#[test]
fn feature_report_shapes() {
assert_eq!(DS4_FEATURE_PAIRING.len(), 16);
assert_eq!(DS4_FEATURE_PAIRING[0], 0x12);
assert_eq!(DS4_FEATURE_CALIBRATION.len(), 37);
assert_eq!(DS4_FEATURE_CALIBRATION[0], 0x02);
assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49);
assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3);
}
}
+68 -7
View File
@@ -82,6 +82,53 @@ const BUTTON_MAP: [(u32, u16); 11] = [
(gamepad::BTN_RS_CLK, BTN_THUMBR), (gamepad::BTN_RS_CLK, BTN_THUMBR),
]; ];
/// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off
/// `bustype/vendor/product/version` (+ name), and games pick button glyphs from it. The button/axis
/// layout this backend emits is the same XInput one regardless — only the identity differs between an
/// X-Box 360 pad and an X-Box One/Series pad (which is why "Xbox One" buys glyphs, not new capability;
/// impulse-trigger rumble is unreachable through evdev FF either way).
#[derive(Clone, Copy)]
pub struct PadIdentity {
vendor: u16,
product: u16,
version: u16,
name: &'static [u8],
/// Short label for the creation log line.
log: &'static str,
}
impl PadIdentity {
/// "Microsoft X-Box 360 pad" (`045e:028e`) — the universal default; matches the kernel `xpad`
/// table verbatim so SDL/Steam map it with zero config.
pub const fn xbox360() -> PadIdentity {
PadIdentity {
vendor: 0x045e,
product: 0x028e,
version: 0x0110,
name: b"Microsoft X-Box 360 pad",
log: "X-Box 360 pad",
}
}
/// "Microsoft X-Box One S pad" (`045e:02ea`) — an `xpad`-table entry, so games show One/Series
/// glyphs. XInput-identical to the 360 pad otherwise.
pub const fn xbox_one() -> PadIdentity {
PadIdentity {
vendor: 0x045e,
product: 0x02ea,
version: 0x0408,
name: b"Microsoft X-Box One S pad",
log: "X-Box One S pad",
}
}
}
impl Default for PadIdentity {
fn default() -> PadIdentity {
PadIdentity::xbox360()
}
}
#[repr(C)] #[repr(C)]
struct InputId { struct InputId {
bustype: u16, bustype: u16,
@@ -202,7 +249,7 @@ pub struct VirtualPad {
} }
impl VirtualPad { impl VirtualPad {
pub fn create(index: usize) -> Result<VirtualPad> { pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
use std::os::fd::FromRawFd; use std::os::fd::FromRawFd;
let raw = unsafe { let raw = unsafe {
libc::open( libc::open(
@@ -272,18 +319,22 @@ impl VirtualPad {
let mut setup = UinputSetup { let mut setup = UinputSetup {
id: InputId { id: InputId {
bustype: 0x0003, // BUS_USB bustype: 0x0003, // BUS_USB
vendor: 0x045e, vendor: identity.vendor,
product: 0x028e, product: identity.product,
version: 0x0110, version: identity.version,
}, },
name: [0; 80], name: [0; 80],
ff_effects_max: 16, // must be > 0 or FF uploads are never delivered ff_effects_max: 16, // must be > 0 or FF uploads are never delivered
}; };
let name = b"Microsoft X-Box 360 pad"; let name = identity.name;
setup.name[..name.len()].copy_from_slice(name); setup.name[..name.len()].copy_from_slice(name);
ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?; ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?;
ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?; ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?;
tracing::info!(index, "virtual gamepad created (X-Box 360 pad via uinput)"); tracing::info!(
index,
pad = identity.log,
"virtual gamepad created (uinput)"
);
Ok(VirtualPad { Ok(VirtualPad {
fd, fd,
@@ -449,14 +500,24 @@ impl Drop for VirtualPad {
#[derive(Default)] #[derive(Default)]
pub struct GamepadManager { pub struct GamepadManager {
pads: Vec<Option<VirtualPad>>, pads: Vec<Option<VirtualPad>>,
/// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when
/// the client asked for `XboxOne`). All pads in a session share one identity.
identity: PadIdentity,
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events. /// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events.
broken: bool, broken: bool,
} }
impl GamepadManager { impl GamepadManager {
/// A manager that creates X-Box 360 pads (the universal default).
pub fn new() -> GamepadManager { pub fn new() -> GamepadManager {
GamepadManager::with_identity(PadIdentity::xbox360())
}
/// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]).
pub fn with_identity(identity: PadIdentity) -> GamepadManager {
GamepadManager { GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(), pads: (0..MAX_PADS).map(|_| None).collect(),
identity,
broken: false, broken: false,
} }
} }
@@ -496,7 +557,7 @@ impl GamepadManager {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return; return;
} }
match VirtualPad::create(idx) { match VirtualPad::create(idx, self.identity) {
Ok(p) => self.pads[idx] = Some(p), Ok(p) => self.pads[idx] = Some(p),
Err(e) => { Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled"); tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
+70 -22
View File
@@ -1164,26 +1164,50 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
tracing::debug!("mic service stopped (host shutting down)"); tracing::debug!("mic service stopped (host shutting down)");
} }
/// The session's virtual-gamepad backend. Default = uinput X-Box-360 pads /// The session's virtual-gamepad backend, resolved once per session (sessions run serially).
/// ([`GamepadManager`](crate::inject::gamepad::GamepadManager)); `PUNKTFUNK_GAMEPAD=dualsense` ///
/// switches to virtual DualSense pads (UHID + the kernel `hid-playstation` driver) so a game sees /// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)),
/// a *real* DualSense — adaptive triggers, lightbar, touchpad, motion — and a game's feedback /// ViGEm on Windows. Also the X-Box One/Series identity (`PUNKTFUNK_GAMEPAD=xboxone`): the same
/// flows back over the rich HID-output plane. Selected once per session (sessions run serially). /// backend with the One/Series USB VID/PID so games show One/Series glyphs (XInput-identical
/// otherwise). The Linux pad carries it as a [`PadIdentity`](crate::inject::gamepad::PadIdentity).
/// - `DualSense` (`PUNKTFUNK_GAMEPAD=dualsense`) — virtual DualSense via UHID + `hid-playstation`,
/// so a game sees a *real* DualSense (adaptive triggers, lightbar, touchpad, motion); feedback
/// flows back over the rich HID-output plane.
/// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar,
/// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute).
///
/// The two UHID pads are Linux-only; off Linux the resolver already folds them (and One/Series)
/// into `Xbox360`, so a non-Linux build never constructs them.
enum PadBackend { enum PadBackend {
Xbox360(crate::inject::gamepad::GamepadManager), Xbox360(crate::inject::gamepad::GamepadManager),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
DualSense(crate::inject::dualsense::DualSenseManager), DualSense(crate::inject::dualsense::DualSenseManager),
#[cfg(target_os = "linux")]
DualShock4(crate::inject::dualshock4::DualShock4Manager),
} }
impl PadBackend { impl PadBackend {
/// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference, /// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference,
/// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only /// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only ever
/// ever construct the X-Box backend, whatever the resolution said. /// construct the X-Box backend, whatever the resolution said.
fn select(kind: GamepadPref) -> PadBackend { fn select(kind: GamepadPref) -> PadBackend {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if kind == GamepadPref::DualSense { match kind {
tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)"); GamepadPref::DualSense => {
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new()); tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)");
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new());
}
GamepadPref::DualShock4 => {
tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)");
return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new());
}
GamepadPref::XboxOne => {
tracing::info!("gamepad backend: uinput X-Box One/Series pad");
return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity(
crate::inject::gamepad::PadIdentity::xbox_one(),
));
}
_ => {}
} }
let _ = kind; let _ = kind;
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new()) PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new())
@@ -1194,21 +1218,26 @@ impl PadBackend {
PadBackend::Xbox360(m) => m.handle(ev), PadBackend::Xbox360(m) => m.handle(ev),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.handle(ev), PadBackend::DualSense(m) => m.handle(ev),
#[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.handle(ev),
} }
} }
/// Apply a rich client→host event (DualSense touchpad / motion). A no-op for the X-Box pad, /// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no
/// which has no equivalent. /// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors.
fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) { fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if let PadBackend::DualSense(m) = self { match self {
m.apply_rich(_rich); PadBackend::DualSense(m) => m.apply_rich(_rich),
PadBackend::DualShock4(m) => m.apply_rich(_rich),
PadBackend::Xbox360(_) => {}
} }
} }
/// Service feedback every cycle. `rumble` carries motor force-feedback on the universal plane /// Service feedback every cycle. `rumble` carries motor force-feedback on the universal plane
/// (both backends); `hidout` carries DualSense-only rich feedback (lightbar / player LEDs / /// (every backend); `hidout` carries rich feedback on the HID-output plane — lightbar (both
/// adaptive triggers DualSense backend only). /// UHID pads), plus player LEDs / adaptive triggers (DualSense only). The X-Box pad has no
/// rich-feedback plane.
fn pump( fn pump(
&mut self, &mut self,
rumble: impl FnMut(u16, u16, u16), rumble: impl FnMut(u16, u16, u16),
@@ -1221,10 +1250,12 @@ impl PadBackend {
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.pump(rumble, hidout), PadBackend::DualSense(m) => m.pump(rumble, hidout),
#[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.pump(rumble, hidout),
} }
} }
/// Keep a virtual DualSense alive during input silence: re-emit its current HID report if it's /// Keep a virtual UHID pad alive during input silence: re-emit its current HID report if it's
/// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as /// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as
/// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev /// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev
/// holds last-known state with no periodic-report requirement). Called every input-thread tick; /// holds last-known state with no periodic-report requirement). Called every input-thread tick;
@@ -1234,6 +1265,8 @@ impl PadBackend {
PadBackend::Xbox360(_) => {} PadBackend::Xbox360(_) => {}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)),
} }
} }
} }
@@ -1516,10 +1549,13 @@ fn synthetic_stream(
} }
/// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins, /// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins,
/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360. The /// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360.
/// DualSense backend needs Linux UHID — when unavailable any DualSense wish degrades to ///
/// X-Box 360 (never an error: a session without rich pads still streams). /// `linux` is whether this is a Linux host (uinput + UHID). The rich UHID pads (DualSense, DualShock
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, dualsense_available: bool) -> GamepadPref { /// 4) need it — off Linux any such wish degrades to X-Box 360 (never an error: a session without rich
/// pads still streams). X-Box One/Series is a distinct uinput *identity* on Linux, but XInput-identical
/// to the 360 pad on Windows (ViGEm has no One target), so it degrades to `Xbox360` there.
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPref {
let want = match pref { let want = match pref {
GamepadPref::Auto => env GamepadPref::Auto => env
.and_then(GamepadPref::from_name) .and_then(GamepadPref::from_name)
@@ -1527,7 +1563,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, dualsense_available: bool)
explicit => explicit, explicit => explicit,
}; };
match want { match want {
GamepadPref::DualSense if dualsense_available => GamepadPref::DualSense, GamepadPref::DualSense if linux => GamepadPref::DualSense,
GamepadPref::DualShock4 if linux => GamepadPref::DualShock4,
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
// Windows (XInput can't tell them apart anyway).
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
_ => GamepadPref::Xbox360, _ => GamepadPref::Xbox360,
} }
} }
@@ -3012,6 +3052,14 @@ mod tests {
// DualSense degrades to X-Box 360 where the backend doesn't exist (non-Linux). // DualSense degrades to X-Box 360 where the backend doesn't exist (non-Linux).
assert_eq!(pick_gamepad(DualSense, None, false), Xbox360); assert_eq!(pick_gamepad(DualSense, None, false), Xbox360);
assert_eq!(pick_gamepad(Auto, Some("dualsense"), false), Xbox360); assert_eq!(pick_gamepad(Auto, Some("dualsense"), false), Xbox360);
// DualShock 4: honored on Linux (UHID), degrades to X-Box 360 off it.
assert_eq!(pick_gamepad(DualShock4, None, true), DualShock4);
assert_eq!(pick_gamepad(Auto, Some("ps4"), true), DualShock4);
assert_eq!(pick_gamepad(DualShock4, None, false), Xbox360);
// X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows.
assert_eq!(pick_gamepad(XboxOne, None, true), XboxOne);
assert_eq!(pick_gamepad(Auto, Some("series"), true), XboxOne);
assert_eq!(pick_gamepad(XboxOne, None, false), Xbox360);
} }
#[test] #[test]
+100
View File
@@ -0,0 +1,100 @@
---
title: Release Channels
description: How punktfunk ships — the canary (every main push) and stable (vX.Y.Z) tracks, how to subscribe to each, and how to cut a release.
---
punktfunk ships on **two tracks**. Every push to `main` publishes a **canary** build to the
canary channels (fast iteration, possibly broken). A `vX.Y.Z` git tag cuts a **stable** release:
every platform is built at that one version, published to the stable channels, and all the
artifacts (`.deb`, `.rpm`, `.msix`, host installer, `.apk`/`.aab`, `.dmg`, flatpak, Decky zip)
are attached to a single [Gitea Release](https://git.unom.io/unom/punktfunk/releases).
The two tracks are **separate repos / tracks per platform**, never a shared version line — so a
stable box never gets pulled onto a canary build, and a canary box always moves forward. Pick the
track per machine; switching is a one-line change.
## Which track should I be on?
- **Canary** — dev boxes, your own test fleet, "I want the latest main build." Updates land minutes
after a merge.
- **Stable** — anything you don't want to babysit. Only moves when a `vX.Y.Z` tag is cut.
## Subscribe — per platform
| Platform | Canary | Stable |
|---|---|---|
| **apt** (host/client) | `deb [signed-by=…] https://git.unom.io/api/packages/unom/debian canary main` | `… debian stable main` |
| **rpm** (host) | baseurl `…/rpm/bazzite-canary` (or `fedora-44-canary`) | `…/rpm/bazzite` (or `fedora-44`) |
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
| **Android** | Play **Internal testing** + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | Play **closed (alpha)** track + the release page |
| **Apple** (mac/iOS/tvOS) | **TestFlight** | TestFlight + a notarized `.dmg` on the release page |
The apt distribution and the rpm group are just path segments in the URL — switching tracks is a
one-line edit of `/etc/apt/sources.list.d/punktfunk.list` (`stable``canary`) or
`/etc/yum.repos.d/punktfunk.repo` (`…/rpm/bazzite``…/rpm/bazzite-canary`), then
`apt update` / `rpm-ostree upgrade`.
> The OS-package channels (apt/rpm) are how Linux hosts get canary builds — they are **not**
> attached to a canary release page. The Gitea Releases page is stable-only.
## Cut a stable release (maintainer)
1. Make sure `main` is green.
2. (Optional) bump any user-facing version that isn't derived from the tag — the Android
`versionName` fallback (`clients/android/app/build.gradle.kts`) and the Decky `plugin.json`
`version` are cosmetic self-reported strings; everything else (binaries via
`PUNKTFUNK_BUILD_VERSION`, MSIX, apt/rpm, the `.dmg`) derives from the tag automatically.
3. Tag and push — **one** tag releases every platform:
```sh
git tag v0.2.0
git push origin v0.2.0
```
4. Every platform workflow fans out, builds at `0.2.0`, publishes to its **stable** channel, and
attaches its artifact to the `v0.2.0` Gitea Release. Concurrent attaches are safe — the shared
`scripts/ci/gitea-release.{sh,ps1}` helper creates the release once and the rest reuse it.
5. **Promote the app stores manually** (CI only uploads to testing tracks — see below).
6. After a release reaches the current canary base, bump the canary base one minor ahead in
`deb.yml` / `rpm.yml` (and the `0.3.<run>` strings in the other workflows) so a stable→canary
re-point still moves forward. Rule: **canary base = one minor ahead of the latest stable.**
Pre-release tags work too: `v0.2.0-rc1` builds a real release (the `-rc1` suffix is dropped where a
strictly-numeric version is required — MSIX, the App Store marketing version).
### App-store promotion (manual, after the tag)
CI uploads stable to **testing** tracks only — it never auto-publishes to the public stores:
- **Apple** — the build lands in **TestFlight**. Promote to the App Store from App Store Connect
(submit for review). The notarized `.dmg` on the release page is the direct-download path.
- **Android** — the build lands in Play's **closed (alpha)** track. Promote alpha → production in
the Play Console when ready.
## Why two tracks (the version-shadow trap)
apt/rpm/registries serve the **highest** version to every subscriber. If a stable release landed in
the same channel as rolling main builds, every box would jump to it and get **stuck** — the rolling
`0.3.0~ciN` build never climbs above a `0.3.0` release. Separate canary/stable channels remove the
trap by construction, which is why a single `vX.Y.Z` tag can safely release the whole project at
once (the old `host-v*` / `win-v*` / `host-win-v*` tag namespaces are retired — `v*` is the only
release tag now).
## Migrating an existing box to canary
Boxes added before this split point at the current stable channels, which now only move on releases.
Point your dev fleet at **canary**:
```sh
# apt
sudo sed -i 's/ stable main/ canary main/' /etc/apt/sources.list.d/punktfunk.list
sudo apt update && sudo apt upgrade
# rpm-ostree (Bazzite / Fedora)
sudo sed -i 's#/rpm/bazzite#/rpm/bazzite-canary#' /etc/yum.repos.d/punktfunk.repo # or fedora-44 → fedora-44-canary
rpm-ostree upgrade
# Flatpak (Steam Deck client)
flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref
```
+4
View File
@@ -7,6 +7,10 @@ This page is the **install path for each client device**. For what each client *
pick, see [Clients](/docs/clients); to install the **host**, see [Install the Host](/docs/install). pick, see [Clients](/docs/clients); to install the **host**, see [Install the Host](/docs/install).
Whichever client you install, the first connection needs a one-time [pairing](/docs/pairing). Whichever client you install, the first connection needs a one-time [pairing](/docs/pairing).
> The links below are the **stable** channel (moves on `vX.Y.Z` releases). For the latest `main`
> build, use the **canary** channel — TestFlight / Play Internal, the `…Canary.flatpakref`, or the
> `canary/` download URLs. See [Release Channels](/docs/channels).
## Pick your device ## Pick your device
| Device | Install | | Device | Install |
+5
View File
@@ -21,6 +21,11 @@ Each registry is public — no auth, you just trust the repo's signing key. Addi
one-time step covered in the linked guide; after that, normal `apt upgrade` / `rpm-ostree upgrade` one-time step covered in the linked guide; after that, normal `apt upgrade` / `rpm-ostree upgrade`
tracks new builds automatically. tracks new builds automatically.
> **Stable vs canary.** The repos in the per-distro guides are the **stable** channel — it only
> moves when a `vX.Y.Z` release is cut. For the latest `main` build (fast, possibly broken), point
> at the **canary** channel instead (`canary` apt distribution / `*-canary` rpm group). See
> [Release Channels](/docs/channels).
## Windows (NVIDIA) ## Windows (NVIDIA)
punktfunk also runs as a native host on **Windows 10/11 (x64) with an NVIDIA GPU**, shipped as a punktfunk also runs as a native host on **Windows 10/11 (x64) with an NVIDIA GPU**, shipped as a
+1
View File
@@ -25,6 +25,7 @@
"troubleshooting", "troubleshooting",
"---Project---", "---Project---",
"roadmap", "roadmap",
"channels",
"---Reference---", "---Reference---",
"[API Reference](/api)" "[API Reference](/api)"
] ]
+21 -5
View File
@@ -7,15 +7,31 @@ CI runs on **Gitea Actions** (`git.unom.io`, org `unom`). The workflows live in
`.gitea/workflows/`; they run across Linux and macOS runners and push a few images to the `.gitea/workflows/`; they run across Linux and macOS runners and push a few images to the
Gitea container registry. Gitea container registry.
## Release model
Two tracks (full guide: [Release Channels](https://punktfunk.unom.io/docs/channels)). A push to
`main` publishes **canary** builds to the canary channels; a single **`vX.Y.Z` tag** is THE release
for every platform — built at that version, published to the **stable** channels, and every artifact
attached to one Gitea Release via the shared `scripts/ci/gitea-release.{sh,ps1}` helper (idempotent
create-or-fetch + delete-before-upload, so concurrent cross-runner attaches don't collide). The old
`host-v*` / `win-v*` / `host-win-v*` tag namespaces are retired — `v*` is the only release tag.
## Workflows ## Workflows
| Workflow | Trigger | Runner | What it does | | Workflow | Trigger | Runner | What it does |
|---|---|---|---| |---|---|---|---|
| `ci.yml` | push to `main`, PRs | Linux | Rust workspace (fmt · clippy `-D warnings` · build · test · C-ABI harness · generated-header drift) inside the `punktfunk-rust-ci` image; `web/` and `docs-site/` build + typecheck in `oven/bun:1` | | `ci.yml` | push `main`, PRs | Linux | Rust workspace (fmt · clippy `-D warnings` · build · test · C-ABI harness · header drift) in `punktfunk-rust-ci`; `web/` + `docs-site/` build + typecheck in `oven/bun:1` |
| `docker.yml` | push to `main`, `v*` tags, manual | Linux | Builds + pushes the images below (`latest` + `sha-<short>` tags) | | `apple.yml` | push `main`, PRs, manual | macOS | Rust core → `PunktfunkCore.xcframework``swift build`/`swift test` (CI gate, no publish) |
| `apple.yml` | push to `main`, PRs, manual | macOS | Rust core → `PunktfunkCore.xcframework``swift build` + `swift test` in `clients/apple` | | `windows.yml` | push `main` (paths), PRs, manual | Windows | client build · clippy · fmt · test for `x86_64`/`aarch64` (CI gate, no publish) |
| `release.yml` | `v*` tags, manual | macOS | Production Apple builds: sandboxed macOS `.dmg` (Developer ID, notarized, stapled) attached to the Gitea release + macOS/iOS/tvOS archives uploaded to TestFlight | | `deb.yml` | push `main` → canary, `v*` → stable, manual | Linux | host/client/web `.deb` → apt (`canary`/`stable` distribution); `v*` attaches to the release |
| `windows-msix.yml` | push to `main`, `v*` tags, manual | Windows | Builds the Windows client for `x86_64`/`aarch64` and packages signed MSIX artifacts | | `rpm.yml` | push `main` → canary, `v*` → stable, manual | Linux | host `.rpm` (bazzite + fedora-44 bases) → rpm (`*-canary`/base groups); `v*` attaches |
| `windows-msix.yml` | push `main` (paths) → canary, `v*` → stable, manual | Windows | client MSIX `x64`+`arm64` → generic registry (`canary/`/`latest/`); `v*` attaches |
| `windows-host.yml` | push `main` (paths) → canary, `v*` → stable, manual | Windows | host Inno installer → generic registry (`canary/`/`latest/`); `v*` attaches |
| `android.yml` | push `main` → Play internal, `v*` → Play alpha, PRs, manual | Linux | signed AAB+APK → Play + generic registry; `v*` attaches |
| `release.yml` | push `main` (paths) → TestFlight, `v*` → DMG + TestFlight, manual | macOS | Apple mac/iOS(/tvOS on stable); `v*` notarized `.dmg` attaches |
| `flatpak.yml` | push `main` (paths) → canary branch, `v*` → stable, manual | Linux | client flatpak (OSTree repo + bundle, branch per channel); `v*` attaches |
| `decky.yml` | push `main` → canary, `v*` → stable, manual | Linux | Decky plugin zip → generic registry (`canary/`/`latest/`); `v*` attaches |
| `docker.yml` | push `main`, `v*`, manual | Linux | web/docs/CI images (`latest` + `sha-<short>`; `v*` adds a `vX.Y.Z` tag) |
## Dockerized pieces ## Dockerized pieces
+12
View File
@@ -70,6 +70,18 @@
// only where available (Linux hosts); otherwise the host falls back to X-Box 360. // only where available (Linux hosts); otherwise the host falls back to X-Box 360.
#define PUNKTFUNK_GAMEPAD_DUALSENSE 2 #define PUNKTFUNK_GAMEPAD_DUALSENSE 2
// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a
// physical X-Box One/Series controller on the client.
#define PUNKTFUNK_GAMEPAD_XBOXONE 3
// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
// hosts); otherwise the host falls back to X-Box 360.
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. // Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to // Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`. // [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
+5 -3
View File
@@ -2,9 +2,11 @@
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public `punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds `unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
and publishes on every push to `main` (a rolling `0.2.0~ciN.g<sha>` build) and on `host-v*` tags and publishes on every push to `main` (a rolling `0.3.0~ciN.g<sha>` build to the **`canary`** apt
(a clean `X.Y.Z`) — the rolling builds outrank the stray `0.1.1`, so plain `apt upgrade` always distribution) and on `vX.Y.Z` tags (a clean `X.Y.Z` to the **`stable`** distribution, plus attached
gets the latest (no version pin needed). to the unified Gitea Release). The two are separate apt distributions, so a stable box never jumps
to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/channels). The repo line
below subscribes to `stable`; swap `stable``canary` for the latest main builds.
The same workflow also publishes **`punktfunk-web`** (the browser management console — pairing + The same workflow also publishes **`punktfunk-web`** (the browser management console — pairing +
status) and **`punktfunk-client`** (the GTK4 couch/Deck client). `punktfunk-host` **Recommends** status) and **`punktfunk-client`** (the GTK4 couch/Deck client). `punktfunk-host` **Recommends**
+7 -5
View File
@@ -1,11 +1,13 @@
# punktfunk-host — RPM (Bazzite / Fedora Atomic) via the Gitea registry # punktfunk-host — RPM (Bazzite / Fedora Atomic) via the Gitea registry
`punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom` `punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom`
org (group `bazzite`), so Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. org (stable groups `bazzite`/`fedora-44`, canary groups `bazzite-canary`/`fedora-44-canary`), so
CI (`.gitea/workflows/rpm.yml`) builds and publishes on every push to `main` (a rolling Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. CI (`.gitea/workflows/rpm.yml`)
`0.2.0-0.ciN.<sha>` build, which outranks the stray `0.1.1` so `rpm-ostree upgrade` always gets the builds and publishes on every push to `main` (a rolling `0.3.0-0.ciN.<sha>` build to the `*-canary`
latest — no version pin needed) and on **host-scoped** `host-v*` tags (a clean `X.Y.Z-1`; the Apple groups) and on `vX.Y.Z` tags (a clean `X.Y.Z-1` to the base groups, plus attached to the unified
client's `v*` tags deliberately do **not** publish a host RPM). The RPM is built in the Gitea Release) — separate repos, so a stable box never jumps to a canary build (see
[Release Channels](https://punktfunk.unom.io/docs/channels)). The `baseurl` below subscribes to the
`bazzite` stable group; use `bazzite-canary` for the latest main builds. The RPM is built in the
Fedora 43 image (`ci/fedora-rpm.Dockerfile`) so its auto-generated library Requires Fedora 43 image (`ci/fedora-rpm.Dockerfile`) so its auto-generated library Requires
(`libavcodec.so.NN`, …) match Bazzite's sonames; the NVIDIA driver lib (`libcuda.so.1`) is (`libavcodec.so.NN`, …) match Bazzite's sonames; the NVIDIA driver lib (`libcuda.so.1`) is
excluded — NVENC/EGL come from whatever NVIDIA stack the host runs (a weak Recommends). excluded — NVENC/EGL come from whatever NVIDIA stack the host runs (a weak Recommends).
+1 -1
View File
@@ -9,7 +9,7 @@
# Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs) # Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs)
set -euo pipefail set -euo pipefail
PF_VERSION="${PF_VERSION:-0.2.0}" PF_VERSION="${PF_VERSION:-0.3.0}" # canary base; keep one minor ahead of the latest stable release
PF_RELEASE="${PF_RELEASE:-1}" PF_RELEASE="${PF_RELEASE:-1}"
# PF_WITH_WEB=1 builds the punktfunk-web subpackage too (needs `bun` on PATH — present in the CI # PF_WITH_WEB=1 builds the punktfunk-web subpackage too (needs `bun` on PATH — present in the CI
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works. # builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
+6 -6
View File
@@ -17,12 +17,12 @@
################################################################################ ################################################################################
Name: punktfunk Name: punktfunk
# Version/Release are overridable so CI can stamp a rolling snapshot: a main build passes # Version/Release are overridable so CI can stamp a rolling snapshot: a canary main build passes
# --define "pf_version 0.2.0" --define "pf_release 0.ci42.gdeadbee" # --define "pf_version 0.3.0" --define "pf_release 0.ci42.gdeadbee"
# (Release starting "0." sorts BEFORE the eventual "1" release; base 0.2.0 sits ABOVE the stray # (Release starting "0." sorts BEFORE the eventual "1" release; the canary base stays one minor
# 0.1.1), a host-v* tag passes the clean version with "pf_release 1". A plain `rpmbuild` (or COPR) # ahead of the latest stable), a vX.Y.Z release tag passes the clean version with "pf_release 1".
# with no defines builds 0.2.0-1. # A plain `rpmbuild` (or COPR) with no defines builds 0.3.0-1.
Version: %{?pf_version}%{!?pf_version:0.2.0} Version: %{?pf_version}%{!?pf_version:0.3.0}
Release: %{?pf_release}%{!?pf_release:1}%{?dist} Release: %{?pf_release}%{!?pf_release:1}%{?dist}
Summary: Low-latency desktop/game streaming host (Moonlight-compatible + punktfunk/1) Summary: Low-latency desktop/game streaming host (Moonlight-compatible + punktfunk/1)
+5 -3
View File
@@ -83,6 +83,8 @@ pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetD
## Release ## Release
Push a `host-win-vX.Y.Z` tag — the workflow builds, signs, and publishes Push a `vX.Y.Z` tag — one tag releases every platform (see
`punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, and refreshes the `latest/` alias. Main pushes [Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and
publish rolling `0.2.<run>` builds (no `latest/` update). publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/`
alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling
`0.3.<run>` **canary** builds to the `canary/` alias.
+77
View File
@@ -0,0 +1,77 @@
# Shared Gitea Release helpers for the punktfunk Windows CI workflows (pwsh / PowerShell 7).
#
# Dot-source it, then call Ensure-GiteaRelease / Upsert-GiteaAsset:
# . scripts/ci/gitea-release.ps1
# Mirrors scripts/ci/gitea-release.sh; parses JSON with ConvertFrom-Json (the Windows runner
# has no python). Same idempotent semantics: Upsert-GiteaAsset deletes an existing asset of
# the same name before uploading, so re-runs / rolling canary uploads don't 409.
#
# Env (Gitea Actions sets the first two automatically):
# GITHUB_SERVER_URL e.g. https://git.unom.io
# GITHUB_REPOSITORY e.g. unom/punktfunk
# GITEA_TOKEN a PAT with repository (release) write scope — set from secrets.REGISTRY_TOKEN
# (must carry write:repository, not only write:package)
$ErrorActionPreference = 'Stop'
function _GiteaApi {
if (-not $env:GITHUB_SERVER_URL) { throw 'GITHUB_SERVER_URL unset' }
if (-not $env:GITHUB_REPOSITORY) { throw 'GITHUB_REPOSITORY unset' }
"$($env:GITHUB_SERVER_URL)/api/v1/repos/$($env:GITHUB_REPOSITORY)"
}
function _GiteaHeaders {
if (-not $env:GITEA_TOKEN) { throw 'GITEA_TOKEN unset' }
@{ Authorization = "token $($env:GITEA_TOKEN)" }
}
# Ensure-GiteaRelease TAG NAME PRERELEASE [TARGETCOMMITISH] -> release id
# Idempotently create or fetch the release for TAG. Prerelease is 'true', 'false', or 'auto'
# (auto marks it a prerelease iff TAG carries a `-` pre-release suffix, e.g. v0.2.0-rc1, so an
# rc never becomes "Latest"). TargetCommitish (optional) creates the tag if missing.
function Ensure-GiteaRelease {
param(
[Parameter(Mandatory)] [string]$Tag,
[Parameter(Mandatory)] [string]$Name,
[Parameter(Mandatory)] [string]$Prerelease,
[string]$TargetCommitish
)
$pre = if ($Prerelease -eq 'auto') { [bool]($Tag -match '-') } else { [System.Convert]::ToBoolean($Prerelease) }
$api = _GiteaApi; $h = _GiteaHeaders
$payload = @{ tag_name = $Tag; name = $Name; prerelease = $pre }
if ($TargetCommitish) { $payload.target_commitish = $TargetCommitish }
try {
$r = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $h `
-ContentType 'application/json' -Body ($payload | ConvertTo-Json -Compress)
return $r.id
} catch {
# Almost always: the release already exists. Fetch it by tag; error if that fails too.
$r = Invoke-RestMethod -Method Get -Uri "$api/releases/tags/$Tag" -Headers $h
if (-not $r.id) { throw "gitea-release: could not create or find a release for tag '$Tag'" }
return $r.id
}
}
# Upsert-GiteaAsset RELEASEID FILE [NAME]
# Attach FILE, replacing any existing asset of the same name first (idempotent).
function Upsert-GiteaAsset {
param(
[Parameter(Mandatory)] [string]$ReleaseId,
[Parameter(Mandatory)] [string]$File,
[string]$Name
)
if (-not (Test-Path $File)) { throw "gitea-release: asset file not found: $File" }
if (-not $Name) { $Name = Split-Path $File -Leaf }
$api = _GiteaApi; $h = _GiteaHeaders
$assets = Invoke-RestMethod -Method Get -Uri "$api/releases/$ReleaseId/assets" -Headers $h
$existing = $assets | Where-Object { $_.name -eq $Name } | Select-Object -First 1
if ($existing) {
Invoke-RestMethod -Method Delete -Uri "$api/releases/$ReleaseId/assets/$($existing.id)" -Headers $h | Out-Null
}
$enc = [uri]::EscapeDataString($Name)
# curl.exe for the multipart upload — matches the rest of the windows workflows.
curl.exe -fsS -H "Authorization: token $($env:GITEA_TOKEN)" -o NUL `
-X POST "$api/releases/$ReleaseId/assets?name=$enc" -F "attachment=@$File"
if ($LASTEXITCODE -ne 0) { throw "gitea-release: asset upload failed ($LASTEXITCODE): $Name" }
Write-Output "gitea-release: uploaded '$Name' -> release $ReleaseId"
}
+95
View File
@@ -0,0 +1,95 @@
# shellcheck shell=bash
# Shared Gitea Release helpers for the punktfunk CI workflows (Linux + macOS runners).
#
# Source this file, then call ensure_release / upsert_asset. It replaces the three
# copy-pasted inline blocks that used to live in release.yml / flatpak.yml / decky.yml,
# and fixes a latent bug those had: the bare asset POST returns 409 if an asset with the
# same name already exists, so re-running a workflow — or reusing the rolling `canary`
# release with stable filenames — would fail. upsert_asset deletes the old asset first.
#
# Callers run under Gitea Actions' default `bash -eo pipefail`, so a non-zero return from
# these functions aborts the step (the desired behaviour on a real failure).
#
# Env (Gitea Actions sets the first two automatically in every step):
# GITHUB_SERVER_URL e.g. https://git.unom.io
# GITHUB_REPOSITORY e.g. unom/punktfunk
# GITEA_TOKEN a PAT with repository (release) write scope — set from secrets.REGISTRY_TOKEN
# (the same PAT the package uploads use; it must carry `write:repository`,
# not only `write:package`, or the release-asset POST 403s)
#
# Requires: curl + python3 (python3 is already a proven dependency on every runner that
# attaches releases today — macOS, the fedora flatpak container, the node:bookworm decky
# image; the .deb runner installs it alongside its other apt deps).
_gitea_api() { printf '%s/api/v1/repos/%s' "${GITHUB_SERVER_URL:?}" "${GITHUB_REPOSITORY:?}"; }
# Tiny JSON / URL helpers. python3 reads the TOP-LEVEL "id" only, so there is no ambiguity
# with the nested author.id / assets[].id fields a string-grep would trip over.
_json_id() { python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null; }
_json_asset_id() {
python3 -c 'import json,sys
want=sys.argv[1]
for a in json.load(sys.stdin):
if a.get("name")==want:
print(a.get("id",""));break' "$1" 2>/dev/null
}
_urlencode() { python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"; }
# ensure_release TAG NAME PRERELEASE [TARGET_COMMITISH]
# Idempotently create (or fetch) the release for TAG; prints its numeric id on stdout.
# PRERELEASE is "true", "false", or "auto" — auto marks it a prerelease iff TAG carries a
# `-` pre-release suffix (e.g. v0.2.0-rc1), so an rc never becomes the repo's "Latest" release
# (Gitea's /releases/latest surfaces the newest non-prerelease). TARGET_COMMITISH (optional)
# creates the git tag if it does not exist yet — for a `vX.Y.Z` release the tag already exists
# (it is the trigger), so TARGET is omitted and create-vs-fetch hinges on the release object.
ensure_release() {
local tag="${1:?tag}" name="${2:?name}" prerelease="${3:?prerelease}" target="${4:-}"
local api body id
if [ "$prerelease" = auto ]; then
case "$tag" in *-*) prerelease=true ;; *) prerelease=false ;; esac
fi
api="$(_gitea_api)"
if [ -n "$target" ]; then
body=$(printf '{"tag_name":"%s","name":"%s","prerelease":%s,"target_commitish":"%s"}' \
"$tag" "$name" "$prerelease" "$target")
else
body=$(printf '{"tag_name":"%s","name":"%s","prerelease":%s}' "$tag" "$name" "$prerelease")
fi
# Try to create. On any failure (almost always "release already exists"), fall back to
# fetching it by tag. Either path MUST yield an id, or we error loudly — so a 401/scope
# problem can't masquerade as a successful no-op.
id=$(curl -fsS -X POST "$api/releases" \
-H "Authorization: token ${GITEA_TOKEN:?}" -H 'Content-Type: application/json' \
-d "$body" 2>/dev/null | _json_id || true)
if [ -z "$id" ]; then
id=$(curl -fsS "$api/releases/tags/$tag" \
-H "Authorization: token ${GITEA_TOKEN:?}" 2>/dev/null | _json_id || true)
fi
if [ -z "$id" ]; then
echo "gitea-release: could not create or find a release for tag '$tag'" >&2
return 1
fi
printf '%s' "$id"
}
# upsert_asset RELEASE_ID FILE [NAME]
# Attach FILE to the release, replacing any existing asset of the same name first so that
# re-runs and rolling canary re-uploads are idempotent (a plain POST 409s on a dup name).
upsert_asset() {
local rid="${1:?release id}" file="${2:?file}" name="${3:-}"
local api existing
[ -n "$name" ] || name="$(basename "$file")"
[ -f "$file" ] || { echo "gitea-release: asset file not found: $file" >&2; return 1; }
api="$(_gitea_api)"
existing=$(curl -fsS "$api/releases/$rid/assets" \
-H "Authorization: token ${GITEA_TOKEN:?}" 2>/dev/null \
| _json_asset_id "$name" || true)
if [ -n "$existing" ]; then
curl -fsS -o /dev/null -X DELETE "$api/releases/$rid/assets/$existing" \
-H "Authorization: token ${GITEA_TOKEN:?}" || true
fi
curl -fsS -o /dev/null -X POST "$api/releases/$rid/assets?name=$(_urlencode "$name")" \
-H "Authorization: token ${GITEA_TOKEN:?}" \
-F "attachment=@$file"
echo "gitea-release: uploaded '$name' -> release $rid"
}