Compare commits
2 Commits
b3811ff72e
...
0205c7b8d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 0205c7b8d6 | |||
| 3e6c9f6060 |
@@ -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
@@ -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
@@ -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"
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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
@@ -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 }}"
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 +
|
||||||
|
|||||||
@@ -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 ----
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"troubleshooting",
|
"troubleshooting",
|
||||||
"---Project---",
|
"---Project---",
|
||||||
"roadmap",
|
"roadmap",
|
||||||
|
"channels",
|
||||||
"---Reference---",
|
"---Reference---",
|
||||||
"[API Reference](/api)"
|
"[API Reference](/api)"
|
||||||
]
|
]
|
||||||
|
|||||||
+21
-5
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user