Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dab40ed98e |
@@ -46,23 +46,13 @@ jobs:
|
|||||||
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
|
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
|
||||||
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
|
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
|
||||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||||
with:
|
|
||||||
# Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain
|
|
||||||
# drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred).
|
|
||||||
# That download was the single flakiest piece of this job: the shared runner fleet drops
|
|
||||||
# packets under parallel-job load and sdkmanager's streamed unzip turns a truncated
|
|
||||||
# stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice).
|
|
||||||
packages: platform-tools
|
|
||||||
|
|
||||||
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
||||||
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
||||||
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
|
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
|
||||||
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||||
# auto-download it if needed during the build.
|
# auto-download it if needed during the build.
|
||||||
# retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the
|
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||||
# helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed
|
|
||||||
# attempt leaves no partial package behind, so a plain re-invoke is safe.
|
|
||||||
run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
|
||||||
|
|
||||||
- name: Caches (cargo + gradle)
|
- name: Caches (cargo + gradle)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Announce a stable release to the Discord #releases channel.
|
|
||||||
#
|
|
||||||
# This is the deliberate "go" step for a release. Release notes live in the repo at
|
|
||||||
# docs/releases/<tag>.md and are seeded into the Gitea release body at creation by the build
|
|
||||||
# workflows (scripts/ci/gitea-release.sh), so the release is never noteless. Once every
|
|
||||||
# platform's CI is green for a tag, dispatch this workflow with that tag: it re-asserts the notes
|
|
||||||
# file over the live release and posts a formatted embed to #releases.
|
|
||||||
#
|
|
||||||
# Manual on purpose — pressing "go" is the quality gate that says "all platforms built, notes are
|
|
||||||
# final, tell the community." It is NOT wired to the tag push, so a half-built or failed release
|
|
||||||
# is never announced. Stable-only: a -rc/pre-release tag is refused unless allow_prerelease=true.
|
|
||||||
#
|
|
||||||
# Requires the repo secret DISCORD_RELEASE_WEBHOOK (the #releases channel webhook URL); GITEA auth
|
|
||||||
# reuses REGISTRY_TOKEN like the other release workflows.
|
|
||||||
name: announce
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: "Release tag to announce (e.g. v0.18.0)"
|
|
||||||
required: true
|
|
||||||
allow_prerelease:
|
|
||||||
description: "Announce even if the tag is a pre-release (-rc)"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
announce:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Post release announcement to Discord
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
|
||||||
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
|
|
||||||
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
|
|
||||||
+30
-24
@@ -6,12 +6,17 @@
|
|||||||
#
|
#
|
||||||
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
||||||
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
||||||
# We build the frontend with pnpm and stage the store-layout tree with the SAME script local
|
# We build the frontend with pnpm and assemble the store-layout zip by hand:
|
||||||
# builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
|
#
|
||||||
# place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
|
# punktfunk.zip
|
||||||
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin
|
# punktfunk/ <- single top-level dir == plugin.json "name"
|
||||||
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
|
# plugin.json [required]
|
||||||
# top: the {channel, manifest} pointer the plugin's self-update check polls.
|
# package.json [required; CI stamps "version" — Decky reads the installed version here]
|
||||||
|
# main.py [required: python backend]
|
||||||
|
# dist/index.js [required: rollup output]
|
||||||
|
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
|
||||||
|
# README.md (recommended)
|
||||||
|
# LICENSE [required by the plugin store]
|
||||||
#
|
#
|
||||||
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
||||||
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
||||||
@@ -85,27 +90,28 @@ jobs:
|
|||||||
- name: Assemble store-layout zip
|
- name: Assemble store-layout zip
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
run: |
|
run: |
|
||||||
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
|
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
||||||
# so an image change can't silently break the build.
|
STAGE="$RUNNER_TEMP/decky"
|
||||||
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
|
DEST="$STAGE/$PLUGIN"
|
||||||
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
|
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
|
||||||
# LICENSE, …) with the same script local/sideload builds use — see the header comment.
|
cp clients/decky/plugin.json "$DEST/"
|
||||||
# Runs AFTER the version stamp, so the staged package.json carries $VERSION.
|
cp clients/decky/package.json "$DEST/"
|
||||||
bash clients/decky/scripts/package.sh
|
cp clients/decky/main.py "$DEST/"
|
||||||
DEST="clients/decky/out/$PLUGIN"
|
cp clients/decky/dist/index.js "$DEST/dist/"
|
||||||
# CI-only addition: the self-update channel pointer the backend reads (main.py
|
cp clients/decky/README.md "$DEST/"
|
||||||
# check_update). It points at THIS channel's manifest.json (published below); that
|
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
|
||||||
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid
|
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
|
||||||
# across future alias re-uploads.
|
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
|
||||||
|
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||||
|
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||||
|
cp LICENSE-MIT "$DEST/LICENSE"
|
||||||
|
# Self-update channel pointer the backend reads (main.py check_update). It points at
|
||||||
|
# THIS channel's manifest.json (published below); that manifest in turn points at the
|
||||||
|
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
|
||||||
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
||||||
( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||||
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
||||||
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
||||||
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
|
|
||||||
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
|
|
||||||
controller_config/punktfunk.vdf update.json; do
|
|
||||||
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
# The update manifest the plugin polls: the immutable per-version artifact + its
|
# The update manifest the plugin polls: the immutable per-version artifact + its
|
||||||
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
||||||
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
||||||
|
|||||||
@@ -73,21 +73,8 @@ jobs:
|
|||||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||||
# there, right before the first `flatpak` network call.
|
# there, right before the first `flatpak` network call.
|
||||||
- name: Fix container DNS (drop nss-resolve)
|
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
||||||
run: |
|
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
|
||||||
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
|
|
||||||
# the runner fleet's Docker embedded resolver dropped UDP lookups under parallel-job
|
|
||||||
# load (investigated 2026-07-11; v0.15.0/v0.16.0 each burned retry.sh's whole budget).
|
|
||||||
# That root cause is now fixed at the infra level (2026-07-22): the runner host runs a
|
|
||||||
# local dnsmasq cache on the docker bridge and daemon.json points every job container
|
|
||||||
# at it, so lookups terminate on-box instead of crossing the saturated uplink — the
|
|
||||||
# UDP path is reliable again. The TCP path through the same chain proved FLAKY under
|
|
||||||
# fleet concurrency (flatpak remote-add failed 10/10 with instant NXDOMAIN while dnf
|
|
||||||
# in the same container resolved fine), so `use-vc` flipped from mitigation to sole
|
|
||||||
# cause of this leg's failures — removed. retry.sh (10×) stays as the backstop for
|
|
||||||
# genuine upstream blips.
|
|
||||||
cat /etc/resolv.conf || true
|
|
||||||
|
|
||||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Publish the plugin framework (@punktfunk/plugin-kit) to the Gitea npm registry
|
|
||||||
# (https://git.unom.io/api/packages/unom/npm/).
|
|
||||||
#
|
|
||||||
# Trigger: push a tag `plugin-kit-vX.Y.Z` (must equal plugin-kit/package.json "version"),
|
|
||||||
# or run manually. Versions independently of the app's `v*` and the SDK's `sdk-v*` tags.
|
|
||||||
#
|
|
||||||
# The kit's devDependency on @punktfunk/host is `file:../sdk`, so the SDK's dist must be
|
|
||||||
# built BEFORE the kit's `bun install` copies it.
|
|
||||||
#
|
|
||||||
# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses.
|
|
||||||
name: plugin-kit-publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['plugin-kit-v*']
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
container:
|
|
||||||
image: oven/bun:1
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
# oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS
|
|
||||||
# fetch needs git + ca-certificates, and the version-guard step below uses node.
|
|
||||||
- name: Install git + node + CA certs
|
|
||||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build the SDK (file:../sdk dependency source)
|
|
||||||
working-directory: sdk
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile --ignore-scripts
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun run typecheck
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun test
|
|
||||||
|
|
||||||
- name: Build (dist/ JS + .d.ts + theme.css)
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Tag matches package version
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
working-directory: plugin-kit
|
|
||||||
run: |
|
|
||||||
TAG="${GITHUB_REF_NAME#plugin-kit-v}"
|
|
||||||
PKG="$(node -p "require('./package.json').version")"
|
|
||||||
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; }
|
|
||||||
|
|
||||||
- name: Publish to Gitea registry
|
|
||||||
working-directory: plugin-kit
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
|
||||||
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
|
||||||
bun publish
|
|
||||||
@@ -149,26 +149,6 @@ jobs:
|
|||||||
# inherits this from the env during the xcframework build).
|
# inherits this from the env during the xcframework build).
|
||||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Pin + prune Xcode DerivedData
|
|
||||||
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
|
||||||
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
|
||||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
|
||||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
|
||||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
|
||||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
|
||||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
|
||||||
run: |
|
|
||||||
DD="$HOME/ci/derived-data/release"
|
|
||||||
mkdir -p "$DD"
|
|
||||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
|
||||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
|
||||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
|
||||||
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
|
||||||
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
|
||||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||||
@@ -196,7 +176,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
@@ -294,7 +273,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
@@ -358,7 +336,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
@@ -393,76 +370,6 @@ jobs:
|
|||||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||||
|
|
||||||
- name: iOS — export .ipa (Gitea release + run artifact)
|
|
||||||
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
|
|
||||||
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
|
|
||||||
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
|
|
||||||
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
|
|
||||||
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
|
|
||||||
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
|
|
||||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
|
||||||
id: ios_ipa
|
|
||||||
run: |
|
|
||||||
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
|
|
||||||
if [ ! -d "$ARCHIVE" ]; then
|
|
||||||
echo "::warning::iOS archive not found — skipping .ipa export"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
PROFILE="Punktfunk iOS App Store Distribution"
|
|
||||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
|
||||||
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
|
|
||||||
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
|
|
||||||
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>method</key><string>app-store-connect</string>
|
|
||||||
<key>destination</key><string>export</string>
|
|
||||||
<key>teamID</key><string>$TEAM_ID</string>
|
|
||||||
<key>signingStyle</key><string>manual</string>
|
|
||||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
|
||||||
<key>provisioningProfiles</key>
|
|
||||||
<dict>
|
|
||||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
|
||||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
EOF
|
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
|
||||||
-archivePath "$ARCHIVE" \
|
|
||||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
|
|
||||||
-exportPath "$RUNNER_TEMP/export-ipa"
|
|
||||||
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
|
|
||||||
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
|
||||||
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
|
|
||||||
mv "$SRC" "$IPA"
|
|
||||||
echo "IPA=$IPA" >> "$GITHUB_ENV"
|
|
||||||
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "exported $IPA"
|
|
||||||
|
|
||||||
- name: Attach .ipa to the workflow run
|
|
||||||
if: steps.ios_ipa.outputs.ipa != ''
|
|
||||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
|
|
||||||
# (same reason as android.yml / apple.yml). Download is a zip of the .ipa.
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: punktfunk-ios-ipa
|
|
||||||
path: ${{ steps.ios_ipa.outputs.ipa }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Attach .ipa to the Gitea release (stable tags only)
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
. scripts/ci/gitea-release.sh
|
|
||||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
|
||||||
upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa"
|
|
||||||
|
|
||||||
- name: tvOS — archive + upload to TestFlight
|
- name: tvOS — archive + upload to TestFlight
|
||||||
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||||
# on every apple push (above), so this matches the iOS step's gate exactly.
|
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||||
@@ -487,7 +394,6 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
|
|||||||
@@ -104,9 +104,8 @@ jobs:
|
|||||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||||
|
|
||||||
# All three client binaries — the shell spawns punktfunk-session.exe (a package
|
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
|
||||||
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
|
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
|
||||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
|
||||||
- name: Build (release)
|
- name: Build (release)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||||
|
|||||||
Generated
+27
-95
@@ -656,30 +656,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cipher",
|
|
||||||
"cpufeatures",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20poly1305"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
|
||||||
dependencies = [
|
|
||||||
"aead",
|
|
||||||
"chacha20",
|
|
||||||
"cipher",
|
|
||||||
"poly1305",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ciborium"
|
name = "ciborium"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -715,7 +691,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
"inout",
|
"inout",
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1459,16 +1434,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gethostname"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
|
||||||
dependencies = [
|
|
||||||
"rustix",
|
|
||||||
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2194,7 +2159,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2299,7 +2264,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libvpl-sys"
|
name = "libvpl-sys"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -2334,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2823,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-capture"
|
name = "pf-capture"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2839,12 +2804,11 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"x11rb",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2868,7 +2832,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2886,7 +2850,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2907,7 +2871,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-encode"
|
name = "pf-encode"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2917,7 +2881,6 @@ dependencies = [
|
|||||||
"libvpl-sys",
|
"libvpl-sys",
|
||||||
"nvidia-video-codec-sdk",
|
"nvidia-video-codec-sdk",
|
||||||
"openh264",
|
"openh264",
|
||||||
"pf-capture",
|
|
||||||
"pf-frame",
|
"pf-frame",
|
||||||
"pf-gpu",
|
"pf-gpu",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2931,7 +2894,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2940,7 +2903,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-frame"
|
name = "pf-frame"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2952,7 +2915,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-gpu"
|
name = "pf-gpu"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2966,11 +2929,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-host-config"
|
name = "pf-host-config"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-inject"
|
name = "pf-inject"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2998,14 +2961,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-paths"
|
name = "pf-paths"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3020,7 +2983,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3050,7 +3013,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-win-display"
|
name = "pf-win-display"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
@@ -3062,7 +3025,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-zerocopy"
|
name = "pf-zerocopy"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3173,17 +3136,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "poly1305"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
|
||||||
dependencies = [
|
|
||||||
"cpufeatures",
|
|
||||||
"opaque-debug",
|
|
||||||
"universal-hash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3269,7 +3221,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3285,7 +3237,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3301,7 +3253,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3316,7 +3268,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3335,12 +3287,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cbindgen",
|
"cbindgen",
|
||||||
"chacha20poly1305",
|
|
||||||
"criterion",
|
"criterion",
|
||||||
"fec-rs",
|
"fec-rs",
|
||||||
"hmac",
|
"hmac",
|
||||||
@@ -3367,7 +3318,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3412,13 +3363,11 @@ dependencies = [
|
|||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reis",
|
"reis",
|
||||||
"ring",
|
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rusty_enet",
|
"rusty_enet",
|
||||||
"semver",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -3451,7 +3400,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3465,7 +3414,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3488,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -5897,23 +5846,6 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x11rb"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
|
||||||
dependencies = [
|
|
||||||
"gethostname",
|
|
||||||
"rustix",
|
|
||||||
"x11rb-protocol",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x11rb-protocol"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x509-parser"
|
name = "x509-parser"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.19.0"
|
version = "0.14.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-1282
File diff suppressed because it is too large
Load Diff
@@ -1,107 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Text clipboard sync for the active session (the desktop-client model, text-only v1):
|
|
||||||
* * **Device → host**: a local copy (the primary-clip listener, plus one probe at start) is
|
|
||||||
* announced as a lazy offer — the text crosses only when the host actually pastes (a
|
|
||||||
* `fetch:` event, answered with the clipboard's current content).
|
|
||||||
* * **Host → device**: a host copy arrives as an `offer:` event and is fetched eagerly into
|
|
||||||
* the system clipboard (Android apps can't lazily materialize a paste from the network
|
|
||||||
* without a content-provider round-trip that isn't worth it here).
|
|
||||||
*
|
|
||||||
* Loop guard: text set from a host fetch is remembered ([lastFromHost]) so the resulting
|
|
||||||
* primary-clip-changed callback doesn't bounce it straight back as a new offer. Clipboard reads
|
|
||||||
* happen while the stream is foreground (Android only allows focused-app reads). The native
|
|
||||||
* events are drained on a dedicated thread and applied on the main thread; [stop] joins it.
|
|
||||||
*/
|
|
||||||
class ClipboardSync(
|
|
||||||
private val context: Context,
|
|
||||||
private val handle: Long,
|
|
||||||
) {
|
|
||||||
private val main = Handler(Looper.getMainLooper())
|
|
||||||
private val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
|
|
||||||
@Volatile private var running = true
|
|
||||||
private var seq = 0
|
|
||||||
private var lastOffered: String? = null
|
|
||||||
private var lastFromHost: String? = null
|
|
||||||
private var pendingFetch = -1
|
|
||||||
private var thread: Thread? = null
|
|
||||||
|
|
||||||
private val clipListener = ClipboardManager.OnPrimaryClipChangedListener { offerLocal() }
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
NativeBridge.nativeClipControl(handle, true)
|
|
||||||
cm.addPrimaryClipChangedListener(clipListener)
|
|
||||||
thread = Thread({ pollLoop() }, "pf-clipboard").also { it.start() }
|
|
||||||
offerLocal() // whatever is already on the clipboard is pasteable host-side right away
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
running = false
|
|
||||||
cm.removePrimaryClipChangedListener(clipListener)
|
|
||||||
thread?.join(600) // one poll timeout (250 ms) + slack
|
|
||||||
thread = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Announce the current local text (if it's new and not an echo of a host copy). */
|
|
||||||
private fun offerLocal() {
|
|
||||||
if (!running) return
|
|
||||||
val text = currentClipText() ?: return
|
|
||||||
if (text == lastOffered || text == lastFromHost) return
|
|
||||||
lastOffered = text
|
|
||||||
seq += 1
|
|
||||||
NativeBridge.nativeClipOfferText(handle, seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun currentClipText(): String? = runCatching {
|
|
||||||
cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)
|
|
||||||
?.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() }
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
private fun pollLoop() {
|
|
||||||
while (running) {
|
|
||||||
val ev = NativeBridge.nativeNextClip(handle) ?: continue
|
|
||||||
if (ev == "closed") return
|
|
||||||
main.post { handleEvent(ev) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleEvent(ev: String) {
|
|
||||||
if (!running) return
|
|
||||||
val parts = ev.split(":", limit = 3)
|
|
||||||
when (parts[0]) {
|
|
||||||
"offer" -> {
|
|
||||||
val offerSeq = parts.getOrNull(1)?.toIntOrNull() ?: return
|
|
||||||
if (parts.getOrNull(2) == "1") {
|
|
||||||
pendingFetch = NativeBridge.nativeClipFetchText(handle, offerSeq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"fetch" -> {
|
|
||||||
val req = parts.getOrNull(1)?.toIntOrNull() ?: return
|
|
||||||
val text = currentClipText()
|
|
||||||
if (text != null) {
|
|
||||||
NativeBridge.nativeClipServeText(handle, req, text)
|
|
||||||
} else {
|
|
||||||
NativeBridge.nativeClipCancel(handle, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"data" -> {
|
|
||||||
val xfer = parts.getOrNull(1)?.toIntOrNull() ?: return
|
|
||||||
if (xfer != pendingFetch) return // stale/unknown transfer
|
|
||||||
pendingFetch = -1
|
|
||||||
val text = parts.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: return
|
|
||||||
lastFromHost = text
|
|
||||||
runCatching { cm.setPrimaryClip(ClipData.newPlainText("Punktfunk", text)) }
|
|
||||||
}
|
|
||||||
// "state"/"cancel"/"error": nothing to drive in the text-only v1.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,21 +54,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
|
|
||||||
* [gamepadRouter]): uncaptured hover/click/wheel forwards as absolute cursor input, captured
|
|
||||||
* ([android.view.View.requestPointerCapture]) raw deltas as relative mouse-look. The dispatch
|
|
||||||
* overrides below route every SOURCE_MOUSE event here while streaming. Null while not streaming.
|
|
||||||
*/
|
|
||||||
var mouseForwarder: MouseForwarder? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TV remote-as-pointer for the active session (StreamScreen builds it on TV devices only):
|
|
||||||
* hold SELECT to toggle, then the D-pad glides the host cursor. Consulted first for
|
|
||||||
* non-gamepad keys while streaming. Null while not streaming or not a TV.
|
|
||||||
*/
|
|
||||||
var remotePointer: RemotePointer? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
||||||
* couch user with no keyboard/Back can always leave a stream.
|
* couch user with no keyboard/Back can always leave a stream.
|
||||||
@@ -339,37 +324,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
|
||||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
|
||||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
|
||||||
remotePointer?.let { if (it.onKey(event)) return true }
|
|
||||||
}
|
|
||||||
// Ctrl+Alt+Shift+Q — the cross-client pointer-capture toggle chord. Swallow both
|
|
||||||
// edges of the Q (the modifiers already went over the wire, exactly like desktop).
|
|
||||||
if (event.keyCode == KeyEvent.KEYCODE_Q &&
|
|
||||||
event.isCtrlPressed && event.isAltPressed && event.isShiftPressed
|
|
||||||
) {
|
|
||||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
|
||||||
mouseForwarder?.toggleCapture()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
when (event.keyCode) {
|
when (event.keyCode) {
|
||||||
// A mouse's back/forward buttons already go over the wire as X1/X2 via their
|
|
||||||
// BUTTON_* motion edges — but Android ALSO delivers them as key events: the input
|
|
||||||
// reader synthesizes KEYCODE_BACK/FORWARD (stamped SOURCE_MOUSE) unconditionally,
|
|
||||||
// and a view-level FALLBACK BACK appears when the BUTTON_* press goes unconsumed.
|
|
||||||
// Swallow every such duplicate or it doubles as Android navigation and yanks the
|
|
||||||
// user out of the stream. A remote/keyboard BACK is never mouse-sourced, so it
|
|
||||||
// still falls through to the BackHandler and exits.
|
|
||||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
|
|
||||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
|
|
||||||
event.flags and KeyEvent.FLAG_FALLBACK != 0
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Leave these to the system even while streaming.
|
// Leave these to the system even while streaming.
|
||||||
// (BACK above → BackHandler leaves the stream.)
|
KeyEvent.KEYCODE_BACK, // → BackHandler leaves the stream
|
||||||
KeyEvent.KEYCODE_VOLUME_UP,
|
KeyEvent.KEYCODE_VOLUME_UP,
|
||||||
KeyEvent.KEYCODE_VOLUME_DOWN,
|
KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||||
KeyEvent.KEYCODE_VOLUME_MUTE,
|
KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||||
@@ -437,10 +394,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||||
if (streamHandle != 0L) {
|
if (streamHandle != 0L) {
|
||||||
if (gamepadRouter?.onMotion(event) == true) return true
|
if (gamepadRouter?.onMotion(event) == true) return true
|
||||||
// Physical mouse (uncaptured): hover motion, wheel, button edges.
|
|
||||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
|
||||||
mouseForwarder?.let { if (it.onGenericMotion(event)) return true }
|
|
||||||
}
|
|
||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||||
@@ -478,24 +431,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mouse clicks/drags ride the TOUCH stream (the pointer is "down"). While streaming they
|
|
||||||
* belong to the mouse forwarder, never to the Compose touch-gesture layer — a physical
|
|
||||||
* mouse click must be a real click at the cursor, not a synthesized trackpad tap.
|
|
||||||
*/
|
|
||||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
|
||||||
if (streamHandle != 0L && ev.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
|
||||||
mouseForwarder?.let { if (it.onTouchEvent(ev)) return true }
|
|
||||||
}
|
|
||||||
return super.dispatchTouchEvent(ev)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The OS is the source of truth for pointer capture (it releases on focus loss). */
|
|
||||||
override fun onPointerCaptureChanged(hasCapture: Boolean) {
|
|
||||||
super.onPointerCaptureChanged(hasCapture)
|
|
||||||
mouseForwarder?.onCaptureChanged(hasCapture)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
||||||
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import android.view.InputDevice
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/** True when any connected input device is a pointer (USB/BT mouse, or a touchpad driving one). */
|
|
||||||
fun hasPhysicalMouse(): Boolean = InputDevice.getDeviceIds().any { id ->
|
|
||||||
InputDevice.getDevice(id)?.supportsSource(InputDevice.SOURCE_MOUSE) == true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Physical mouse → wire, in two modes (the iPadOS/desktop model):
|
|
||||||
* * **uncaptured** (default): hover/drag positions forward as absolute cursor moves
|
|
||||||
* (`MouseMoveAbs`, host-normalized against the window size) — desktop-style pointing. The
|
|
||||||
* local cursor is hidden over the stream (StreamScreen sets a TYPE_NULL pointer icon); the
|
|
||||||
* host's own cursor, composited into the video, is the one you see.
|
|
||||||
* * **captured**: the OS pointer is grabbed ([android.view.View.requestPointerCapture]) and raw
|
|
||||||
* relative deltas forward as `MouseMove` — FPS mouse-look. Engaged at stream start / by
|
|
||||||
* clicking into the stream when the "Capture pointer for games" setting is on, and toggled
|
|
||||||
* any time by Ctrl+Alt+Shift+Q (the cross-client chord). Focus loss releases it (the OS
|
|
||||||
* guarantees that); a click re-engages.
|
|
||||||
*
|
|
||||||
* Buttons ride [MotionEvent.ACTION_BUTTON_PRESS]/RELEASE edges (left/middle/right/back/forward →
|
|
||||||
* wire 1/2/3/4/5), the wheel rides [MotionEvent.ACTION_SCROLL] with fractional accumulation so
|
|
||||||
* high-resolution wheels don't lose sub-notch travel. Held buttons are tracked and flushed on
|
|
||||||
* capture loss / stream exit so nothing sticks on the host. Events reach this class from
|
|
||||||
* MainActivity's dispatch overrides (uncaptured) and the capture view's captured-pointer listener.
|
|
||||||
*/
|
|
||||||
class MouseForwarder(
|
|
||||||
private val handle: Long,
|
|
||||||
private val invertScroll: Boolean,
|
|
||||||
private val captureWanted: Boolean,
|
|
||||||
private val surfaceSize: () -> Pair<Int, Int>,
|
|
||||||
) {
|
|
||||||
/** Capture plumbing, owned by StreamScreen (the focusable capture view). */
|
|
||||||
var onRequestCapture: (() -> Unit)? = null
|
|
||||||
var onReleaseCapture: (() -> Unit)? = null
|
|
||||||
|
|
||||||
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
|
|
||||||
var captured = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** Chord-released: no auto re-engage (start / click) until the user opts back in. */
|
|
||||||
private var userReleased = false
|
|
||||||
|
|
||||||
private val heldButtons = mutableSetOf<Int>()
|
|
||||||
private var scrollAccV = 0f
|
|
||||||
private var scrollAccH = 0f
|
|
||||||
private var moveAccX = 0f
|
|
||||||
private var moveAccY = 0f
|
|
||||||
|
|
||||||
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
|
|
||||||
fun onTouchEvent(ev: MotionEvent): Boolean {
|
|
||||||
when (ev.actionMasked) {
|
|
||||||
MotionEvent.ACTION_DOWN -> {
|
|
||||||
if (captureWanted && !captured && !userReleased) {
|
|
||||||
// The engaging click: grab the pointer and swallow the click (desktop
|
|
||||||
// parity — the click that captures never reaches the host). The paired
|
|
||||||
// BUTTON_RELEASE is dropped by the held-set guard in [button].
|
|
||||||
onRequestCapture?.invoke()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
sendAbs(ev)
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_MOVE -> sendAbs(ev)
|
|
||||||
// Button edges are documented on the generic stream, but be robust to either.
|
|
||||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
|
||||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
|
|
||||||
fun onGenericMotion(ev: MotionEvent): Boolean {
|
|
||||||
when (ev.actionMasked) {
|
|
||||||
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
|
|
||||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
|
||||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
|
||||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
|
||||||
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_EXIT -> {}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captured-pointer events (the view holds [android.view.View.requestPointerCapture]): x/y ARE
|
|
||||||
* the relative deltas ([InputDevice.SOURCE_MOUSE_RELATIVE]), batched samples included. A
|
|
||||||
* captured touchpad reports absolute finger coordinates instead — not handled (the touch
|
|
||||||
* gesture layer is the touchpad story); returning false leaves those to the framework.
|
|
||||||
*/
|
|
||||||
fun onCapturedPointer(ev: MotionEvent): Boolean {
|
|
||||||
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
|
||||||
when (ev.actionMasked) {
|
|
||||||
MotionEvent.ACTION_MOVE -> {
|
|
||||||
var dx = 0f
|
|
||||||
var dy = 0f
|
|
||||||
for (i in 0 until ev.historySize) {
|
|
||||||
dx += ev.getHistoricalX(i)
|
|
||||||
dy += ev.getHistoricalY(i)
|
|
||||||
}
|
|
||||||
dx += ev.x
|
|
||||||
dy += ev.y
|
|
||||||
moveAccX += dx
|
|
||||||
moveAccY += dy
|
|
||||||
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept w/ sign
|
|
||||||
val oy = moveAccY.toInt()
|
|
||||||
if (ox != 0 || oy != 0) {
|
|
||||||
NativeBridge.nativeSendPointerMove(handle, ox, oy)
|
|
||||||
moveAccX -= ox
|
|
||||||
moveAccY -= oy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
|
||||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
|
||||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ctrl+Alt+Shift+Q: release the grab, or (re-)engage it — works even when auto-capture is off. */
|
|
||||||
fun toggleCapture() {
|
|
||||||
if (captured) {
|
|
||||||
userReleased = true
|
|
||||||
onReleaseCapture?.invoke()
|
|
||||||
} else {
|
|
||||||
userReleased = false
|
|
||||||
onRequestCapture?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Auto-engage at stream start (setting on + a mouse actually present). */
|
|
||||||
fun engageFromStart() {
|
|
||||||
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
|
|
||||||
onRequestCapture?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** From [android.app.Activity.onPointerCaptureChanged] — the OS is the source of truth. */
|
|
||||||
fun onCaptureChanged(has: Boolean) {
|
|
||||||
captured = has
|
|
||||||
// Losing the grab (focus loss, chord) must not leave buttons held on the host.
|
|
||||||
if (!has) flushButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stream teardown: lift anything held and let the grab go. */
|
|
||||||
fun release() {
|
|
||||||
flushButtons()
|
|
||||||
if (captured) onReleaseCapture?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendAbs(ev: MotionEvent) {
|
|
||||||
val (w, h) = surfaceSize()
|
|
||||||
if (w <= 0 || h <= 0) return
|
|
||||||
NativeBridge.nativeSendPointerAbs(
|
|
||||||
handle,
|
|
||||||
ev.x.roundToInt().coerceIn(0, w - 1),
|
|
||||||
ev.y.roundToInt().coerceIn(0, h - 1),
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun wheel(ev: MotionEvent) {
|
|
||||||
val dir = if (invertScroll) -1f else 1f
|
|
||||||
// Android: AXIS_VSCROLL + = up/away, AXIS_HSCROLL + = right — the wire's convention too.
|
|
||||||
scrollAccV += ev.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120f * dir
|
|
||||||
scrollAccH += ev.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120f * dir
|
|
||||||
val v = scrollAccV.toInt()
|
|
||||||
if (v != 0) {
|
|
||||||
NativeBridge.nativeSendScroll(handle, 0, v)
|
|
||||||
scrollAccV -= v
|
|
||||||
}
|
|
||||||
val h = scrollAccH.toInt()
|
|
||||||
if (h != 0) {
|
|
||||||
NativeBridge.nativeSendScroll(handle, 1, h)
|
|
||||||
scrollAccH -= h
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun button(actionButton: Int, down: Boolean) {
|
|
||||||
val b = when (actionButton) {
|
|
||||||
MotionEvent.BUTTON_PRIMARY -> 1
|
|
||||||
MotionEvent.BUTTON_TERTIARY -> 2
|
|
||||||
MotionEvent.BUTTON_SECONDARY -> 3
|
|
||||||
MotionEvent.BUTTON_BACK -> 4
|
|
||||||
MotionEvent.BUTTON_FORWARD -> 5
|
|
||||||
else -> return
|
|
||||||
}
|
|
||||||
if (down) {
|
|
||||||
heldButtons.add(b)
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, b, true)
|
|
||||||
} else if (heldButtons.remove(b)) {
|
|
||||||
// Only release what we pressed — drops the release of a swallowed engaging click
|
|
||||||
// and anything that raced a capture transition.
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, b, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flushButtons() {
|
|
||||||
heldButtons.forEach { NativeBridge.nativeSendPointerButton(handle, it, false) }
|
|
||||||
heldButtons.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.Choreographer
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
|
||||||
import kotlin.math.hypot
|
|
||||||
|
|
||||||
// Hold this long on SELECT (pointer-mode toggle) / PLAY-PAUSE (keyboard toggle) for the long-press
|
|
||||||
// action instead of the tap action.
|
|
||||||
private const val LONG_PRESS_MS = 800L
|
|
||||||
|
|
||||||
// D-pad glide ballistics, in screen-widths per second: start slow enough to hit a close button,
|
|
||||||
// ramp over RAMP_S seconds of continuous hold so crossing the desktop doesn't take all day.
|
|
||||||
private const val SPEED_MIN = 0.14f
|
|
||||||
private const val SPEED_MAX = 0.70f
|
|
||||||
private const val RAMP_S = 1.2f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android TV remote as a pointer — the Android analogue of the Apple client's Siri-remote pointer,
|
|
||||||
* adapted for D-pad-only remotes (most Android TV remotes have no touch surface). For the
|
|
||||||
* "TV as a desktop client" use case, where a plain remote is often the only thing in hand.
|
|
||||||
*
|
|
||||||
* While streaming on a TV, **hold SELECT ≈ 0.8 s** to toggle pointer mode. While active:
|
|
||||||
* * D-pad (held) glides the host cursor with ramping acceleration (relative `MouseMove`,
|
|
||||||
* Choreographer-paced, diagonal-normalized);
|
|
||||||
* * SELECT tap = left click; PLAY/PAUSE tap = right click (Siri-remote parity);
|
|
||||||
* * PLAY/PAUSE held = toggle the on-screen keyboard; BACK = leave pointer mode
|
|
||||||
* (a second BACK then leaves the stream as usual).
|
|
||||||
* While inactive, everything except the SELECT long-press passes through untouched (D-pad =
|
|
||||||
* arrow keys, SELECT tap = Enter — synthesized on release, since the down was held back to
|
|
||||||
* disambiguate the long-press).
|
|
||||||
*
|
|
||||||
* Only consulted for non-gamepad key events on TV devices (MainActivity gates the calls); all
|
|
||||||
* state lives on the main thread.
|
|
||||||
*/
|
|
||||||
class RemotePointer(
|
|
||||||
private val handle: Long,
|
|
||||||
private val surfaceWidth: () -> Int,
|
|
||||||
private val onActiveChanged: (Boolean) -> Unit,
|
|
||||||
private val onKeyboardToggle: () -> Unit,
|
|
||||||
) {
|
|
||||||
var active = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
|
|
||||||
private var moveAccX = 0f
|
|
||||||
private var moveAccY = 0f
|
|
||||||
private var lastFrameNs = 0L
|
|
||||||
private var rampSec = 0f
|
|
||||||
private var tickerRunning = false
|
|
||||||
private var centerLongFired = false
|
|
||||||
private var playLongFired = false
|
|
||||||
|
|
||||||
private val centerLong = Runnable {
|
|
||||||
centerLongFired = true
|
|
||||||
toggle()
|
|
||||||
}
|
|
||||||
private val playLong = Runnable {
|
|
||||||
playLongFired = true
|
|
||||||
onKeyboardToggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val frame = object : Choreographer.FrameCallback {
|
|
||||||
override fun doFrame(nowNs: Long) {
|
|
||||||
if (!tickerRunning) return
|
|
||||||
if (held.isEmpty() || !active) {
|
|
||||||
tickerRunning = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val dt = if (lastFrameNs == 0L) {
|
|
||||||
1f / 60f
|
|
||||||
} else {
|
|
||||||
((nowNs - lastFrameNs) / 1e9f).coerceIn(0.001f, 0.1f)
|
|
||||||
}
|
|
||||||
lastFrameNs = nowNs
|
|
||||||
rampSec += dt
|
|
||||||
var vx = 0f
|
|
||||||
var vy = 0f
|
|
||||||
if (KeyEvent.KEYCODE_DPAD_LEFT in held) vx -= 1f
|
|
||||||
if (KeyEvent.KEYCODE_DPAD_RIGHT in held) vx += 1f
|
|
||||||
if (KeyEvent.KEYCODE_DPAD_UP in held) vy -= 1f
|
|
||||||
if (KeyEvent.KEYCODE_DPAD_DOWN in held) vy += 1f
|
|
||||||
val mag = hypot(vx, vy)
|
|
||||||
if (mag > 0f) {
|
|
||||||
val w = surfaceWidth().coerceAtLeast(640)
|
|
||||||
val speed = w * (SPEED_MIN + (SPEED_MAX - SPEED_MIN) * (rampSec / RAMP_S).coerceAtMost(1f))
|
|
||||||
moveAccX += vx / mag * speed * dt
|
|
||||||
moveAccY += vy / mag * speed * dt
|
|
||||||
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept
|
|
||||||
val oy = moveAccY.toInt()
|
|
||||||
if (ox != 0 || oy != 0) {
|
|
||||||
NativeBridge.nativeSendPointerMove(handle, ox, oy)
|
|
||||||
moveAccX -= ox
|
|
||||||
moveAccY -= oy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Choreographer.getInstance().postFrameCallback(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One remote key event; true = consumed. Ignore key repeats — the ticker owns motion. */
|
|
||||||
fun onKey(event: KeyEvent): Boolean {
|
|
||||||
val down = event.action == KeyEvent.ACTION_DOWN
|
|
||||||
when (event.keyCode) {
|
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
|
||||||
if (down) {
|
|
||||||
if (event.repeatCount == 0) {
|
|
||||||
centerLongFired = false
|
|
||||||
handler.postDelayed(centerLong, LONG_PRESS_MS)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handler.removeCallbacks(centerLong)
|
|
||||||
if (!centerLongFired) {
|
|
||||||
if (active) {
|
|
||||||
click(1)
|
|
||||||
} else {
|
|
||||||
// The down was held back to disambiguate the long-press, so the
|
|
||||||
// normal path never saw it — synthesize the Enter here instead.
|
|
||||||
NativeBridge.nativeSendKey(handle, 0x0D, true, 0)
|
|
||||||
NativeBridge.nativeSendKey(handle, 0x0D, false, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
|
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
|
|
||||||
-> {
|
|
||||||
if (!active) return false
|
|
||||||
if (down) {
|
|
||||||
if (held.add(event.keyCode) && held.size == 1) startTicker()
|
|
||||||
} else {
|
|
||||||
held.remove(event.keyCode)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
|
||||||
if (!active) return false // inactive: the media-key VK path owns it
|
|
||||||
if (down) {
|
|
||||||
if (event.repeatCount == 0) {
|
|
||||||
playLongFired = false
|
|
||||||
handler.postDelayed(playLong, LONG_PRESS_MS)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handler.removeCallbacks(playLong)
|
|
||||||
if (!playLongFired) click(3)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_BACK -> {
|
|
||||||
if (!active) return false
|
|
||||||
if (!down) toggle() // leave pointer mode; the next BACK leaves the stream
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stream teardown: stop timers/ticker; nothing wire-held to flush (clicks are edges). */
|
|
||||||
fun release() {
|
|
||||||
handler.removeCallbacks(centerLong)
|
|
||||||
handler.removeCallbacks(playLong)
|
|
||||||
active = false
|
|
||||||
held.clear()
|
|
||||||
tickerRunning = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggle() {
|
|
||||||
active = !active
|
|
||||||
if (!active) {
|
|
||||||
held.clear()
|
|
||||||
tickerRunning = false
|
|
||||||
}
|
|
||||||
onActiveChanged(active)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startTicker() {
|
|
||||||
rampSec = 0f
|
|
||||||
lastFrameNs = 0L
|
|
||||||
if (!tickerRunning) {
|
|
||||||
tickerRunning = true
|
|
||||||
Choreographer.getInstance().postFrameCallback(frame)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun click(button: Int) {
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, button, true)
|
|
||||||
NativeBridge.nativeSendPointerButton(handle, button, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -109,27 +109,6 @@ data class Settings(
|
|||||||
* setup where the OS-level pad (lizard mode) is preferred.
|
* setup where the OS-level pad (lizard mode) is preferred.
|
||||||
*/
|
*/
|
||||||
val sc2Capture: Boolean = true,
|
val sc2Capture: Boolean = true,
|
||||||
|
|
||||||
/**
|
|
||||||
* Lock a physical mouse to the stream ([android.view.View.requestPointerCapture]) and forward
|
|
||||||
* raw relative motion — FPS mouse-look, the iPad "Capture pointer for games" twin. Engages at
|
|
||||||
* stream start and on a click into the stream; Ctrl+Alt+Shift+Q toggles it live (the chord
|
|
||||||
* works even with this off). Off (default): a mouse points absolutely, desktop-style.
|
|
||||||
*/
|
|
||||||
val pointerCapture: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with
|
|
||||||
* the Apple/GTK clients' "Invert scroll direction".
|
|
||||||
*/
|
|
||||||
val invertScroll: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync text copied on this device to the host and vice versa while streaming (the desktop
|
|
||||||
* clients' shared clipboard, text-only here). Only effective when the host advertises the
|
|
||||||
* clipboard capability; the protocol is opt-in per session either way.
|
|
||||||
*/
|
|
||||||
val clipboardSync: Boolean = true,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -193,9 +172,6 @@ class SettingsStore(context: Context) {
|
|||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||||
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false),
|
|
||||||
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
|
|
||||||
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -219,9 +195,6 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||||
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture)
|
|
||||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
|
||||||
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
|
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +233,6 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||||
const val K_SC2_CAPTURE = "sc2_capture"
|
const val K_SC2_CAPTURE = "sc2_capture"
|
||||||
const val K_POINTER_CAPTURE = "pointer_capture"
|
|
||||||
const val K_INVERT_SCROLL = "invert_scroll"
|
|
||||||
const val K_CLIPBOARD_SYNC = "clipboard_sync"
|
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
|
|||||||
@@ -412,27 +412,6 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
ToggleRow(
|
|
||||||
title = "Capture pointer for games",
|
|
||||||
subtitle = "Lock a connected mouse to the stream and send raw relative motion " +
|
|
||||||
"(mouse-look). Ctrl+Alt+Shift+Q toggles it live; click the stream to re-capture. " +
|
|
||||||
"Off: the mouse points at the desktop directly",
|
|
||||||
checked = s.pointerCapture,
|
|
||||||
onCheckedChange = { on -> update(s.copy(pointerCapture = on)) },
|
|
||||||
)
|
|
||||||
ToggleRow(
|
|
||||||
title = "Invert scroll direction",
|
|
||||||
subtitle = "Flip the mouse wheel and two-finger touch scrolling",
|
|
||||||
checked = s.invertScroll,
|
|
||||||
onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
|
|
||||||
)
|
|
||||||
ToggleRow(
|
|
||||||
title = "Shared clipboard",
|
|
||||||
subtitle = "Text copied here pastes on the host and vice versa (hosts with " +
|
|
||||||
"clipboard sharing enabled)",
|
|
||||||
checked = s.clipboardSync,
|
|
||||||
onCheckedChange = { on -> update(s.copy(clipboardSync = on)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import android.net.wifi.WifiManager
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -177,14 +176,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
|
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
|
||||||
var exitArming by remember { mutableStateOf(false) }
|
var exitArming by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// True while the TV remote is acting as a pointer (hold SELECT toggles) — drives the mode hint.
|
|
||||||
var remotePointerOn by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Focus anchor the soft keyboard is summoned onto AND the pointer-capture grab target (a grab
|
|
||||||
// needs a focusable view; captured-pointer events land on it). Declared before the effect
|
|
||||||
// below so the capture callbacks can reach the view once it exists.
|
|
||||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
|
||||||
|
|
||||||
DisposableEffect(handle) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
wifiLocks.forEach { lock ->
|
wifiLocks.forEach { lock ->
|
||||||
@@ -230,54 +221,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||||
router.onExitArmed = { armed -> exitArming = armed }
|
router.onExitArmed = { armed -> exitArming = armed }
|
||||||
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
|
|
||||||
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
|
|
||||||
// The local cursor is hidden over the stream — the host's own cursor, composited into
|
|
||||||
// the video, is the one the user sees (twin of the desktop clients' hidden cursor).
|
|
||||||
val decor = window?.decorView
|
|
||||||
val priorPointerIcon = decor?.pointerIcon
|
|
||||||
decor?.pointerIcon = android.view.PointerIcon.getSystemIcon(
|
|
||||||
context,
|
|
||||||
android.view.PointerIcon.TYPE_NULL,
|
|
||||||
)
|
|
||||||
val mouse = MouseForwarder(
|
|
||||||
handle,
|
|
||||||
invertScroll = initialSettings.invertScroll,
|
|
||||||
captureWanted = initialSettings.pointerCapture,
|
|
||||||
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
|
|
||||||
)
|
|
||||||
mouse.onRequestCapture = {
|
|
||||||
// The grab needs the (focusable) capture view: focus it, then ask. Posted so a
|
|
||||||
// request racing view attach/focus settles on the next frame.
|
|
||||||
keyCapture?.let { v ->
|
|
||||||
v.post {
|
|
||||||
v.requestFocus()
|
|
||||||
v.requestPointerCapture()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mouse.onReleaseCapture = { keyCapture?.releasePointerCapture() }
|
|
||||||
activity?.mouseForwarder = mouse
|
|
||||||
// TV remote-as-pointer: hold SELECT ≈ 0.8 s to toggle; the D-pad then glides the host
|
|
||||||
// cursor (see RemotePointer). TV only — a phone's remote-less keys stay on the VK path.
|
|
||||||
val remote = if (isTv) {
|
|
||||||
RemotePointer(
|
|
||||||
handle,
|
|
||||||
surfaceWidth = { decor?.width ?: 1920 },
|
|
||||||
onActiveChanged = { on -> remotePointerOn = on },
|
|
||||||
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
activity?.remotePointer = remote
|
|
||||||
// Shared clipboard (text v1): only when the user setting is on AND the host has a
|
|
||||||
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
|
|
||||||
val clip = if (initialSettings.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
|
|
||||||
ClipboardSync(context, handle).also { it.start() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||||
// index via the router; poll threads stopped + joined before the router is released and the
|
// index via the router; poll threads stopped + joined before the router is released and the
|
||||||
@@ -343,7 +286,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
}
|
}
|
||||||
onDispose {
|
onDispose {
|
||||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
|
||||||
feedback.onHidRaw = null
|
feedback.onHidRaw = null
|
||||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
@@ -351,12 +293,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||||
activity?.gamepadRouter = null
|
activity?.gamepadRouter = null
|
||||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
|
||||||
mouse.release()
|
|
||||||
activity?.mouseForwarder = null
|
|
||||||
remote?.release()
|
|
||||||
activity?.remotePointer = null
|
|
||||||
decor?.pointerIcon = priorPointerIcon
|
|
||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
activity?.requestStreamExit = null
|
activity?.requestStreamExit = null
|
||||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||||
@@ -384,12 +320,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
|
|
||||||
// Auto-engage pointer capture at stream start (setting on + a mouse actually present).
|
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
|
||||||
// Delayed a beat: the grab needs window focus and the capture view attached.
|
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||||
LaunchedEffect(handle) {
|
|
||||||
delay(400)
|
|
||||||
activity?.mouseForwarder?.engageFromStart()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
@@ -447,49 +379,23 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
if (exitArming) {
|
if (exitArming) {
|
||||||
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||||
}
|
}
|
||||||
// Remote-pointer mode hint — the remote's keys are remapped while it's on, so say so.
|
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
|
||||||
if (remotePointerOn) {
|
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
|
||||||
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
|
||||||
}
|
|
||||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
|
|
||||||
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
|
|
||||||
// touches, it just owns IME focus and receives captured-pointer events.
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.size(1.dp),
|
modifier = Modifier.size(1.dp),
|
||||||
factory = { ctx ->
|
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
|
||||||
KeyCaptureView(ctx).also { v ->
|
|
||||||
keyCapture = v
|
|
||||||
// Real IME text path when the host types committed text (see KeyCaptureView).
|
|
||||||
v.textHandle =
|
|
||||||
if (NativeBridge.nativeTextInputSupported(handle)) handle else 0L
|
|
||||||
v.setOnCapturedPointerListener { _, ev ->
|
|
||||||
(ctx as? MainActivity)?.mouseForwarder?.onCapturedPointer(ev) ?: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
||||||
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
||||||
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
||||||
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
|
|
||||||
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
|
|
||||||
// stationary held stroke alive (and its cancellation lifts everything on teardown).
|
|
||||||
val stylus = remember(handle) {
|
|
||||||
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
|
|
||||||
}
|
|
||||||
if (stylus != null) {
|
|
||||||
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||||
when (touchMode) {
|
when (touchMode) {
|
||||||
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
|
TouchMode.TOUCH -> streamTouchPassthrough(handle)
|
||||||
else -> streamTouchInput(
|
else -> streamTouchInput(
|
||||||
handle,
|
handle,
|
||||||
stylus,
|
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||||
invertScroll = initialSettings.invertScroll,
|
|
||||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||||
)
|
)
|
||||||
@@ -517,35 +423,14 @@ private fun ExitChordHint(modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The remote-pointer mode cue: while active the remote's keys are remapped (D-pad glides the host
|
|
||||||
* cursor, SELECT clicks), so the overlay both confirms the toggle and teaches the vocabulary.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun RemotePointerHint(modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
"Remote pointer — SELECT click · play/pause right-click · hold SELECT to exit",
|
|
||||||
modifier = modifier
|
|
||||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||||
* onto this view. Two IME models, picked by the host's capabilities:
|
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
|
||||||
* * **Text path** ([textHandle] set — the host advertised `HOST_CAP_TEXT_INPUT`): a real
|
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
|
||||||
* editable [HostTextConnection], so the IME gives autocorrect, gesture typing, non-Latin
|
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
|
||||||
* composition and emoji, all mirrored to the host as committed text + diffs.
|
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
|
||||||
* * **Fallback** (older host): `TYPE_NULL` puts the IME in "dumb keyboard" mode — raw
|
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
|
||||||
* [KeyEvent]s flow through `MainActivity.dispatchKeyEvent` → `Keymap.toVk` → the host, the
|
* `MainActivity.dispatchKeyEvent`).
|
||||||
* exact path a hardware keyboard takes (with the IME-shift wrap documented there).
|
|
||||||
*
|
|
||||||
* Doubles as the pointer-capture grab target: a grab needs a focusable view, and captured-pointer
|
|
||||||
* events are delivered to it (routed to [MouseForwarder.onCapturedPointer] via the listener the
|
|
||||||
* stream screen installs).
|
|
||||||
*/
|
*/
|
||||||
private class KeyCaptureView(context: Context) : View(context) {
|
private class KeyCaptureView(context: Context) : View(context) {
|
||||||
init {
|
init {
|
||||||
@@ -553,171 +438,22 @@ private class KeyCaptureView(context: Context) : View(context) {
|
|||||||
isFocusableInTouchMode = true
|
isFocusableInTouchMode = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The session handle when the host types committed text; `0` = VK-only fallback. */
|
override fun onCheckIsTextEditor(): Boolean = true
|
||||||
var textHandle: Long = 0L
|
|
||||||
|
|
||||||
/** Whether [setImeVisible] last showed the IME — for toggle-style callers (remote pointer). */
|
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||||
var imeShown = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun onCheckIsTextEditor(): Boolean = imeShown
|
|
||||||
|
|
||||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
|
||||||
// Only an editor while the user has SUMMONED the keyboard (gesture / remote toggle).
|
|
||||||
// This view holds focus for the whole stream (it's the capture anchor), and with an
|
|
||||||
// always-live editable connection the IME counts input as active on it — TV IMEs then
|
|
||||||
// pop their UI the moment a PHYSICAL keyboard key arrives. With no connection, hardware
|
|
||||||
// typing stays on the raw dispatchKeyEvent → Keymap → wire path and no keyboard appears.
|
|
||||||
if (!imeShown) return null
|
|
||||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
|
||||||
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_ENTER_ACTION
|
|
||||||
return if (textHandle != 0L) {
|
|
||||||
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
|
|
||||||
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
|
||||||
HostTextConnection(this, textHandle)
|
|
||||||
} else {
|
|
||||||
outAttrs.inputType = InputType.TYPE_NULL
|
outAttrs.inputType = InputType.TYPE_NULL
|
||||||
BaseInputConnection(this, false)
|
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||||
}
|
return BaseInputConnection(this, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setImeVisible(show: Boolean) {
|
fun setImeVisible(show: Boolean) {
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||||
?: return
|
?: return
|
||||||
imeShown = show
|
|
||||||
if (show) {
|
if (show) {
|
||||||
requestFocus()
|
requestFocus()
|
||||||
// The view may already be focused from a null-connection state — restart so the
|
|
||||||
// framework re-queries onCreateInputConnection with the gate now open.
|
|
||||||
imm.restartInput(this)
|
|
||||||
imm.showSoftInput(this, 0)
|
imm.showSoftInput(this, 0)
|
||||||
} else {
|
} else {
|
||||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||||
imm.restartInput(this) // gate closed — drop the editable connection
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* BACK while the summoned keyboard is up: the IME consumes it pre-IME to dismiss itself, so
|
|
||||||
* [setImeVisible] never hears about it — sync the gate here or a stale `imeShown` leaves the
|
|
||||||
* editable connection live and physical typing re-pops the keyboard.
|
|
||||||
*/
|
|
||||||
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
|
|
||||||
if (keyCode == KeyEvent.KEYCODE_BACK && imeShown && event.action == KeyEvent.ACTION_UP) {
|
|
||||||
imeShown = false
|
|
||||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
|
|
||||||
?.restartInput(this)
|
|
||||||
}
|
|
||||||
return super.onKeyPreIme(keyCode, event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IME → host text bridge (the `HOST_CAP_TEXT_INPUT` path): a real **editable** connection, so
|
|
||||||
* the IME runs its full machinery (autocorrect, gesture typing, non-Latin composition), mirrored
|
|
||||||
* to the host as it happens. The one piece of host-side state tracked is *what the host currently
|
|
||||||
* shows of the active composition* ([sentComposition]): composing updates send a common-prefix
|
|
||||||
* diff (backspaces + the new suffix) so corrections materialize live on the host; a commit
|
|
||||||
* settles it. [setComposingRegion] adopts already-committed text as the active composition
|
|
||||||
* (autocorrect-revert / backspace-into-word flows), so the next update diffs against it instead
|
|
||||||
* of retyping. Newlines become Enter taps; [deleteSurroundingText] becomes Backspace/Delete taps.
|
|
||||||
*
|
|
||||||
* Known approximation: diff lengths are counted in Unicode scalars, assuming one host Backspace
|
|
||||||
* deletes one scalar — true for the composition text IMEs actually produce (emoji and other
|
|
||||||
* multi-unit graphemes commit directly rather than composing).
|
|
||||||
*/
|
|
||||||
private class HostTextConnection(
|
|
||||||
view: KeyCaptureView,
|
|
||||||
private val handle: Long,
|
|
||||||
) : BaseInputConnection(view, true) {
|
|
||||||
/** What the host currently shows of the active composition ("" = none). */
|
|
||||||
private var sentComposition = ""
|
|
||||||
|
|
||||||
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
|
|
||||||
retype(text.toString())
|
|
||||||
sentComposition = ""
|
|
||||||
val ok = super.commitText(text, newCursorPosition)
|
|
||||||
trimEditable()
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
|
|
||||||
retype(text.toString())
|
|
||||||
return super.setComposingText(text, newCursorPosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finishComposingText(): Boolean {
|
|
||||||
// The composition text stands as committed — the host already shows it verbatim.
|
|
||||||
sentComposition = ""
|
|
||||||
return super.finishComposingText()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setComposingRegion(start: Int, end: Int): Boolean {
|
|
||||||
val e = editable
|
|
||||||
if (e != null) {
|
|
||||||
val a = start.coerceIn(0, e.length)
|
|
||||||
val b = end.coerceIn(0, e.length)
|
|
||||||
sentComposition = e.subSequence(minOf(a, b), maxOf(a, b)).toString()
|
|
||||||
}
|
|
||||||
return super.setComposingRegion(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
|
||||||
repeat(beforeLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_BACK) }
|
|
||||||
repeat(afterLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_DELETE) }
|
|
||||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun performEditorAction(actionCode: Int): Boolean {
|
|
||||||
tapVk(VK_RETURN)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace the host's view of the composition with [text] via a common-prefix diff. */
|
|
||||||
private fun retype(text: String) {
|
|
||||||
var common = sentComposition.commonPrefixWith(text)
|
|
||||||
// Never split a surrogate pair mid-diff — back off to the pair boundary.
|
|
||||||
if (common.isNotEmpty() && common.last().isHighSurrogate()) {
|
|
||||||
common = common.dropLast(1)
|
|
||||||
}
|
|
||||||
val stale = sentComposition.substring(common.length)
|
|
||||||
repeat(stale.codePointCount(0, stale.length).coerceAtMost(MAX_TAPS)) { tapVk(VK_BACK) }
|
|
||||||
sendText(text.substring(common.length))
|
|
||||||
sentComposition = text
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forward literal text, turning newlines into Enter taps (control chars never ride text). */
|
|
||||||
private fun sendText(s: String) {
|
|
||||||
var chunk = StringBuilder()
|
|
||||||
for (ch in s) {
|
|
||||||
if (ch == '\n') {
|
|
||||||
if (chunk.isNotEmpty()) {
|
|
||||||
NativeBridge.nativeSendText(handle, chunk.toString())
|
|
||||||
chunk = StringBuilder()
|
|
||||||
}
|
|
||||||
tapVk(VK_RETURN)
|
|
||||||
} else {
|
|
||||||
chunk.append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chunk.isNotEmpty()) NativeBridge.nativeSendText(handle, chunk.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tapVk(vk: Int) {
|
|
||||||
NativeBridge.nativeSendKey(handle, vk, true, 0)
|
|
||||||
NativeBridge.nativeSendKey(handle, vk, false, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bound the mirror buffer: once nothing is composing, old text serves no purpose. */
|
|
||||||
private fun trimEditable() {
|
|
||||||
val e = editable ?: return
|
|
||||||
if (getComposingSpanStart(e) == -1 && e.length > 4000) e.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val VK_BACK = 0x08
|
|
||||||
const val VK_RETURN = 0x0D
|
|
||||||
const val VK_DELETE = 0x2E
|
|
||||||
const val MAX_TAPS = 256
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEvent
|
|
||||||
import androidx.compose.ui.input.pointer.PointerType
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
// Wire PEN_* state bits (punktfunk_core::quic::pen; mirrored, asserted by the Rust shim's docs).
|
|
||||||
private const val PEN_IN_RANGE = 1f
|
|
||||||
private const val PEN_TOUCHING = 2f
|
|
||||||
private const val PEN_BARREL1 = 4f
|
|
||||||
private const val PEN_BARREL2 = 8f
|
|
||||||
private const val STRIDE = 10
|
|
||||||
private const val MAX_SAMPLES = 8
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
|
|
||||||
* (`AXIS_TILT`, radians from the surface normal), azimuth (`AXIS_ORIENTATION` — Android's 0 =
|
|
||||||
* "pointed away from the user" IS the wire's north, no offset needed), hover with
|
|
||||||
* `AXIS_DISTANCE`, the eraser tool, both stylus barrel buttons, and historical (coalesced)
|
|
||||||
* samples batched oldest-first for full capture-rate fidelity. Android has no barrel-roll
|
|
||||||
* axis — roll stays unknown on this client.
|
|
||||||
*
|
|
||||||
* Both touch loops call [intercept] first; stylus/eraser pointers are consumed here (against a
|
|
||||||
* pen-capable host) and never reach the finger paths, independent of the touch-input mode.
|
|
||||||
* [heartbeatLoop] implements the ≤100 ms keepalive wire contract: a stationary held stylus is
|
|
||||||
* silent in Android's input pipeline, and the host force-releases a stroke after 200 ms
|
|
||||||
* without samples.
|
|
||||||
*/
|
|
||||||
internal class StylusStream(private val handle: Long) {
|
|
||||||
private var inRange = false
|
|
||||||
private var touching = false
|
|
||||||
private var sawHover = false
|
|
||||||
private val last = FloatArray(STRIDE)
|
|
||||||
private val batch = FloatArray(MAX_SAMPLES * STRIDE)
|
|
||||||
|
|
||||||
init {
|
|
||||||
idle(last)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume the event's stylus pointers into pen samples. Returns true when this event
|
|
||||||
* carried any (the caller's finger/gesture handling must then skip those changes).
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
fun intercept(ev: PointerEvent, size: IntSize): Boolean {
|
|
||||||
val stylusChanges = ev.changes.filter {
|
|
||||||
it.type == PointerType.Stylus || it.type == PointerType.Eraser
|
|
||||||
}
|
|
||||||
if (stylusChanges.isEmpty()) return false
|
|
||||||
stylusChanges.forEach { it.consume() }
|
|
||||||
val me = ev.motionEvent ?: return true
|
|
||||||
if (size.width <= 0 || size.height <= 0) return true
|
|
||||||
// At most one stylus exists — find its pointer index by tool type.
|
|
||||||
val idx = (0 until me.pointerCount).firstOrNull {
|
|
||||||
me.getToolType(it) == MotionEvent.TOOL_TYPE_STYLUS ||
|
|
||||||
me.getToolType(it) == MotionEvent.TOOL_TYPE_ERASER
|
|
||||||
} ?: return true
|
|
||||||
|
|
||||||
when (me.actionMasked) {
|
|
||||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN,
|
|
||||||
MotionEvent.ACTION_MOVE,
|
|
||||||
-> {
|
|
||||||
touching = true
|
|
||||||
inRange = true
|
|
||||||
emitSamples(me, idx, size)
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE -> {
|
|
||||||
sawHover = true
|
|
||||||
inRange = true
|
|
||||||
touching = false
|
|
||||||
emitSamples(me, idx, size)
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> {
|
|
||||||
touching = false
|
|
||||||
// Hover-capable hardware keeps proximity (HOVER_EXIT owns the leave);
|
|
||||||
// anything else leaves range on lift — the host never parks a phantom pen.
|
|
||||||
inRange = sawHover
|
|
||||||
emitSamples(me, idx, size)
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_CANCEL -> release()
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Session/composition teardown: leave range so the host lifts anything still inked. */
|
|
||||||
fun reset() {
|
|
||||||
if (inRange || touching) release()
|
|
||||||
sawHover = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The ≤100 ms keepalive (80 ms leaves headroom for one lost datagram). Runs until
|
|
||||||
* cancelled; resends the last state-full sample while the pen is in range. */
|
|
||||||
suspend fun heartbeatLoop() {
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
delay(80)
|
|
||||||
if (inRange || touching) {
|
|
||||||
last[9] = 0f // dt
|
|
||||||
NativeBridge.nativeSendPen(handle, last, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun release() {
|
|
||||||
touching = false
|
|
||||||
inRange = false
|
|
||||||
last[0] = 0f // state: out of range
|
|
||||||
last[4] = 0f // pressure
|
|
||||||
NativeBridge.nativeSendPen(handle, last, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
|
|
||||||
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
|
|
||||||
val history = minOf(me.historySize, MAX_SAMPLES - 1)
|
|
||||||
var count = 0
|
|
||||||
var prevT = if (history > 0) me.getHistoricalEventTime(0) else me.eventTime
|
|
||||||
for (h in (me.historySize - history) until me.historySize) {
|
|
||||||
val t = me.getHistoricalEventTime(h)
|
|
||||||
fill(
|
|
||||||
batch, count * STRIDE, size,
|
|
||||||
x = me.getHistoricalX(idx, h), y = me.getHistoricalY(idx, h),
|
|
||||||
pressure = me.getHistoricalPressure(idx, h),
|
|
||||||
tiltRad = me.getHistoricalAxisValue(MotionEvent.AXIS_TILT, idx, h),
|
|
||||||
orientRad = me.getHistoricalAxisValue(MotionEvent.AXIS_ORIENTATION, idx, h),
|
|
||||||
distance = me.getHistoricalAxisValue(MotionEvent.AXIS_DISTANCE, idx, h),
|
|
||||||
buttons = me.buttonState, tool = me.getToolType(idx),
|
|
||||||
dtUs = ((t - prevT) * 1000).coerceIn(0, 65535).toFloat(),
|
|
||||||
)
|
|
||||||
prevT = t
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
fill(
|
|
||||||
batch, count * STRIDE, size,
|
|
||||||
x = me.getX(idx), y = me.getY(idx), pressure = me.getPressure(idx),
|
|
||||||
tiltRad = me.getAxisValue(MotionEvent.AXIS_TILT, idx),
|
|
||||||
orientRad = me.getAxisValue(MotionEvent.AXIS_ORIENTATION, idx),
|
|
||||||
distance = me.getAxisValue(MotionEvent.AXIS_DISTANCE, idx),
|
|
||||||
buttons = me.buttonState, tool = me.getToolType(idx),
|
|
||||||
dtUs = ((me.eventTime - prevT) * 1000).coerceIn(0, 65535).toFloat(),
|
|
||||||
)
|
|
||||||
count++
|
|
||||||
batch.copyInto(last, 0, (count - 1) * STRIDE, count * STRIDE)
|
|
||||||
NativeBridge.nativeSendPen(handle, batch, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fill(
|
|
||||||
out: FloatArray,
|
|
||||||
off: Int,
|
|
||||||
size: IntSize,
|
|
||||||
x: Float,
|
|
||||||
y: Float,
|
|
||||||
pressure: Float,
|
|
||||||
tiltRad: Float,
|
|
||||||
orientRad: Float,
|
|
||||||
distance: Float,
|
|
||||||
buttons: Int,
|
|
||||||
tool: Int,
|
|
||||||
dtUs: Float,
|
|
||||||
) {
|
|
||||||
var state = 0f
|
|
||||||
if (inRange || touching) state += PEN_IN_RANGE
|
|
||||||
if (touching) state += PEN_TOUCHING
|
|
||||||
if (buttons and MotionEvent.BUTTON_STYLUS_PRIMARY != 0) state += PEN_BARREL1
|
|
||||||
if (buttons and MotionEvent.BUTTON_STYLUS_SECONDARY != 0) state += PEN_BARREL2
|
|
||||||
out[off + 0] = state
|
|
||||||
out[off + 1] = if (tool == MotionEvent.TOOL_TYPE_ERASER) 1f else 0f
|
|
||||||
out[off + 2] = (x / (size.width - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
|
|
||||||
out[off + 3] = (y / (size.height - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
|
|
||||||
out[off + 4] = if (touching) pressure.coerceIn(0f, 1f) else 0f
|
|
||||||
// AXIS_DISTANCE units are device-arbitrary; 0..1 covers real hardware, and 0 while
|
|
||||||
// hovering legitimately means "at the hover floor".
|
|
||||||
out[off + 5] = if (touching) 0f else distance.coerceIn(0f, 1f)
|
|
||||||
out[off + 6] = Math.toDegrees(tiltRad.toDouble()).toFloat().coerceIn(0f, 90f)
|
|
||||||
// AXIS_ORIENTATION: 0 = pointed away from the user (= wire north), clockwise, −π..π.
|
|
||||||
out[off + 7] = ((Math.toDegrees(orientRad.toDouble()) + 360.0) % 360.0).toFloat()
|
|
||||||
out[off + 8] = -1f // no barrel-roll axis on Android
|
|
||||||
out[off + 9] = dtUs
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun idle(out: FloatArray) {
|
|
||||||
out.fill(0f)
|
|
||||||
out[5] = -1f // distance unknown
|
|
||||||
out[6] = -1f // tilt unknown
|
|
||||||
out[7] = -1f // azimuth unknown
|
|
||||||
out[8] = -1f // roll unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.ui.input.pointer.PointerId
|
import androidx.compose.ui.input.pointer.PointerId
|
||||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
|
||||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||||
import androidx.compose.ui.input.pointer.PointerType
|
|
||||||
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||||
import androidx.compose.ui.input.pointer.positionChanged
|
import androidx.compose.ui.input.pointer.positionChanged
|
||||||
@@ -58,26 +56,7 @@ private const val ACCEL_MAX = 3.0f
|
|||||||
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
|
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
|
||||||
* contact is lifted so nothing stays stuck on the host.
|
* contact is lifted so nothing stays stuck on the host.
|
||||||
*/
|
*/
|
||||||
/** Whether this change belongs to the stylus lane (only when a pen-capable host is live). */
|
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||||
private fun isStylus(c: PointerInputChange, stylus: StylusStream?): Boolean =
|
|
||||||
stylus != null && (c.type == PointerType.Stylus || c.type == PointerType.Eraser)
|
|
||||||
|
|
||||||
/** [awaitFirstDown] with the stylus lane split out: pen events feed [stylus] and never start a
|
|
||||||
* mouse/touch gesture. Toward a pen-less host ([stylus] == null) a stylus stays a finger. */
|
|
||||||
private suspend fun AwaitPointerEventScope.awaitFirstFingerDown(
|
|
||||||
stylus: StylusStream?,
|
|
||||||
): PointerInputChange {
|
|
||||||
while (true) {
|
|
||||||
val ev = awaitPointerEvent()
|
|
||||||
stylus?.intercept(ev, size)
|
|
||||||
val down = ev.changes.firstOrNull {
|
|
||||||
it.changedToDownIgnoreConsumed() && !isStylus(it, stylus)
|
|
||||||
}
|
|
||||||
if (down != null) return down
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, stylus: StylusStream?) {
|
|
||||||
val ids = mutableMapOf<PointerId, Int>()
|
val ids = mutableMapOf<PointerId, Int>()
|
||||||
fun alloc(p: PointerId): Int {
|
fun alloc(p: PointerId): Int {
|
||||||
var id = 0
|
var id = 0
|
||||||
@@ -89,12 +68,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
|
|||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) {
|
while (true) {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
stylus?.intercept(ev, size)
|
|
||||||
val sw = size.width
|
val sw = size.width
|
||||||
val sh = size.height
|
val sh = size.height
|
||||||
if (sw <= 0 || sh <= 0) continue
|
if (sw <= 0 || sh <= 0) continue
|
||||||
for (c in ev.changes) {
|
for (c in ev.changes) {
|
||||||
if (isStylus(c, stylus)) continue // the pen plane owns it
|
|
||||||
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
|
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
|
||||||
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
|
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
|
||||||
when {
|
when {
|
||||||
@@ -121,13 +98,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
|
|||||||
|
|
||||||
internal suspend fun PointerInputScope.streamTouchInput(
|
internal suspend fun PointerInputScope.streamTouchInput(
|
||||||
handle: Long,
|
handle: Long,
|
||||||
stylus: StylusStream?,
|
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
invertScroll: Boolean,
|
|
||||||
onCycleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
onKeyboard: (show: Boolean) -> Unit,
|
onKeyboard: (show: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scrollDir = if (invertScroll) -1 else 1
|
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
var lastTapY = 0f
|
var lastTapY = 0f
|
||||||
@@ -144,7 +118,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstFingerDown(stylus)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
val startX = down.position.x
|
val startX = down.position.x
|
||||||
val startY = down.position.y
|
val startY = down.position.y
|
||||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||||
@@ -181,8 +155,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
stylus?.intercept(ev, size)
|
val pressed = ev.changes.filter { it.pressed }
|
||||||
val pressed = ev.changes.filter { it.pressed && !isStylus(it, stylus) }
|
|
||||||
if (pressed.isEmpty()) {
|
if (pressed.isEmpty()) {
|
||||||
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||||
break
|
break
|
||||||
@@ -211,12 +184,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||||
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||||
if (sy != 0) {
|
if (sy != 0) {
|
||||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir)
|
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||||
prevCy = cy
|
prevCy = cy
|
||||||
moved = true
|
moved = true
|
||||||
}
|
}
|
||||||
if (sx != 0) {
|
if (sx != 0) {
|
||||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir)
|
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||||
prevCx = cx
|
prevCx = cx
|
||||||
moved = true
|
moved = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,17 +106,6 @@ object Keymap {
|
|||||||
KeyEvent.KEYCODE_DPAD_UP -> 0x26
|
KeyEvent.KEYCODE_DPAD_UP -> 0x26
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27
|
KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27
|
||||||
KeyEvent.KEYCODE_DPAD_DOWN -> 0x28
|
KeyEvent.KEYCODE_DPAD_DOWN -> 0x28
|
||||||
// TV-remote SELECT = Enter (a gamepad's press routes via SOURCE_GAMEPAD before this).
|
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER -> 0x0D
|
|
||||||
|
|
||||||
// Consumer/media keys — forwarded to the host while streaming (volume stays local:
|
|
||||||
// MainActivity's pass-through list wins before the map is consulted).
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> 0xB3 // VK_MEDIA_PLAY_PAUSE
|
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> 0xB0 // VK_MEDIA_NEXT_TRACK
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> 0xB1 // VK_MEDIA_PREV_TRACK
|
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> 0xB2 // VK_MEDIA_STOP
|
|
||||||
|
|
||||||
// Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant)
|
// Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant)
|
||||||
KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0
|
KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0
|
||||||
|
|||||||
@@ -287,66 +287,6 @@ object NativeBridge {
|
|||||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the host advertised full-fidelity stylus injection (`HOST_CAP_PEN`) — the gate
|
|
||||||
* for splitting stylus pointers out of the touch path onto the pen plane. False on `0`.
|
|
||||||
*/
|
|
||||||
external fun nativeHostSupportsPen(handle: Long): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One stylus batch of STATE-FULL samples (the pen plane; design/pen-tablet-input.md §7):
|
|
||||||
* [count] × 10 floats, oldest first — `[state, tool, x, y, pressure, distance, tilt_deg,
|
|
||||||
* azimuth_deg, roll_deg, dt_us]`. `state` = the wire in-range/touching/barrel bits; `tool`
|
|
||||||
* 0=pen 1=eraser; x/y/pressure/distance normalized 0..1; distance/tilt/azimuth/roll < 0 =
|
|
||||||
* unknown. Send only when [nativeHostSupportsPen]; repeat the last sample ≤100 ms while the
|
|
||||||
* pen is in range (the host force-releases a silent stroke after 200 ms).
|
|
||||||
*/
|
|
||||||
external fun nativeSendPen(handle: Long, samples: FloatArray, count: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the host advertised committed-text injection (`HOST_CAP_TEXT_INPUT`) — its inject
|
|
||||||
* backend can type Unicode text directly. Picks the real IME `InputConnection` (autocorrect,
|
|
||||||
* gesture typing, non-Latin scripts) over the TYPE_NULL raw-key fallback. False on `0`.
|
|
||||||
*/
|
|
||||||
external fun nativeTextInputSupported(handle: Long): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Committed IME text → one `TextInput` wire event per Unicode scalar, in order. Control
|
|
||||||
* characters are skipped natively (Enter/Backspace ride [nativeSendKey]). Only meaningful
|
|
||||||
* when [nativeTextInputSupported] returned true — older hosts ignore the events.
|
|
||||||
*/
|
|
||||||
external fun nativeSendText(handle: Long, text: String)
|
|
||||||
|
|
||||||
// ---- Shared clipboard (text v1): Kotlin drives ClipboardManager, Rust the protocol ----
|
|
||||||
// Opt-in per session (nativeClipControl). Local copies are announced as lazy offers; bytes
|
|
||||||
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
|
|
||||||
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
|
|
||||||
|
|
||||||
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
|
|
||||||
external fun nativeClipSupported(handle: Long): Boolean
|
|
||||||
|
|
||||||
/** Session-level clipboard opt-in/out; nothing happens until enabled=true crosses. */
|
|
||||||
external fun nativeClipControl(handle: Long, enabled: Boolean)
|
|
||||||
|
|
||||||
/** Announce "this device's clipboard now holds text". [seq]: monotonic, newest wins. */
|
|
||||||
external fun nativeClipOfferText(handle: Long, seq: Int)
|
|
||||||
|
|
||||||
/** Pull the text of the host's offer [seq] → transfer id echoed on "data:"/"error:", or -1. */
|
|
||||||
external fun nativeClipFetchText(handle: Long, seq: Int): Int
|
|
||||||
|
|
||||||
/** Answer a "fetch:" event with the clipboard's current text (the host is pasting). */
|
|
||||||
external fun nativeClipServeText(handle: Long, reqId: Int, text: String)
|
|
||||||
|
|
||||||
/** Abort a clipboard transfer by id (either direction). */
|
|
||||||
external fun nativeClipCancel(handle: Long, id: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Block ≤250 ms for the next clipboard event, as a compact string: `state:<0|1>` ·
|
|
||||||
* `offer:<seq>:<hasText>` · `fetch:<reqId>` · `data:<xferId>:<text>` · `cancel:<id>` ·
|
|
||||||
* `error:<id>:<code>` · `closed` (session gone) — null on timeout. Dedicated poll thread.
|
|
||||||
*/
|
|
||||||
external fun nativeNextClip(handle: Long): String?
|
|
||||||
|
|
||||||
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
||||||
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
||||||
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||||
|
|||||||
@@ -404,14 +404,7 @@ fn feeder_loop(
|
|||||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||||
if stats.enabled() || measure_decode {
|
if stats.enabled() || measure_decode {
|
||||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
let received_ns = now_realtime_ns();
|
||||||
// here would fold the hand-off queue wait into the network latency figure
|
|
||||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
|
||||||
let received_ns = if frame.received_ns > 0 {
|
|
||||||
frame.received_ns as i128
|
|
||||||
} else {
|
|
||||||
now_realtime_ns()
|
|
||||||
};
|
|
||||||
{
|
{
|
||||||
let mut g = in_flight
|
let mut g = in_flight
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -221,13 +221,7 @@ pub(super) fn run_sync(
|
|||||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||||
if stats.enabled() || measure_decode {
|
if stats.enabled() || measure_decode {
|
||||||
// Core reassembly-completion stamp (ABI v9), not the pull instant — see
|
let received_ns = now_realtime_ns();
|
||||||
// async_loop: a pull stamp folds hand-off queue wait into "network".
|
|
||||||
let received_ns = if frame.received_ns > 0 {
|
|
||||||
frame.received_ns as i128
|
|
||||||
} else {
|
|
||||||
now_realtime_ns()
|
|
||||||
};
|
|
||||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||||
if in_flight.len() > IN_FLIGHT_CAP {
|
if in_flight.len() > IN_FLIGHT_CAP {
|
||||||
in_flight.pop_front(); // stale — codec never echoed it back
|
in_flight.pop_front(); // stale — codec never echoed it back
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
//! Shared-clipboard plane (text-only v1): Kotlin drives the Android `ClipboardManager`, these
|
|
||||||
//! shims drive [`punktfunk_core::client::NativeClient`]'s clipboard surface.
|
|
||||||
//!
|
|
||||||
//! Model (mirrors the desktop clients): opt-in via `nativeClipControl(true)`; local copies are
|
|
||||||
//! announced lazily as format-list offers (`nativeClipOfferText`) and the bytes cross only when
|
|
||||||
//! the host pastes (a `fetch` event answered by `nativeClipServeText`); a host copy arrives as an
|
|
||||||
//! `offer` event, which the Kotlin side fetches eagerly (Android's clipboard has no lazy provider
|
|
||||||
//! path worth the complexity) and lands in the system clipboard on the `data` event.
|
|
||||||
//!
|
|
||||||
//! Events cross to Kotlin as compact strings from the blocking `nativeNextClip` poll (drained on
|
|
||||||
//! a dedicated thread, same pattern as `nativeNextRumble`):
|
|
||||||
//! `state:<0|1>` · `offer:<seq>:<has_text 0|1>` · `fetch:<req_id>` · `data:<xfer_id>:<text>` ·
|
|
||||||
//! `cancel:<id>` · `error:<id>:<code>` · `closed` — null on a poll timeout. Non-text fetch
|
|
||||||
//! requests are cancelled natively (only text is ever offered, so they shouldn't occur).
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use jni::objects::{JObject, JString};
|
|
||||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
|
||||||
use jni::JNIEnv;
|
|
||||||
use punktfunk_core::clipboard::ClipEventCore;
|
|
||||||
use punktfunk_core::error::PunktfunkError;
|
|
||||||
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
|
|
||||||
|
|
||||||
use super::SessionHandle;
|
|
||||||
|
|
||||||
/// The portable wire MIME both ends map to their platform text type.
|
|
||||||
const TEXT_MIME: &str = "text/plain;charset=utf-8";
|
|
||||||
|
|
||||||
/// Deref the opaque handle (`0` → `None`).
|
|
||||||
///
|
|
||||||
/// SAFETY: live handle per the nativeConnect/nativeClose contract; every method used is `&self`
|
|
||||||
/// on the `Sync` connector.
|
|
||||||
fn client(handle: jlong) -> Option<&'static SessionHandle> {
|
|
||||||
if handle == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// SAFETY: see the function docs — the Kotlin side guarantees the handle outlives the call.
|
|
||||||
Some(unsafe { &*(handle as *const SessionHandle) })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jboolean {
|
|
||||||
client(handle).map_or(0, |h| {
|
|
||||||
u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
|
||||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
enabled: jboolean,
|
|
||||||
) {
|
|
||||||
if let Some(h) = client(handle) {
|
|
||||||
let _ = h.client.clip_control(enabled != 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
|
|
||||||
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
|
|
||||||
/// counter, newest wins.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
seq: jint,
|
|
||||||
) {
|
|
||||||
if let Some(h) = client(handle) {
|
|
||||||
let _ = h.client.clip_offer(
|
|
||||||
seq as u32,
|
|
||||||
vec![ClipKind {
|
|
||||||
mime: TEXT_MIME.into(),
|
|
||||||
size_hint: 0,
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
|
|
||||||
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or −1.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
seq: jint,
|
|
||||||
) -> jint {
|
|
||||||
client(handle)
|
|
||||||
.and_then(|h| {
|
|
||||||
h.client
|
|
||||||
.clip_fetch(seq as u32, TEXT_MIME.into(), CLIP_FILE_INDEX_NONE)
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.map_or(-1, |xfer| xfer as jint)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
|
|
||||||
/// clipboard's current text (the host is pasting our offer).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
|
|
||||||
mut env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
req_id: jint,
|
|
||||||
text: JString,
|
|
||||||
) {
|
|
||||||
let Some(h) = client(handle) else { return };
|
|
||||||
let Ok(s) = env.get_string(&text) else {
|
|
||||||
let _ = h.client.clip_cancel(req_id as u32);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let _ = h
|
|
||||||
.client
|
|
||||||
.clip_serve(req_id as u32, String::from(s).into_bytes(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
id: jint,
|
|
||||||
) {
|
|
||||||
if let Some(h) = client(handle) {
|
|
||||||
let _ = h.client.clip_cancel(id as u32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextClip(handle)` — block ≤250 ms for the next clipboard event, encoded
|
|
||||||
/// as a compact string (module docs); null on timeout, `"closed"` once the session is gone.
|
|
||||||
/// Call from a dedicated poll thread.
|
|
||||||
///
|
|
||||||
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
|
|
||||||
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
|
|
||||||
/// can never split a UTF-8 sequence.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
|
|
||||||
env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jstring {
|
|
||||||
let Some(h) = client(handle) else {
|
|
||||||
return std::ptr::null_mut();
|
|
||||||
};
|
|
||||||
let msg = match h.client.next_clip(Duration::from_millis(250)) {
|
|
||||||
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
|
|
||||||
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
|
|
||||||
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
|
|
||||||
format!("offer:{seq}:{}", u8::from(has_text))
|
|
||||||
}
|
|
||||||
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
|
|
||||||
if mime.starts_with("text/plain") {
|
|
||||||
format!("fetch:{req_id}")
|
|
||||||
} else {
|
|
||||||
// We only ever offer text; cancel anything else rather than stall the host.
|
|
||||||
let _ = h.client.clip_cancel(req_id);
|
|
||||||
return std::ptr::null_mut();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
|
|
||||||
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
|
|
||||||
}
|
|
||||||
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
|
|
||||||
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
|
|
||||||
Err(PunktfunkError::NoFrame) => return std::ptr::null_mut(),
|
|
||||||
Err(_) => "closed".into(),
|
|
||||||
};
|
|
||||||
env.new_string(msg)
|
|
||||||
.map(|s| s.into_raw())
|
|
||||||
.unwrap_or(std::ptr::null_mut())
|
|
||||||
}
|
|
||||||
@@ -201,9 +201,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
||||||
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
||||||
None,
|
None,
|
||||||
// No non-video caps: this client does not render the host cursor locally (no shape/state
|
|
||||||
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
|
|
||||||
0,
|
|
||||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
|
|||||||
@@ -6,14 +6,11 @@
|
|||||||
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
||||||
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
||||||
|
|
||||||
use jni::objects::{JByteBuffer, JFloatArray, JObject, JString};
|
use jni::objects::{JByteBuffer, JObject};
|
||||||
use jni::sys::{jboolean, jint, jlong};
|
use jni::sys::{jboolean, jint, jlong};
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::quic::{
|
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||||
PenSample, PenTool, RichInput, HID_REPORT_MAX, HOST_CAP_PEN, HOST_CAP_TEXT_INPUT,
|
|
||||||
PEN_ANGLE_UNKNOWN, PEN_BATCH_MAX, PEN_DISTANCE_UNKNOWN, PEN_TILT_UNKNOWN,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::SessionHandle;
|
use super::SessionHandle;
|
||||||
|
|
||||||
@@ -148,132 +145,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
|||||||
send_event(handle, kind, vk as u32, 0, 0, mods as u32);
|
send_event(handle, kind, vk as u32, 0, 0, mods as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
|
|
||||||
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
|
|
||||||
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jboolean {
|
|
||||||
if handle == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
u8::from(h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
|
|
||||||
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
|
|
||||||
/// (design/pen-tablet-input.md §7). `0` handle → false.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
) -> jboolean {
|
|
||||||
if handle == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
u8::from(h.client.host_caps() & HOST_CAP_PEN != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Floats per sample in the `nativeSendPen` flat array.
|
|
||||||
const PEN_JNI_STRIDE: usize = 10;
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
|
|
||||||
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
|
|
||||||
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
|
|
||||||
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
|
|
||||||
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
|
|
||||||
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
|
|
||||||
/// while in range (Kotlin side — see `StylusStream`).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
|
||||||
env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
samples: JFloatArray,
|
|
||||||
count: jint,
|
|
||||||
) {
|
|
||||||
if handle == 0 || count <= 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let count = (count as usize).min(PEN_BATCH_MAX);
|
|
||||||
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
|
|
||||||
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
|
||||||
if env.get_float_array_region(&samples, 0, flat).is_err() {
|
|
||||||
return; // short array — a bridge bug, never worth a crash on the input path
|
|
||||||
}
|
|
||||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
|
||||||
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
|
|
||||||
if !s[2].is_finite() || !s[3].is_finite() {
|
|
||||||
return; // never forward a NaN coordinate
|
|
||||||
}
|
|
||||||
*slot = PenSample {
|
|
||||||
state: s[0] as u8,
|
|
||||||
tool: if s[1] as u8 == 1 {
|
|
||||||
PenTool::Eraser
|
|
||||||
} else {
|
|
||||||
PenTool::Pen
|
|
||||||
},
|
|
||||||
x: s[2].clamp(0.0, 1.0),
|
|
||||||
y: s[3].clamp(0.0, 1.0),
|
|
||||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
|
||||||
distance: if s[5] < 0.0 {
|
|
||||||
PEN_DISTANCE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
|
||||||
},
|
|
||||||
tilt_deg: if s[6] < 0.0 {
|
|
||||||
PEN_TILT_UNKNOWN
|
|
||||||
} else {
|
|
||||||
(s[6].clamp(0.0, 90.0)) as u8
|
|
||||||
},
|
|
||||||
azimuth_deg: if s[7] < 0.0 {
|
|
||||||
PEN_ANGLE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
(s[7] as u16) % 360
|
|
||||||
},
|
|
||||||
roll_deg: if s[8] < 0.0 {
|
|
||||||
PEN_ANGLE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
(s[8] as u16) % 360
|
|
||||||
},
|
|
||||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
||||||
let _ = h.client.send_pen(&batch[..count]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
|
|
||||||
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
|
|
||||||
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
|
|
||||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
|
||||||
mut env: JNIEnv,
|
|
||||||
_this: JObject,
|
|
||||||
handle: jlong,
|
|
||||||
text: JString,
|
|
||||||
) {
|
|
||||||
if handle == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Ok(s) = env.get_string(&text) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for ch in String::from(s).chars().filter(|c| !c.is_control()) {
|
|
||||||
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||||
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
||||||
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||||
|
|
||||||
mod clipboard;
|
|
||||||
mod connect;
|
mod connect;
|
||||||
mod input;
|
mod input;
|
||||||
mod planes;
|
mod planes;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
|
||||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
||||||
|
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -504,7 +504,6 @@
|
|||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -541,7 +540,6 @@
|
|||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -721,7 +719,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -766,7 +764,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 0.9.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -863,15 +861,15 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = PunktfunkKit;
|
productName = PunktfunkKit;
|
||||||
};
|
};
|
||||||
|
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = PunktfunkShared;
|
||||||
|
};
|
||||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||||
productName = SwiftUINavigationTransitions;
|
productName = SwiftUINavigationTransitions;
|
||||||
};
|
};
|
||||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = PunktfunkShared;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = AA000000000000000000000D /* Project object */;
|
rootObject = AA000000000000000000000D /* Project object */;
|
||||||
|
|||||||
@@ -55,11 +55,6 @@
|
|||||||
value = "1"
|
value = "1"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
<EnvironmentVariable
|
|
||||||
key = "MTL_HUD_ENABLED"
|
|
||||||
value = "1"
|
|
||||||
isEnabled = "YES">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ private struct SmallHostView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(.tint)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(host.displayName)
|
Text(host.displayName)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -122,12 +122,12 @@ private struct MediumHostsView: View {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Punktfunk")
|
Text("Punktfunk")
|
||||||
.font(.caption).bold()
|
.font(.caption).bold()
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(.tint)
|
||||||
ForEach(hosts.prefix(4)) { host in
|
ForEach(hosts.prefix(4)) { host in
|
||||||
Link(destination: connectURL(host)) {
|
Link(destination: connectURL(host)) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(.tint)
|
||||||
Text(host.displayName)
|
Text(host.displayName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -184,47 +184,3 @@ private struct EmptyHostView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews (Xcode canvas)
|
|
||||||
//
|
|
||||||
// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩). The widget
|
|
||||||
// `#Preview(as:widget:timeline:)` form feeds sample entries directly — the App-Group store is
|
|
||||||
// never read, so the canvas works without a paired device or saved hosts. The small preview's
|
|
||||||
// second entry shows the empty state one timeline click away.
|
|
||||||
|
|
||||||
private let previewHosts: [StoredHost] = [
|
|
||||||
StoredHost(
|
|
||||||
name: "Studio", address: "192.168.1.20",
|
|
||||||
lastConnected: .now.addingTimeInterval(-40 * 60)),
|
|
||||||
StoredHost(
|
|
||||||
name: "Living Room", address: "192.168.1.30",
|
|
||||||
lastConnected: .now.addingTimeInterval(-26 * 3600)),
|
|
||||||
StoredHost(
|
|
||||||
name: "Workstation", address: "10.0.0.5",
|
|
||||||
lastConnected: .now.addingTimeInterval(-6 * 86400)),
|
|
||||||
]
|
|
||||||
|
|
||||||
#Preview("Small", as: .systemSmall) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
HostsEntry(date: .now, hosts: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Medium", as: .systemMedium) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen circular", as: .accessoryCircular) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
|
|
||||||
HostsWidget()
|
|
||||||
} timeline: {
|
|
||||||
HostsEntry(date: .now, hosts: previewHosts)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,69 +19,43 @@ struct PunktfunkSessionLiveActivity: Widget {
|
|||||||
LockScreenView(context: context)
|
LockScreenView(context: context)
|
||||||
.activitySystemActionForegroundColor(.white)
|
.activitySystemActionForegroundColor(.white)
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
// Island layout (2026-07 rebuild): the EXPANDED island leads with identity (brand
|
|
||||||
// glyph + host), keeps the elapsed clock at the trailing edge, and gives the bottom
|
|
||||||
// region one purposeful row — session status + the live numbers on the left, the
|
|
||||||
// End action on the right. COMPACT shows the one number worth a glance: live
|
|
||||||
// latency while streaming (the sparse ~30 s pushes), the disconnect countdown while
|
|
||||||
// backgrounded, a state glyph otherwise. Brand purple is the identity accent
|
|
||||||
// everywhere `.tint` used to leak system blue.
|
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
HStack(spacing: 6) {
|
Label {
|
||||||
|
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||||
|
} icon: {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
Text(context.attributes.hostName)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
.padding(.leading, 4)
|
.foregroundStyle(.tint)
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
ElapsedClock(startedAt: context.state.startedAt)
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
.font(.subheadline)
|
.font(.caption).monospacedDigit()
|
||||||
|
.frame(maxWidth: 56)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: 64, alignment: .trailing)
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.center) {
|
DynamicIslandExpandedRegion(.center) {
|
||||||
if let title = context.attributes.launchTitle {
|
if let title = context.attributes.launchTitle {
|
||||||
Text(title)
|
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
// The expanded island's height is there to be used: one info row (status
|
VStack(spacing: 6) {
|
||||||
// leading, live numbers trailing — the mode string stays on the Lock
|
Text(context.state.modeLine)
|
||||||
// Screen), then the platform-conventional LARGE full-width action button.
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||||
VStack(spacing: 10) {
|
StageLine(state: context.state)
|
||||||
HStack {
|
EndButton()
|
||||||
StatusLine(state: context.state)
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
StatsLine(state: context.state, showMode: false)
|
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0) // any slack height goes here — button hugs the bottom
|
|
||||||
EndButton(fullWidth: true)
|
|
||||||
}
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
CompactReadout(state: context.state)
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(maxWidth: 44)
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||||
.foregroundStyle(Color.brand)
|
|
||||||
}
|
}
|
||||||
.keylineTint(Color.brand)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,20 +69,21 @@ private struct LockScreenView: View {
|
|||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(.tint)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
ElapsedClock(startedAt: context.state.startedAt)
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||||
.font(.subheadline)
|
.font(.subheadline).monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let title = context.attributes.launchTitle {
|
if let title = context.attributes.launchTitle {
|
||||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
StatusLine(state: context.state)
|
Text(context.state.modeLine)
|
||||||
StatsLine(state: context.state)
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
StageLine(state: context.state)
|
||||||
}
|
}
|
||||||
if context.state.stage == .background {
|
if context.state.stage == .background {
|
||||||
EndButton()
|
EndButton()
|
||||||
@@ -120,155 +95,41 @@ private struct LockScreenView: View {
|
|||||||
|
|
||||||
// MARK: - Shared pieces
|
// MARK: - Shared pieces
|
||||||
|
|
||||||
/// The ticking elapsed-session clock — client-side via `timerInterval`, no per-second push.
|
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||||
private struct ElapsedClock: View {
|
private struct StageLine: View {
|
||||||
let startedAt: Date
|
|
||||||
var body: some View {
|
|
||||||
Text(timerInterval: startedAt...Date.distantFuture, countsDown: false)
|
|
||||||
.monospacedDigit()
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The session's state as a colored dot + label; while backgrounded with a deadline, the label
|
|
||||||
/// IS the countdown. One shared truth for the island bottom and the Lock Screen banner.
|
|
||||||
private struct StatusLine: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 5) {
|
|
||||||
Circle()
|
|
||||||
.fill(color)
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
label
|
|
||||||
.font(.caption2.weight(.medium))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var color: Color {
|
|
||||||
switch state.stage {
|
|
||||||
case .streaming: return .green
|
|
||||||
case .background: return .orange
|
|
||||||
case .reconnecting: return .yellow
|
|
||||||
case .ending: return .secondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private var label: some View {
|
|
||||||
switch state.stage {
|
|
||||||
case .streaming:
|
|
||||||
Text("Streaming")
|
|
||||||
case .background:
|
|
||||||
if let deadline = state.backgroundDeadline {
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
Text("Background · ends in")
|
|
||||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("Running in background")
|
|
||||||
}
|
|
||||||
case .reconnecting:
|
|
||||||
Text("Reconnecting…")
|
|
||||||
case .ending:
|
|
||||||
Text("Session ended")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The live numbers, one quiet line: latency and bitrate (the sparse ~30 s pushes) ahead of the
|
|
||||||
/// mode (`showMode` false on the island, where the row shares space with the status line).
|
|
||||||
/// Anything unreported simply doesn't appear.
|
|
||||||
private struct StatsLine: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
|
||||||
var showMode = true
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if let ms = state.latencyMs {
|
|
||||||
stat("bolt.fill", "\(ms) ms")
|
|
||||||
}
|
|
||||||
if let mbps = state.mbps {
|
|
||||||
stat("arrow.down", String(format: "%.0f Mb/s", mbps))
|
|
||||||
}
|
|
||||||
if showMode {
|
|
||||||
Text(state.modeLine)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stat(_ icon: String, _ text: String) -> some View {
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 8, weight: .semibold))
|
|
||||||
Text(text)
|
|
||||||
.font(.caption2)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The compact trailing readout — the island's one glanceable number. Streaming: the live
|
|
||||||
/// latency once the app has reported one, the elapsed clock until then. Backgrounded: the
|
|
||||||
/// auto-disconnect countdown. Off-nominal stages show a state glyph instead of a number.
|
|
||||||
private struct CompactReadout: View {
|
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch state.stage {
|
switch state.stage {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
if let ms = state.latencyMs {
|
EmptyView()
|
||||||
Text("\(ms) ms")
|
|
||||||
.font(.caption2.weight(.medium))
|
|
||||||
.monospacedDigit()
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
} else {
|
|
||||||
ElapsedClock(startedAt: state.startedAt)
|
|
||||||
.font(.caption2)
|
|
||||||
.frame(maxWidth: 48)
|
|
||||||
}
|
|
||||||
case .background:
|
case .background:
|
||||||
if let deadline = state.backgroundDeadline {
|
if let deadline = state.backgroundDeadline {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text("Keeps running for")
|
||||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||||
.font(.caption2)
|
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.multilineTextAlignment(.trailing)
|
}
|
||||||
.frame(maxWidth: 48)
|
.font(.caption2)
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "moon.fill").foregroundStyle(.orange)
|
badge("Running in background", .orange)
|
||||||
}
|
}
|
||||||
case .reconnecting:
|
case .reconnecting:
|
||||||
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
|
badge("Reconnecting…", .yellow)
|
||||||
case .ending:
|
case .ending:
|
||||||
Image(systemName: "stop.fill").foregroundStyle(.secondary)
|
badge("Session ended", .secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func badge(_ text: String, _ color: Color) -> some View {
|
||||||
|
Text(text).font(.caption2).foregroundStyle(color)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||||
/// `fullWidth` is the expanded island's large bottom action (the platform convention there);
|
|
||||||
/// the compact form remains the Lock Screen banner's trailing button while backgrounded.
|
|
||||||
private struct EndButton: View {
|
private struct EndButton: View {
|
||||||
var fullWidth = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if fullWidth {
|
|
||||||
Button(intent: EndStreamIntent()) {
|
|
||||||
Label("End Session", systemImage: "stop.fill")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
.tint(.red)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
} else {
|
|
||||||
Button(intent: EndStreamIntent()) {
|
Button(intent: EndStreamIntent()) {
|
||||||
Label("End", systemImage: "stop.fill")
|
Label("End", systemImage: "stop.fill")
|
||||||
.font(.caption).bold()
|
.font(.caption).bold()
|
||||||
@@ -277,80 +138,3 @@ private struct EndButton: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Previews (Xcode canvas)
|
|
||||||
//
|
|
||||||
// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩) — the activity
|
|
||||||
// `#Preview(as:using:)` form renders every surface WITHOUT running the app or starting a real
|
|
||||||
// Activity: `.content` is the Lock Screen banner, `.dynamicIsland(.expanded/.compact/.minimal)`
|
|
||||||
// the island states (canvas device must be a Dynamic Island phone for those). Each listed
|
|
||||||
// content state becomes a frame in the canvas timeline strip, so all four session stages are one
|
|
||||||
// click apart. Sample state lives here (fileprivate), never in PunktfunkShared.
|
|
||||||
|
|
||||||
extension PunktfunkSessionAttributes {
|
|
||||||
fileprivate static var preview: PunktfunkSessionAttributes {
|
|
||||||
PunktfunkSessionAttributes(hostID: UUID(), hostName: "Studio", launchTitle: "Hades II")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PunktfunkSessionAttributes.ContentState {
|
|
||||||
fileprivate static var streaming: Self {
|
|
||||||
.init(
|
|
||||||
stage: .streaming, startedAt: .now.addingTimeInterval(-754),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR", latencyMs: 8, mbps: 84.2)
|
|
||||||
}
|
|
||||||
fileprivate static var backgrounded: Self {
|
|
||||||
.init(
|
|
||||||
stage: .background, startedAt: .now.addingTimeInterval(-1975),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR",
|
|
||||||
backgroundDeadline: .now.addingTimeInterval(9 * 60))
|
|
||||||
}
|
|
||||||
fileprivate static var reconnecting: Self {
|
|
||||||
.init(
|
|
||||||
stage: .reconnecting, startedAt: .now.addingTimeInterval(-754),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR")
|
|
||||||
}
|
|
||||||
fileprivate static var ended: Self {
|
|
||||||
.init(
|
|
||||||
stage: .ending, startedAt: .now.addingTimeInterval(-3541),
|
|
||||||
modeLine: "2752×2064 @120 · HEVC · HDR")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Lock Screen", as: .content, using: PunktfunkSessionAttributes.preview) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
PunktfunkSessionAttributes.ContentState.backgrounded
|
|
||||||
PunktfunkSessionAttributes.ContentState.reconnecting
|
|
||||||
PunktfunkSessionAttributes.ContentState.ended
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island expanded", as: .dynamicIsland(.expanded),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
PunktfunkSessionAttributes.ContentState.backgrounded
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island compact", as: .dynamicIsland(.compact),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(
|
|
||||||
"Island minimal", as: .dynamicIsland(.minimal),
|
|
||||||
using: PunktfunkSessionAttributes.preview
|
|
||||||
) {
|
|
||||||
PunktfunkSessionLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
PunktfunkSessionAttributes.ContentState.streaming
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ struct ContentView: View {
|
|||||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||||
@State private var showShortcutHint = false
|
@State private var showShortcutHint = false
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
|
||||||
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
|
|
||||||
/// — the disc must LEAVE the hierarchy so nothing composites over the metal layer).
|
|
||||||
@State private var showTouchExit = false
|
|
||||||
#endif
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -198,9 +193,6 @@ struct ContentView: View {
|
|||||||
#if os(macOS) || os(tvOS)
|
#if os(macOS) || os(tvOS)
|
||||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
|
||||||
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
|
|
||||||
#endif
|
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
// plus the accent ring on the most recent host).
|
// plus the accent ring on the most recent host).
|
||||||
guard let host = model.activeHost else { break }
|
guard let host = model.activeHost else { break }
|
||||||
@@ -500,19 +492,12 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
||||||
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
||||||
// steering and the next click still reaches the stream. Mounted ONLY while a
|
// steering and the next click still reaches the stream.
|
||||||
// resize is live: resident structure above the CAMetalLayer is what the stage-4
|
|
||||||
// direct-to-display hunt is eliminating — composited presents reach glass a full
|
|
||||||
// refresh later. The enter/exit fade rides the call-site transition + the
|
|
||||||
// .animation(value: resizing) below (the view's internal `if active` fade can't
|
|
||||||
// run when the whole view unmounts).
|
|
||||||
.overlay {
|
.overlay {
|
||||||
if pendingFingerprint == nil, model.resizing {
|
if pendingFingerprint == nil {
|
||||||
ResizeIndicatorView(active: true)
|
ResizeIndicatorView(active: model.resizing)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
|
||||||
if let fp = pendingFingerprint {
|
if let fp = pendingFingerprint {
|
||||||
TrustCardView(
|
TrustCardView(
|
||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
@@ -579,21 +564,13 @@ struct ContentView: View {
|
|||||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||||
},
|
},
|
||||||
onFrame: { [meter = model.meter, latency = model.latency,
|
onFrame: { [meter = model.meter, latency = model.latency,
|
||||||
split = model.latencySplit, queue = model.clientQueue,
|
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||||
offset = conn.clockOffsetNs] au in
|
|
||||||
meter.note(byteCount: au.data.count)
|
meter.note(byteCount: au.data.count)
|
||||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
// host/network split — drained by the 1 s stats tick).
|
||||||
// the core's reassembly stamp (ABI v9), so the split's network term no
|
|
||||||
// longer contains the client-queue wait...
|
|
||||||
split.recordReceipt(
|
split.recordReceipt(
|
||||||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||||||
// ...which is measured as its own term instead (receipt→pull, both
|
|
||||||
// client-local).
|
|
||||||
queue.record(
|
|
||||||
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
|
|
||||||
offsetNs: 0)
|
|
||||||
},
|
},
|
||||||
onSessionEnd: { [weak model] in
|
onSessionEnd: { [weak model] in
|
||||||
Task { @MainActor in model?.sessionEnded() }
|
Task { @MainActor in model?.sessionEnded() }
|
||||||
@@ -610,8 +587,7 @@ struct ContentView: View {
|
|||||||
},
|
},
|
||||||
endToEndMeter: model.endToEnd,
|
endToEndMeter: model.endToEnd,
|
||||||
decodeMeter: model.decodeStage,
|
decodeMeter: model.decodeStage,
|
||||||
displayMeter: model.displayStage,
|
displayMeter: model.displayStage
|
||||||
presentFloorMeter: model.presentFloor
|
|
||||||
)
|
)
|
||||||
.overlay(alignment: placement.alignment) {
|
.overlay(alignment: placement.alignment) {
|
||||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||||||
@@ -660,27 +636,18 @@ struct ContentView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||||
// keep a minimal touch exit in a corner. It rides a material disc (like the
|
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||||
// HUD) so the glyph stays legible over a bright frame.
|
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||||
//
|
// sole touch disconnect path in those tiers.
|
||||||
// In the OFF tier the disc shows for the first 8 s of a session, then leaves
|
|
||||||
// the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay
|
|
||||||
// above the stream — a glass one doubly so, its blur SAMPLES the video layer —
|
|
||||||
// forces the CAMetalLayer through the compositor, costing ~a refresh of display
|
|
||||||
// latency and blocking direct-to-display promotion. Off is the immersive/
|
|
||||||
// measurement tier; after the fade, touch-only exits are backgrounding the app
|
|
||||||
// or re-enabling the stats overlay. Compact keeps its disc permanently — that
|
|
||||||
// tier composites a HUD pill anyway, so hiding the exit there wins nothing.
|
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if captureEnabled,
|
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
|
||||||
Button { model.disconnect() } label: {
|
Button { model.disconnect() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.headline.weight(.semibold))
|
.font(.headline.weight(.semibold))
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
// Floating glass disc over the frame (26+, material fallback).
|
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||||
// interactive: the disc IS the tap target, so the glass reacts
|
// over the frame (26+, material fallback). interactive: the disc
|
||||||
// to press.
|
// IS the tap target, so the glass reacts to press.
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
// Match the hit region to the visible disc so every tap also
|
// Match the hit region to the visible disc so every tap also
|
||||||
// triggers the interactive-glass press highlight.
|
// triggers the interactive-glass press highlight.
|
||||||
@@ -689,12 +656,6 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.accessibilityLabel("Disconnect")
|
.accessibilityLabel("Disconnect")
|
||||||
.transition(.opacity)
|
|
||||||
.task {
|
|
||||||
guard statsVerbosity == .off else { return }
|
|
||||||
try? await Task.sleep(for: .seconds(8))
|
|
||||||
withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -102,26 +102,6 @@ final class SessionModel: ObservableObject {
|
|||||||
@Published var decodeValid = false
|
@Published var decodeValid = false
|
||||||
@Published var displayP50Ms = 0.0
|
@Published var displayP50Ms = 0.0
|
||||||
@Published var displayValid = false
|
@Published var displayValid = false
|
||||||
/// Client-queue wait: core reassembly receipt → the pump's pull (`AccessUnit.pulledNs −
|
|
||||||
/// receivedNs`, ABI v9 receipt split — the 2026-07 two-pair investigation). ~0 on a healthy
|
|
||||||
/// stream; a persistent value is a client-side standing backlog that used to hide inside
|
|
||||||
/// "network". Shown in the detailed tier only when it says something (≥ ~2 ms).
|
|
||||||
@Published var clientQueueP50Ms = 0.0
|
|
||||||
@Published var clientQueueValid = false
|
|
||||||
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
|
|
||||||
/// engine's vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
|
||||||
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
|
|
||||||
/// the shown display/e2e so the numbers describe Punktfunk's own pipeline; raw values stay
|
|
||||||
/// in the detailed tier + the stats log. Invalid (0) on macOS arrival (sync-off ≈ no floor)
|
|
||||||
/// and under stage-1.
|
|
||||||
@Published var osFloorP50Ms = 0.0
|
|
||||||
@Published var osFloorValid = false
|
|
||||||
|
|
||||||
/// The floor-shaved values every HUD tier displays (raw − floor, never below 0). Identical
|
|
||||||
/// to the raw values whenever no floor is measured.
|
|
||||||
var displayAdjP50Ms: Double { max(0, displayP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
var endToEndAdjP50Ms: Double { max(0, endToEndP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
var endToEndAdjP95Ms: Double { max(0, endToEndP95Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
|
||||||
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
||||||
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||||
@Published var lostFrames = 0
|
@Published var lostFrames = 0
|
||||||
@@ -153,12 +133,6 @@ final class SessionModel: ObservableObject {
|
|||||||
let endToEnd = LatencyMeter()
|
let endToEnd = LatencyMeter()
|
||||||
let decodeStage = LatencyMeter()
|
let decodeStage = LatencyMeter()
|
||||||
let displayStage = LatencyMeter()
|
let displayStage = LatencyMeter()
|
||||||
/// Client-queue sampler (see `clientQueueP50Ms`) — fed per AU by the stream view's onFrame,
|
|
||||||
/// drained by the same 1 s tick as the stage meters.
|
|
||||||
let clientQueue = LatencyMeter()
|
|
||||||
/// The OS present floor sampler (see `osFloorP50Ms`) — fed one sample per display-link
|
|
||||||
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
|
|
||||||
let presentFloor = LatencyMeter()
|
|
||||||
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||||||
private var lastFramesDropped: UInt64 = 0
|
private var lastFramesDropped: UInt64 = 0
|
||||||
private var statsTimer: Timer?
|
private var statsTimer: Timer?
|
||||||
@@ -302,26 +276,13 @@ final class SessionModel: ObservableObject {
|
|||||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||||
}
|
}
|
||||||
// Cursor channel (remote-desktop-sweep M2, macOS): sessions STARTING in the desktop
|
|
||||||
// mouse model advertise local cursor rendering — the host then stops compositing
|
|
||||||
// the pointer and forwards shape/state, which StreamView draws as the real
|
|
||||||
// NSCursor. Capture-mode sessions keep today's composited pointer.
|
|
||||||
#if os(macOS)
|
|
||||||
let clientCaps: UInt8 =
|
|
||||||
(MouseInputMode(
|
|
||||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? "")
|
|
||||||
?? .capture) == .desktop ? 0x01 : 0
|
|
||||||
#else
|
|
||||||
let clientCaps: UInt8 = 0
|
|
||||||
#endif
|
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
audioChannels: audioChannels,
|
audioChannels: audioChannels,
|
||||||
videoCodecs: videoCodecs, preferredCodec: preferredCodec,
|
videoCodecs: videoCodecs, preferredCodec: preferredCodec, launchID: launchID,
|
||||||
clientCaps: clientCaps, launchID: launchID,
|
|
||||||
// Delegated approval: the host holds this connect open until the operator approves
|
// Delegated approval: the host holds this connect open until the operator approves
|
||||||
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||||
// connects keep the snappy default.
|
// connects keep the snappy default.
|
||||||
@@ -511,8 +472,6 @@ final class SessionModel: ObservableObject {
|
|||||||
endToEndValid = false
|
endToEndValid = false
|
||||||
decodeValid = false
|
decodeValid = false
|
||||||
displayValid = false
|
displayValid = false
|
||||||
clientQueueValid = false
|
|
||||||
osFloorValid = false
|
|
||||||
lostFrames = 0
|
lostFrames = 0
|
||||||
lostPct = 0
|
lostPct = 0
|
||||||
mouseCaptured = false
|
mouseCaptured = false
|
||||||
@@ -696,34 +655,15 @@ final class SessionModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.displayValid = false
|
self.displayValid = false
|
||||||
}
|
}
|
||||||
if let f = self.presentFloor.drain() {
|
|
||||||
self.osFloorP50Ms = f.p50Ms
|
|
||||||
self.osFloorValid = true
|
|
||||||
} else {
|
|
||||||
self.osFloorValid = false
|
|
||||||
}
|
|
||||||
if let q = self.clientQueue.drain() {
|
|
||||||
self.clientQueueP50Ms = q.p50Ms
|
|
||||||
self.clientQueueValid = true
|
|
||||||
} else {
|
|
||||||
self.clientQueueValid = false
|
|
||||||
}
|
|
||||||
// Mirror the window to the unified log (see statsLog) — one line per second,
|
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||||
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||||||
// upstream (host capture/encode or the network).
|
// upstream (host capture/encode or the network).
|
||||||
if frames > 0 {
|
if frames > 0 {
|
||||||
// The classic fields stay RAW (cross-session comparability with every log
|
|
||||||
// captured before the 2026-07 floor policy); the appended trio carries the
|
|
||||||
// measured OS present floor and the floor-shaved values the HUD displays.
|
|
||||||
let line = String(
|
let line = String(
|
||||||
// Swift Int is 64-bit → %lld, NOT %d (which is a 32-bit C int); macOS 26's
|
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||||
// strict String(format:) validator rejects the %d/Int mismatch and drops
|
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
|
||||||
// the whole line (a cascade error that also mis-blames the float args).
|
|
||||||
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
|
||||||
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
|
|
||||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
|
||||||
frames,
|
frames,
|
||||||
displayWindow?.count ?? 0,
|
displayWindow?.count ?? 0,
|
||||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||||
@@ -731,11 +671,7 @@ final class SessionModel: ObservableObject {
|
|||||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||||
self.decodeValid ? self.decodeP50Ms : -1,
|
self.decodeValid ? self.decodeP50Ms : -1,
|
||||||
self.displayValid ? self.displayP50Ms : -1,
|
self.displayValid ? self.displayP50Ms : -1,
|
||||||
lost,
|
lost)
|
||||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
|
||||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
|
||||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
|
||||||
self.clientQueueValid ? self.clientQueueP50Ms : -1)
|
|
||||||
statsLog.info("\(line, privacy: .public)")
|
statsLog.info("\(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ struct StreamHUDView: View {
|
|||||||
private var compactLine: String {
|
private var compactLine: String {
|
||||||
var parts = ["\(model.fps) fps"]
|
var parts = ["\(model.fps) fps"]
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
|
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||||
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
|
|
||||||
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
|
|
||||||
} else if model.hostNetworkValid {
|
} else if model.hostNetworkValid {
|
||||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||||
}
|
}
|
||||||
@@ -88,46 +86,24 @@ struct StreamHUDView: View {
|
|||||||
}
|
}
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||||
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
// corrected) — "(same-host clock)" when the host didn't answer the skew handshake.
|
||||||
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
|
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||||
// pipeline's fixed depth is excluded so the number describes Punktfunk's own
|
|
||||||
// latency; the detailed tier shows the excluded floor as its own line, and the
|
|
||||||
// stats log keeps the raw values.
|
|
||||||
Text("end-to-end \(model.endToEndAdjP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndAdjP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The equation (detailed tier only): the stages tiling the headline interval
|
// The equation (detailed tier only): the stages tiling the headline interval
|
||||||
// (per-window p50s — they only approximately sum to the directly-measured
|
// (per-window p50s — they only approximately sum to the directly-measured
|
||||||
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
||||||
// into host + network (phase 2); an old host keeps the combined term. The
|
// into host + network (phase 2); an old host keeps the combined term.
|
||||||
// display term is floor-shaved like the headline, so the equation still sums.
|
|
||||||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||||
if model.splitValid {
|
if model.splitValid {
|
||||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if model.osFloorValid {
|
|
||||||
// The excluded OS term, kept visible for honesty: display-pipeline
|
|
||||||
// minimum no client can pace under (~2 refresh intervals composited).
|
|
||||||
Text("os present +\(model.osFloorP50Ms, specifier: "%.1f") excluded (display pipeline minimum)")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
// Client-queue wait (reassembly receipt → decode pull, ABI v9 split): ~0 on
|
|
||||||
// a healthy stream and hidden as noise; shown from 2 ms — a persistent value
|
|
||||||
// is a client-side standing backlog that pre-split builds displayed as
|
|
||||||
// "network" (the 2026-07 two-pair plateau). The core's standing-latency
|
|
||||||
// bleed logs alongside when it acts on the same state.
|
|
||||||
if model.clientQueueValid && model.clientQueueP50Ms >= 2 {
|
|
||||||
Text("client queue +\(model.clientQueueP50Ms, specifier: "%.1f") (receive backlog — standing if it persists)")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if model.hostNetworkValid {
|
} else if model.hostNetworkValid {
|
||||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||||
|
|||||||
@@ -40,12 +40,7 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||||
SettingsOptions.presentPriorityDefault
|
|
||||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
|
||||||
#if os(macOS)
|
|
||||||
@AppStorage(DefaultsKey.windowedSafePresent) private var windowedSafePresent = true
|
|
||||||
#endif
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||||
#endif
|
#endif
|
||||||
@@ -293,19 +288,11 @@ struct GamepadSettingsView: View {
|
|||||||
+ "hardware decode.",
|
+ "hardware decode.",
|
||||||
value: $enable444),
|
value: $enable444),
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
||||||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
||||||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
||||||
+ "from the next session.",
|
options: SettingsOptions.presenters, current: presenter
|
||||||
options: SettingsOptions.presentPriorities, current: presentPriority
|
) { presenter = $0 },
|
||||||
) { presentPriority = $0 },
|
|
||||||
choiceRow(
|
|
||||||
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
|
|
||||||
detail: "How many frames Smoothness holds — each adds about a refresh of "
|
|
||||||
+ "display latency and absorbs about a refresh of jitter. Only applies "
|
|
||||||
+ "when prioritizing smoothness.",
|
|
||||||
options: SettingsOptions.smoothBuffers(refreshHz: hz), current: smoothBuffer
|
|
||||||
) { smoothBuffer = $0 },
|
|
||||||
|
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||||
@@ -348,22 +335,6 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||||
value: $gamepadUIEnabled),
|
value: $gamepadUIEnabled),
|
||||||
]
|
]
|
||||||
#if os(macOS)
|
|
||||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
|
||||||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
|
||||||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
|
||||||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
|
||||||
list.insert(
|
|
||||||
toggleRow(
|
|
||||||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
|
||||||
label: "Safe windowed presentation",
|
|
||||||
detail: "Windowed streams present in step with the compositor — avoids a "
|
|
||||||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
|
||||||
+ "latency cost. Fullscreen always uses the fastest path.",
|
|
||||||
value: $windowedSafePresent),
|
|
||||||
at: at + 1)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import SwiftUI
|
|||||||
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||||
/// private) so the screenshot harness can open SettingsView on a specific category.
|
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||||
// The 2026-07 revamp's map: General = session/app behavior, Display = everything about the
|
case general, display, audio, controllers, advanced, about
|
||||||
// picture (resolution, quality, presentation, host output), Input = touch/keyboard/mouse.
|
|
||||||
// The old Advanced tab dissolved (its lone game-library toggle lives in General now).
|
|
||||||
case general, display, input, audio, controllers, about
|
|
||||||
|
|
||||||
var id: Self { self }
|
var id: Self { self }
|
||||||
|
|
||||||
@@ -19,9 +16,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .general: return "General"
|
case .general: return "General"
|
||||||
case .display: return "Display"
|
case .display: return "Display"
|
||||||
case .input: return "Input"
|
|
||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
case .controllers: return "Controllers"
|
case .controllers: return "Controllers"
|
||||||
|
case .advanced: return "Advanced"
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,9 +27,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .general: return "gearshape"
|
case .general: return "gearshape"
|
||||||
case .display: return "display"
|
case .display: return "display"
|
||||||
case .input: return "keyboard"
|
|
||||||
case .audio: return "speaker.wave.2"
|
case .audio: return "speaker.wave.2"
|
||||||
case .controllers: return "gamecontroller"
|
case .controllers: return "gamecontroller"
|
||||||
|
case .advanced: return "slider.horizontal.3"
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,31 +37,28 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
|
||||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
static var presenters: [(label: String, tag: String)] {
|
||||||
/// PUNKTFUNK_PRESENTER debug env lever.
|
var options: [(label: String, tag: String)] = [
|
||||||
static let presentPriorities: [(label: String, tag: String)] = [
|
("Stage 2", "stage2"),
|
||||||
("Lowest latency", "latency"),
|
("Stage 3", "stage3"),
|
||||||
("Smoothness", "smooth"),
|
|
||||||
]
|
]
|
||||||
static let presentPriorityDefault = "latency"
|
#if DEBUG
|
||||||
|
options.append(("Stage 1 (debug)", "stage1"))
|
||||||
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
|
#endif
|
||||||
/// frames). The ms hints derive from the chosen refresh setting — each buffered frame costs
|
return options
|
||||||
/// about one refresh interval of display latency and absorbs about one interval of arrival
|
|
||||||
/// jitter.
|
|
||||||
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
|
|
||||||
let periodMs = 1000.0 / Double(max(24, refreshHz))
|
|
||||||
func hint(_ frames: Int) -> String {
|
|
||||||
String(format: "+%.0f ms", Double(frames) * periodMs)
|
|
||||||
}
|
}
|
||||||
return [
|
|
||||||
("Automatic", 0),
|
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS and
|
||||||
("1 frame (\(hint(1)))", 1),
|
/// iOS/iPadOS run glass pacing, macOS arrival). Views seed their @AppStorage display from
|
||||||
("2 frames (\(hint(2)))", 2),
|
/// this so an untouched picker shows what actually runs.
|
||||||
("3 frames (\(hint(3)))", 3),
|
static var presenterDefault: String {
|
||||||
]
|
#if os(macOS)
|
||||||
|
"stage2"
|
||||||
|
#else
|
||||||
|
"stage3"
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||||
// composed by the per-platform bodies in SettingsView.swift.
|
// composed by the per-platform bodies in SettingsView.swift.
|
||||||
//
|
|
||||||
// 2026-07 settings revamp: every field carries its explanation DIRECTLY under it in the same
|
|
||||||
// cell (the `described` helper in SettingsView+Support) — the old per-section footer paragraphs
|
|
||||||
// collected several fields' explanations into one blob nobody could match back to its row.
|
|
||||||
// Where a picker's meaning depends on the selection (touch mode, modifier layout, prioritize),
|
|
||||||
// the description is DYNAMIC — it explains the current choice. The only footers left are the
|
|
||||||
// one-line "Applies from the next session." form notes.
|
|
||||||
//
|
|
||||||
// Category map (SettingsCategory): General = session/app behavior, Display = everything about
|
|
||||||
// the picture (resolution lives HERE), Input = touch/keyboard/mouse, Audio, Controllers, About.
|
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import CoreHaptics
|
import CoreHaptics
|
||||||
@@ -18,23 +8,18 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
// MARK: - Display: Resolution
|
// MARK: - Sections (shared)
|
||||||
|
|
||||||
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
||||||
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
||||||
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
||||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||||
@ViewBuilder var resolutionSection: some View {
|
@ViewBuilder var streamModeSection: some View {
|
||||||
Section("Resolution") {
|
Section {
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
||||||
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
||||||
described(matchWindow
|
|
||||||
? "The host resizes its output to follow this window — the picture stays "
|
|
||||||
+ "pixel-exact (1:1) through every resize."
|
|
||||||
: "Stream at the fixed mode below; a window at a different size shows it scaled.") {
|
|
||||||
Toggle("Match window", isOn: $matchWindow)
|
Toggle("Match window", isOn: $matchWindow)
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
iosResolutionWheel
|
iosResolutionWheel
|
||||||
@@ -47,19 +32,75 @@ extension SettingsView {
|
|||||||
TextField("", value: $height, format: .number.grouping(.never))
|
TextField("", value: $height, format: .number.grouping(.never))
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
described("The host drives a real virtual output at exactly this size and refresh — "
|
|
||||||
+ "true pixels, no scaling.") {
|
|
||||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||||
}
|
|
||||||
LabeledContent("") {
|
LabeledContent("") {
|
||||||
Button("Use this display's mode") { fillFromMainScreen() }
|
Button("Use this display's mode") { fillFromMainScreen() }
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
|
renderScaleRow
|
||||||
|
bitrateRows
|
||||||
|
#endif
|
||||||
|
} header: {
|
||||||
|
Text("Stream mode")
|
||||||
|
} footer: {
|
||||||
|
Text(matchWindow
|
||||||
|
? "The stream follows this window — the host resizes its virtual output to match "
|
||||||
|
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
|
||||||
|
+ "\(Self.bitrateFooter)"
|
||||||
|
: "The host creates a virtual output at exactly this mode — native resolution, but "
|
||||||
|
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
|
||||||
|
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
|
||||||
|
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
|
||||||
|
@ViewBuilder var renderScaleRow: some View {
|
||||||
|
Picker("Render scale", selection: $renderScale) {
|
||||||
|
ForEach(RenderScale.presets, id: \.self) { scale in
|
||||||
|
Text(RenderScale.label(scale)).tag(scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The concrete host resolution makes the cost legible. Only meaningful for the explicit mode
|
||||||
|
// (match-window derives the base from the live window, not these fields).
|
||||||
|
if renderScale != 1.0, !matchWindow {
|
||||||
|
let mode = RenderScale.apply(
|
||||||
|
baseWidth: width, baseHeight: height,
|
||||||
|
scale: renderScale,
|
||||||
|
maxDimension: RenderScale.maxDimension(codec: codec))
|
||||||
|
Text("Host renders \(Int(mode.width))×\(Int(mode.height)); this device downscales it to your display.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard & mouse forwarding — applies wherever a hardware keyboard/mouse drives the stream
|
||||||
|
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
||||||
|
@ViewBuilder var inputSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Modifier keys", selection: $modifierLayout) {
|
||||||
|
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||||
|
Text(layout.label).tag(layout.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("Invert scroll direction", isOn: $invertScroll)
|
||||||
|
} header: {
|
||||||
|
Text("Keyboard & mouse")
|
||||||
|
} footer: {
|
||||||
|
Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail
|
||||||
|
+ " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// MARK: - Display: Resolution (iOS wheel)
|
// MARK: - Stream mode (iOS wheel)
|
||||||
|
|
||||||
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
||||||
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
||||||
@@ -78,11 +119,6 @@ extension SettingsView {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.wheel)
|
.pickerStyle(.wheel)
|
||||||
.frame(maxHeight: 140)
|
.frame(maxHeight: 140)
|
||||||
Text("The host drives a real output at exactly this mode — true pixels, no scaling.")
|
|
||||||
.font(.geist(13, relativeTo: .footnote))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.frame(maxWidth: 360, alignment: .leading) // match the described-row caption cap
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,69 +210,10 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Display: Quality
|
|
||||||
|
|
||||||
@ViewBuilder var qualitySection: some View {
|
|
||||||
Section("Quality") {
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
renderScaleRow
|
|
||||||
bitrateRows
|
|
||||||
#endif
|
|
||||||
described("A preference — the host falls back if it can't encode it.") {
|
|
||||||
Picker("Video codec", selection: $codec) {
|
|
||||||
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
described("HDR10, when the host has HDR content and this display supports it. "
|
|
||||||
+ "HEVC only; otherwise the stream stays SDR.") {
|
|
||||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
|
||||||
}
|
|
||||||
described("Sharper text and UI for desktop work, at more bandwidth. For games the "
|
|
||||||
+ "bits are better spent at 4:2:0. HEVC only.") {
|
|
||||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
|
|
||||||
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
|
|
||||||
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
|
|
||||||
@ViewBuilder var renderScaleRow: some View {
|
|
||||||
described(renderScaleDescription) {
|
|
||||||
Picker("Render scale", selection: $renderScale) {
|
|
||||||
ForEach(RenderScale.presets, id: \.self) { scale in
|
|
||||||
Text(RenderScale.label(scale)).tag(scale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render scale explained, with the CONCRETE host resolution when it applies — the cost made
|
|
||||||
/// legible. Only the explicit mode can show it (match-window derives the base from the live
|
|
||||||
/// window, not these fields).
|
|
||||||
private var renderScaleDescription: String {
|
|
||||||
var text = "Above native supersamples for sharpness; below renders lighter on the host "
|
|
||||||
+ "and the link."
|
|
||||||
if renderScale != 1.0, !matchWindow {
|
|
||||||
let mode = RenderScale.apply(
|
|
||||||
baseWidth: width, baseHeight: height,
|
|
||||||
scale: renderScale,
|
|
||||||
maxDimension: RenderScale.maxDimension(codec: codec))
|
|
||||||
text += " Host renders \(Int(mode.width))×\(Int(mode.height)); this device scales "
|
|
||||||
+ "it to your display."
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||||
@ViewBuilder private var bitrateRows: some View {
|
@ViewBuilder private var bitrateRows: some View {
|
||||||
described("The host's default 20 Mbps, clamped to what it supports. Turn off to set a "
|
|
||||||
+ "fixed rate — a host card's context menu has a network speed test.") {
|
|
||||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||||
}
|
|
||||||
if bitrateKbps != 0 {
|
if bitrateKbps != 0 {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Slider(value: bitrateSlider, in: 0...1) {
|
Slider(value: bitrateSlider, in: 0...1) {
|
||||||
@@ -256,247 +233,14 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Display: Presentation
|
|
||||||
|
|
||||||
// The presentation intent (design/apple-presentation-rebuild.md — replaced the visible
|
|
||||||
// stage picker): latency (newest-wins, zero queue) vs smoothness (a small deliberate jitter
|
|
||||||
// buffer). The stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
|
|
||||||
@ViewBuilder var presentationSection: some View {
|
|
||||||
Section("Presentation") {
|
|
||||||
described(presentPriority == "smooth"
|
|
||||||
? "A small frame buffer evens out network hiccups, at the buffer's worth of "
|
|
||||||
+ "added display latency."
|
|
||||||
: "Every frame shows the moment the display can take it — a network hiccup is "
|
|
||||||
+ "an occasional repeated or skipped frame.") {
|
|
||||||
Picker("Prioritize", selection: $presentPriority) {
|
|
||||||
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
|
|
||||||
Text(option.tag == SettingsOptions.presentPriorityDefault
|
|
||||||
? "\(option.label) (default)" : option.label)
|
|
||||||
.tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if presentPriority == "smooth" {
|
|
||||||
described("Frames held back — each absorbs about one refresh of jitter and "
|
|
||||||
+ "adds one refresh of delay.") {
|
|
||||||
Picker("Buffer", selection: $smoothBuffer) {
|
|
||||||
ForEach(SettingsOptions.smoothBuffers(refreshHz: hz), id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh.
|
|
||||||
#if !os(tvOS)
|
|
||||||
described("A ProMotion or adaptive-sync display follows the stream's rate — "
|
|
||||||
+ "smoother motion. No effect on fixed-refresh displays.") {
|
|
||||||
Toggle("Allow VRR", isOn: $allowVRR)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice
|
|
||||||
// only exists on the Mac (the layer's own sync stays off — see MetalVideoPresenter).
|
|
||||||
#if os(macOS)
|
|
||||||
described("Flips align to the display's refresh — even pacing, up to one refresh "
|
|
||||||
+ "of added latency. Off shows frames as soon as they're ready.") {
|
|
||||||
Toggle("V-Sync", isOn: $vsync)
|
|
||||||
}
|
|
||||||
// The DCP swapID-panic mitigation's user handle (see DefaultsKey.windowedSafePresent
|
|
||||||
// for the saga). Default ON: turning it off re-arms a WHOLE-MACHINE kernel panic on
|
|
||||||
// affected setups, so the caption says so in plain words.
|
|
||||||
described(windowedSafePresent
|
|
||||||
? "Windowed streams present in step with the system compositor — avoids a macOS "
|
|
||||||
+ "display-driver crash seen on high-refresh displays, at a small latency "
|
|
||||||
+ "cost. Fullscreen always uses the fastest path."
|
|
||||||
: "Windowed streams use the fastest present path. On some high-refresh setups "
|
|
||||||
+ "this can crash macOS itself (kernel panic) — turn back on if your Mac "
|
|
||||||
+ "restarts during windowed streaming.") {
|
|
||||||
Toggle("Safe windowed presentation", isOn: $windowedSafePresent)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Display: Host output
|
|
||||||
|
|
||||||
@ViewBuilder var hostOutputSection: some View {
|
|
||||||
Section {
|
|
||||||
described("The backend the host uses for its virtual output. A specific choice "
|
|
||||||
+ "falls back to auto-detection when that backend isn't available.") {
|
|
||||||
Picker("Compositor", selection: $compositor) {
|
|
||||||
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
|
||||||
Text(option.label).tag(option.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Host output")
|
|
||||||
} footer: {
|
|
||||||
// The one form-level note (deliberately not repeated on every row above).
|
|
||||||
Text("Display changes apply from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Session
|
|
||||||
|
|
||||||
@ViewBuilder var sessionSection: some View {
|
|
||||||
Section("Session") {
|
|
||||||
#if os(macOS)
|
|
||||||
described("Go fullscreen when a session starts; return to a window on the host "
|
|
||||||
+ "list.") {
|
|
||||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
described("Connecting to a saved host that's offline sends Wake-on-LAN and waits "
|
|
||||||
+ "for it to boot. Turn off if hosts behind a VPN look offline when they "
|
|
||||||
+ "aren't.") {
|
|
||||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
described("Audio and the connection stay live after you switch away; video pauses "
|
|
||||||
+ "to save power and resumes instantly when you return. Off, backgrounding "
|
|
||||||
+ "freezes the session.") {
|
|
||||||
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
|
||||||
}
|
|
||||||
if backgroundKeepAlive {
|
|
||||||
described("Ends a backgrounded session so it can't run down the battery.") {
|
|
||||||
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
|
||||||
Text("1 minute").tag(1)
|
|
||||||
Text("5 minutes").tag(5)
|
|
||||||
Text("10 minutes").tag(10)
|
|
||||||
Text("30 minutes").tag(30)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Statistics overlay
|
|
||||||
|
|
||||||
@ViewBuilder var overlaySection: some View {
|
|
||||||
Section("Statistics") {
|
|
||||||
described(Self.statisticsDescription) {
|
|
||||||
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
|
||||||
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
|
||||||
Text(tier.label).tag(tier.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Picker("Position", selection: $hudPlacement) {
|
|
||||||
ForEach(HUDPlacement.allCases) { placement in
|
|
||||||
Text(placement.label).tag(placement.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General: Library
|
|
||||||
|
|
||||||
@ViewBuilder var librarySection: some View {
|
|
||||||
Section("Library") {
|
|
||||||
described("Adds “Browse Library…” to paired hosts — list their Steam and custom "
|
|
||||||
+ "games and launch one directly. No extra host setup.") {
|
|
||||||
Toggle("Show game library", isOn: $libraryEnabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Input
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
|
||||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
|
||||||
@ViewBuilder var pointerSection: some View {
|
|
||||||
Section("Touch & pointer") {
|
|
||||||
described(touchModeDescription) {
|
|
||||||
Picker("Touch input", selection: $touchMode) {
|
|
||||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
|
||||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
|
||||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
||||||
described("Locks a hardware mouse for relative mouse-look in games; off sends "
|
|
||||||
+ "absolute positions. Needs the stream fullscreen and frontmost.") {
|
|
||||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SELECTED touch mode explained — dynamic, so the caption always describes what the
|
|
||||||
/// picker currently does instead of narrating all three modes at once.
|
|
||||||
private var touchModeDescription: String {
|
|
||||||
switch TouchInputMode(rawValue: touchMode) ?? .trackpad {
|
|
||||||
case .trackpad:
|
|
||||||
return "Your finger drives the host cursor like a laptop trackpad — tap to click, "
|
|
||||||
+ "two-finger tap right-clicks, two-finger drag scrolls, tap-and-drag holds."
|
|
||||||
case .pointer:
|
|
||||||
return "The host cursor jumps to wherever you touch — tap is a click at that spot."
|
|
||||||
case .touch:
|
|
||||||
return "Real multi-touch reaches the host — for touch-native apps and games."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
/// Keyboard & mouse forwarding — applies wherever a hardware keyboard/mouse drives the stream
|
|
||||||
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
|
||||||
@ViewBuilder var inputSection: some View {
|
|
||||||
Section("Keyboard & mouse") {
|
|
||||||
#if os(macOS)
|
|
||||||
described(mouseModeDescription) {
|
|
||||||
Picker("Mouse input", selection: $mouseMode) {
|
|
||||||
Text("Capture (games)").tag(MouseInputMode.capture.rawValue)
|
|
||||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
|
||||||
Picker("Modifier keys", selection: $modifierLayout) {
|
|
||||||
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
|
||||||
Text(layout.label).tag(layout.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
described("Reverses the wheel and trackpad scroll direction sent to the host.") {
|
|
||||||
Toggle("Invert scroll direction", isOn: $invertScroll)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
|
||||||
private var mouseModeDescription: String {
|
|
||||||
switch MouseInputMode(rawValue: mouseMode) ?? .capture {
|
|
||||||
case .capture:
|
|
||||||
return "The pointer locks to the stream and sends relative motion — best for "
|
|
||||||
+ "games. ⌃⌥⇧M switches live; applies from the next capture otherwise."
|
|
||||||
case .desktop:
|
|
||||||
return "The pointer moves freely in and out of the stream and sends absolute "
|
|
||||||
+ "positions — best for remote desktop work. Unavailable on gamescope hosts."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Audio
|
|
||||||
|
|
||||||
@ViewBuilder var audioSection: some View {
|
@ViewBuilder var audioSection: some View {
|
||||||
Section {
|
Section {
|
||||||
described("The speaker layout requested from the host.") {
|
|
||||||
Picker("Audio channels", selection: $audioChannels) {
|
Picker("Audio channels", selection: $audioChannels) {
|
||||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||||
Text(option.label).tag(option.tag)
|
Text(option.label).tag(option.tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
described("Host audio plays through this device; System default follows your "
|
|
||||||
+ "Mac's output changes.") {
|
|
||||||
Picker("Speaker", selection: $speakerUID) {
|
Picker("Speaker", selection: $speakerUID) {
|
||||||
Text("System default").tag("")
|
Text("System default").tag("")
|
||||||
ForEach(outputDevices) { device in
|
ForEach(outputDevices) { device in
|
||||||
@@ -507,11 +251,8 @@ extension SettingsView {
|
|||||||
Text("Unavailable device").tag(speakerUID)
|
Text("Unavailable device").tag(speakerUID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
described("This device's microphone feeds the host's virtual mic.") {
|
|
||||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Picker("Microphone", selection: $micUID) {
|
Picker("Microphone", selection: $micUID) {
|
||||||
Text("System default").tag("")
|
Text("System default").tag("")
|
||||||
@@ -527,7 +268,6 @@ extension SettingsView {
|
|||||||
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
||||||
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
||||||
if micChannelCount > 1 {
|
if micChannelCount > 1 {
|
||||||
described("Pick the input your mic is on; Auto sums every channel.") {
|
|
||||||
Picker("Microphone channel", selection: $micChannel) {
|
Picker("Microphone channel", selection: $micChannel) {
|
||||||
Text("Auto (all channels)").tag(0)
|
Text("Auto (all channels)").tag(0)
|
||||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||||
@@ -536,18 +276,257 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
.disabled(!micEnabled)
|
.disabled(!micEnabled)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Audio")
|
Text("Audio")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Applies from the next session.")
|
Text(Self.audioFooter)
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Controllers
|
#if os(iOS)
|
||||||
|
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||||||
|
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||||
|
@ViewBuilder var pointerSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Touch input", selection: $touchMode) {
|
||||||
|
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||||
|
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||||
|
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||||
|
}
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Touch & pointer")
|
||||||
|
} footer: {
|
||||||
|
Text(pointerFooterText)
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
|
||||||
|
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
||||||
|
/// type-checker budget and was what actually broke the iOS archive.
|
||||||
|
private var pointerFooterText: String {
|
||||||
|
var text = "Trackpad: your finger moves the host cursor like a laptop touchpad — tap "
|
||||||
|
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
|
||||||
|
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
|
||||||
|
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
|
||||||
|
text += "the host. Applies from the next touch."
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
|
||||||
|
text += "absolute positions. Needs the stream full-screen and frontmost."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@ViewBuilder var compositorSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Compositor", selection: $compositor) {
|
||||||
|
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Host compositor")
|
||||||
|
} footer: {
|
||||||
|
Text("Which compositor drives the virtual output on the host. A specific "
|
||||||
|
+ "choice is honored only if that backend is available there — "
|
||||||
|
+ "otherwise the host falls back to auto-detection.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
||||||
|
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
||||||
|
@ViewBuilder var wakeSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Wake-on-LAN")
|
||||||
|
} footer: {
|
||||||
|
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
||||||
|
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
||||||
|
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
||||||
|
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
||||||
|
+ "way.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var windowSection: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||||
|
} header: {
|
||||||
|
Text("Window")
|
||||||
|
} footer: {
|
||||||
|
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||||
|
+ "list, so only the stream is fullscreen — not the picker.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
|
||||||
|
@ViewBuilder var vrrSection: some View {
|
||||||
|
#if !os(tvOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Allow VRR", isOn: $allowVRR)
|
||||||
|
} header: {
|
||||||
|
Text("Variable refresh rate")
|
||||||
|
} footer: {
|
||||||
|
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
|
||||||
|
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
|
||||||
|
+ "Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
|
||||||
|
// exists on the Mac (where the layer's own sync must stay off — see MetalVideoPresenter).
|
||||||
|
@ViewBuilder var vsyncSection: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
Section {
|
||||||
|
Toggle("V-Sync", isOn: $vsync)
|
||||||
|
} header: {
|
||||||
|
Text("Presentation")
|
||||||
|
} footer: {
|
||||||
|
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
|
||||||
|
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
|
||||||
|
+ "in step with the display's refresh — evenly paced, up to one refresh of "
|
||||||
|
+ "added latency. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) vs stage-3 (the same
|
||||||
|
// pipeline with glass-gated present pacing). The default is per-platform — glass on iOS/tvOS,
|
||||||
|
// whose layers always vsync-latch, arrival on macOS (see Stage2Pipeline's PresentPacing for
|
||||||
|
// the queue-saturation rationale) — so the "(default)" marker rides presenterDefault instead
|
||||||
|
// of a hardcoded row. Stage-1 (compressed video straight to the system layer) stays a
|
||||||
|
// DEBUG-only diagnostic — it freezes hard on a lost HEVC reference.
|
||||||
|
@ViewBuilder var presenterSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Presenter", selection: $presenter) {
|
||||||
|
ForEach(SettingsOptions.presenters, id: \.tag) { option in
|
||||||
|
Text(option.tag == SettingsOptions.presenterDefault
|
||||||
|
? "\(option.label) (default)" : option.label)
|
||||||
|
.tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Video presenter")
|
||||||
|
} footer: {
|
||||||
|
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
|
||||||
|
+ "running near the stream's frame rate, queued frames can add two to three "
|
||||||
|
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
||||||
|
+ "to the display — a strict cap on undisplayed frames, always the freshest, "
|
||||||
|
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
|
||||||
|
+ "display time to compare. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var hdrSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Video codec", selection: $codec) {
|
||||||
|
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||||
|
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||||
|
} header: {
|
||||||
|
Text("Video quality")
|
||||||
|
} footer: {
|
||||||
|
Text("Codec is a preference; the host falls back if it can't encode your choice. "
|
||||||
|
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
|
||||||
|
+ "both this device and the host support it — otherwise the stream stays 8-bit "
|
||||||
|
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
|
||||||
|
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
|
||||||
|
+ "session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var statisticsSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||||
|
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||||
|
Text(tier.label).tag(tier.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("Position", selection: $hudPlacement) {
|
||||||
|
ForEach(HUDPlacement.allCases) { placement in
|
||||||
|
Text(placement.label).tag(placement.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||||
|
} header: {
|
||||||
|
Text("Statistics")
|
||||||
|
} footer: {
|
||||||
|
Text(Self.statisticsFooter)
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
|
||||||
|
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
|
||||||
|
/// detail can reference it unconditionally.
|
||||||
|
@ViewBuilder var keepAliveSection: some View {
|
||||||
|
#if os(iOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||||
|
if backgroundKeepAlive {
|
||||||
|
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
||||||
|
Text("1 minute").tag(1)
|
||||||
|
Text("5 minutes").tag(5)
|
||||||
|
Text("10 minutes").tag(10)
|
||||||
|
Text("30 minutes").tag(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Background")
|
||||||
|
} footer: {
|
||||||
|
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
|
||||||
|
+ "playing and the connection stays live (video is dropped to save power) after you "
|
||||||
|
+ "switch away — and the session auto-disconnects after the time above so it can't "
|
||||||
|
+ "run down your battery. Returning to the app resumes video instantly.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var experimentalSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Show game library", isOn: $libraryEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Experimental")
|
||||||
|
} footer: {
|
||||||
|
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||||
|
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
|
||||||
|
+ "extra host setup.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder var controllersSection: some View {
|
@ViewBuilder var controllersSection: some View {
|
||||||
Section {
|
Section {
|
||||||
@@ -559,37 +538,24 @@ extension SettingsView {
|
|||||||
controllerRow(controller)
|
controllerRow(controller)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
described("One controller is forwarded as player 1 — Automatic picks the most "
|
|
||||||
+ "recently connected.") {
|
|
||||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||||
ForEach(controllerOptions, id: \.tag) { option in
|
ForEach(controllerOptions, id: \.tag) { option in
|
||||||
Text(option.label).tag(option.tag)
|
Text(option.label).tag(option.tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
described("The virtual pad created on the host. Automatic matches your controller "
|
|
||||||
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.") {
|
|
||||||
Picker("Controller type", selection: $gamepadType) {
|
Picker("Controller type", selection: $gamepadType) {
|
||||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||||
Text(option.label).tag(option.tag)
|
Text(option.label).tag(option.tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
described("Plays player 1's rumble on the phone's own Taptic Engine — for "
|
|
||||||
+ "clip-on controllers without motors of their own.") {
|
|
||||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
described("With a controller connected, the host list and library switch to a "
|
|
||||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
|
||||||
+ "browser.") {
|
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
Button("Test Controller…") { showControllerTest = true }
|
Button("Test Controller…") { showControllerTest = true }
|
||||||
@@ -599,7 +565,20 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Applies from the next session.")
|
// The gamepad-UI blurb is appended here, not merged into the shared
|
||||||
|
// `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348)
|
||||||
|
// for its own footer and has no such toggle to describe.
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(Self.controllersFooter)
|
||||||
|
#if os(iOS)
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
Text(Self.deviceRumbleFooter)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
|
Text(Self.gamepadUIFooter)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,31 +9,6 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
// MARK: - Described rows (the 2026-07 revamp's field idiom)
|
|
||||||
|
|
||||||
/// A control with its explanation attached to the SAME cell: the field, then a tight caption
|
|
||||||
/// directly under it. This replaced the per-section footer paragraphs — a description the eye
|
|
||||||
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
|
|
||||||
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
|
|
||||||
/// choice.
|
|
||||||
@ViewBuilder
|
|
||||||
func described<Content: View>(
|
|
||||||
_ caption: String, @ViewBuilder content: () -> Content
|
|
||||||
) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
|
||||||
content()
|
|
||||||
Text(caption)
|
|
||||||
.font(.geist(13, relativeTo: .footnote))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true) // wrap, never truncate, in Form cells
|
|
||||||
// Cap the caption's line length well short of the cell: a full-width caption runs
|
|
||||||
// its text right up to the control column (toggles especially), reading as one
|
|
||||||
// colliding block. ~46 chars/line also just measures better.
|
|
||||||
.frame(maxWidth: 360, alignment: .leading)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bitrate
|
// MARK: - Bitrate
|
||||||
|
|
||||||
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
||||||
@@ -42,7 +17,6 @@ extension SettingsView {
|
|||||||
private static let minSliderKbps = 2_000.0
|
private static let minSliderKbps = 2_000.0
|
||||||
private static let maxSliderKbps = 3_000_000.0
|
private static let maxSliderKbps = 3_000_000.0
|
||||||
|
|
||||||
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
|
|
||||||
static let bitrateFooter =
|
static let bitrateFooter =
|
||||||
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
||||||
+ "to its supported range. Run a speed test from a host card's context menu to "
|
+ "to its supported range. Run a speed test from a host card's context menu to "
|
||||||
@@ -79,27 +53,55 @@ extension SettingsView {
|
|||||||
|
|
||||||
// MARK: - Statistics
|
// MARK: - Statistics
|
||||||
|
|
||||||
static var statisticsDescription: String {
|
static var statisticsFooter: String {
|
||||||
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
|
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||||
+ "Detailed adds the latency stage breakdown."
|
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||||
|
+ "breakdown."
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return base + " ⌃⌥⇧S cycles the tiers any time."
|
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time."
|
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||||
|
+ "any time."
|
||||||
#else
|
#else
|
||||||
return base
|
return base
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio
|
||||||
|
|
||||||
|
static var audioFooter: String {
|
||||||
|
#if os(macOS)
|
||||||
|
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
|
||||||
|
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
|
||||||
|
+ "next session."
|
||||||
|
#else
|
||||||
|
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
|
||||||
|
+ "Applies from the next session."
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Controllers
|
// MARK: - Controllers
|
||||||
|
|
||||||
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
|
|
||||||
static let controllersFooter =
|
static let controllersFooter =
|
||||||
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
||||||
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
||||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "Applies from the next session."
|
+ "Applies from the next session."
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static let deviceRumbleFooter =
|
||||||
|
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
|
||||||
|
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
|
||||||
|
+ "from the next session."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
static let gamepadUIFooter =
|
||||||
|
"When a controller connects, the host list and library switch to a controller-"
|
||||||
|
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
|
||||||
|
+ "off to always use the standard layout."
|
||||||
|
#endif
|
||||||
|
|
||||||
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
||||||
var controllerOptions: [(label: String, tag: String)] {
|
var controllerOptions: [(label: String, tag: String)] {
|
||||||
SettingsOptions.controllerOptions(gamepads)
|
SettingsOptions.controllerOptions(gamepads)
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
||||||
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
||||||
//
|
//
|
||||||
// Navigation differs per platform, but all three follow the same category map (General =
|
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||||
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers,
|
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||||
// About — see SettingsCategory): macOS uses a tabbed preferences window; iOS/iPadOS uses an
|
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||||
// adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
|
||||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||||
// focus-native pushed-picker layout in the same order. The individual sections
|
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||||
// (`resolutionSection`, `audioSection`, …) are shared across all three so a setting is defined
|
// `audioSection`, …) are shared across all three so a setting is defined exactly once — they
|
||||||
// exactly once — they live in SettingsView+Sections.swift, with their helpers (including the
|
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
||||||
// per-field `described` caption idiom) in SettingsView+Support.swift.
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -35,12 +33,9 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||||
SettingsOptions.presentPriorityDefault
|
|
||||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||||
@AppStorage(DefaultsKey.windowedSafePresent) var windowedSafePresent = true
|
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||||||
@@ -89,7 +84,6 @@ struct SettingsView: View {
|
|||||||
@State var customMode = false
|
@State var customMode = false
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
|
||||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||||
@@ -126,32 +120,27 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var macBody: some View {
|
private var macBody: some View {
|
||||||
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
|
|
||||||
// picture (resolution lives here), Input = keyboard & mouse.
|
|
||||||
TabView {
|
TabView {
|
||||||
Form {
|
Form {
|
||||||
sessionSection
|
streamModeSection
|
||||||
overlaySection
|
inputSection
|
||||||
librarySection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
resolutionSection
|
presenterSection
|
||||||
qualitySection
|
hdrSection
|
||||||
presentationSection
|
vrrSection
|
||||||
hostOutputSection
|
vsyncSection
|
||||||
|
windowSection
|
||||||
|
statisticsSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("Display", systemImage: "display") }
|
.tabItem { Label("Display", systemImage: "display") }
|
||||||
|
|
||||||
Form {
|
|
||||||
inputSection
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.tabItem { Label("Input", systemImage: "keyboard") }
|
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
audioSection
|
audioSection
|
||||||
}
|
}
|
||||||
@@ -179,10 +168,16 @@ struct SettingsView: View {
|
|||||||
.onDisappear { gamepads.stopDiscovery() }
|
.onDisappear { gamepads.stopDiscovery() }
|
||||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||||
|
|
||||||
|
Form {
|
||||||
|
experimentalSection
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||||
|
|
||||||
AcknowledgementsView()
|
AcknowledgementsView()
|
||||||
.tabItem { Label("About", systemImage: "info.circle") }
|
.tabItem { Label("About", systemImage: "info.circle") }
|
||||||
}
|
}
|
||||||
.frame(width: 500, height: 520)
|
.frame(width: 480, height: 460)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -257,31 +252,26 @@ struct SettingsView: View {
|
|||||||
switch category {
|
switch category {
|
||||||
case .general:
|
case .general:
|
||||||
Form {
|
Form {
|
||||||
sessionSection
|
streamModeSection
|
||||||
overlaySection
|
pointerSection
|
||||||
librarySection
|
inputSection
|
||||||
|
compositorSection
|
||||||
|
wakeSection
|
||||||
|
keepAliveSection // iOS-only content; empty on tvOS
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .display:
|
case .display:
|
||||||
Form {
|
Form {
|
||||||
resolutionSection
|
presenterSection
|
||||||
qualitySection
|
hdrSection
|
||||||
presentationSection
|
vrrSection
|
||||||
hostOutputSection
|
statisticsSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("Display")
|
.navigationTitle("Display")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .input:
|
|
||||||
Form {
|
|
||||||
pointerSection
|
|
||||||
inputSection
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.navigationTitle("Input")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
case .audio:
|
case .audio:
|
||||||
Form { audioSection }
|
Form { audioSection }
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
@@ -292,6 +282,11 @@ struct SettingsView: View {
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("Controllers")
|
.navigationTitle("Controllers")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
case .advanced:
|
||||||
|
Form { experimentalSection }
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Advanced")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .about:
|
case .about:
|
||||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||||
@@ -337,16 +332,6 @@ struct SettingsView: View {
|
|||||||
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One cluster caption, TV-legible — the 10-foot analogue of the touch/desktop per-row
|
|
||||||
/// `described` captions (per-row text doesn't scale to TV type sizes).
|
|
||||||
private func tvCaption(_ text: String) -> some View {
|
|
||||||
Text(text)
|
|
||||||
.font(.geist(20, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tvBody: some View {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -359,9 +344,6 @@ struct SettingsView: View {
|
|||||||
if !options.contains(where: { $0.tag == currentTag }) {
|
if !options.contains(where: { $0.tag == currentTag }) {
|
||||||
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
||||||
}
|
}
|
||||||
// Row order mirrors the touch/desktop category map: Display (mode → quality →
|
|
||||||
// presentation → host output), then Audio, General, Statistics, Controllers — with one
|
|
||||||
// short caption per cluster (per-row captions don't scale to 10-foot type sizes).
|
|
||||||
return ScrollView {
|
return ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||||
@@ -373,41 +355,37 @@ struct SettingsView: View {
|
|||||||
title: "Bitrate",
|
title: "Bitrate",
|
||||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||||
selection: $bitrateKbps)
|
selection: $bitrateKbps)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Audio channels",
|
||||||
|
options: SettingsOptions.audioChannels,
|
||||||
|
selection: $audioChannels)
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Compositor", options: SettingsOptions.compositors,
|
||||||
|
selection: $compositor)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Presenter",
|
||||||
|
options: SettingsOptions.presenters,
|
||||||
|
selection: $presenter)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||||
TVSelectionRow(
|
|
||||||
title: "Prioritize",
|
|
||||||
options: SettingsOptions.presentPriorities,
|
|
||||||
selection: $presentPriority)
|
|
||||||
if presentPriority == "smooth" {
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Smoothness buffer",
|
|
||||||
options: SettingsOptions.smoothBuffers(refreshHz: hz),
|
|
||||||
selection: $smoothBuffer)
|
|
||||||
}
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Compositor", options: SettingsOptions.compositors,
|
|
||||||
selection: $compositor)
|
|
||||||
tvCaption("The host drives a real output at exactly the chosen mode. "
|
|
||||||
+ "\(Self.bitrateFooter) Lowest latency shows frames immediately; "
|
|
||||||
+ "Smoothness buffers a few to even out network hiccups. A specific "
|
|
||||||
+ "compositor is honored only if available on the host.")
|
|
||||||
TVSelectionRow(
|
|
||||||
title: "Audio channels",
|
|
||||||
options: SettingsOptions.audioChannels,
|
|
||||||
selection: $audioChannels)
|
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Auto-wake on connect",
|
title: "Auto-wake on connect",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||||
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "it before streaming.")
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
|
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
||||||
|
+ "sleeping saved host and waits for it before streaming.")
|
||||||
|
.font(.geist(20, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 8)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Statistics overlay",
|
title: "Statistics overlay",
|
||||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||||
@@ -427,7 +405,11 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Gamepad-optimized browsing",
|
title: "Gamepad-optimized browsing",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||||
tvCaption(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
|
.font(.geist(20, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 8)
|
||||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,31 +35,10 @@ public struct AccessUnit: Sendable {
|
|||||||
public let ptsNs: UInt64
|
public let ptsNs: UInt64
|
||||||
public let frameIndex: UInt32
|
public let frameIndex: UInt32
|
||||||
public let flags: UInt32
|
public let flags: UInt32
|
||||||
/// Client `CLOCK_REALTIME` instant the AU finished reassembly in the core (post-FEC,
|
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
|
||||||
/// decrypted — `PunktfunkFrame.received_ns`, ABI v9) — the **received** measurement point of
|
/// — the **received** measurement point of design/stats-unification.md. The decode stage is
|
||||||
/// design/stats-unification.md. NOT the pull instant: stamping at the pull folded the
|
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
|
||||||
/// pre-decode hand-off wait into the network term, which is how the 2026-07 two-pair
|
|
||||||
/// standing-latency plateau hid as "network". The decode stage is `decodedNs - receivedNs`,
|
|
||||||
/// both client-local (no skew offset applies).
|
|
||||||
public let receivedNs: Int64
|
public let receivedNs: Int64
|
||||||
/// Client `CLOCK_REALTIME` instant this pull returned. `pulledNs - receivedNs` is the
|
|
||||||
/// client-queue wait (kernel hand-off + FrameChannel dwell) — the term the HUD splits out
|
|
||||||
/// so a client-side standing backlog can never masquerade as network latency again.
|
|
||||||
public let pulledNs: Int64
|
|
||||||
|
|
||||||
/// `pulledNs` defaults to `receivedNs` (zero queue wait) for callers with no pull instant —
|
|
||||||
/// the synthetic probe AUs and decode tests, where the split is meaningless.
|
|
||||||
public init(
|
|
||||||
data: Data, ptsNs: UInt64, frameIndex: UInt32, flags: UInt32,
|
|
||||||
receivedNs: Int64, pulledNs: Int64? = nil
|
|
||||||
) {
|
|
||||||
self.data = data
|
|
||||||
self.ptsNs = ptsNs
|
|
||||||
self.frameIndex = frameIndex
|
|
||||||
self.flags = flags
|
|
||||||
self.receivedNs = receivedNs
|
|
||||||
self.pulledNs = pulledNs ?? receivedNs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
||||||
@@ -221,9 +200,6 @@ public final class PunktfunkConnection {
|
|||||||
/// core). The clip *sends* (`clipControl`/`clipOffer`/`clipServe`…) share this lock too:
|
/// core). The clip *sends* (`clipControl`/`clipOffer`/`clipServe`…) share this lock too:
|
||||||
/// they're quick non-blocking enqueues, and a single lock keeps close() ordering simple.
|
/// they're quick non-blocking enqueues, and a single lock keeps close() ordering simple.
|
||||||
private let clipboardLock = NSLock()
|
private let clipboardLock = NSLock()
|
||||||
/// Serializes the (single) cursor pull thread against close() — both cursor planes are
|
|
||||||
/// drained by ONE thread, so one lock covers them.
|
|
||||||
private let cursorLock = NSLock()
|
|
||||||
|
|
||||||
/// Negotiated session mode (host-confirmed).
|
/// Negotiated session mode (host-confirmed).
|
||||||
public private(set) var width: UInt32 = 0
|
public private(set) var width: UInt32 = 0
|
||||||
@@ -384,106 +360,6 @@ public final class PunktfunkConnection {
|
|||||||
public var hostSupportsClipboard: Bool {
|
public var hostSupportsClipboard: Bool {
|
||||||
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0
|
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host answered `HOST_CAP_CURSOR`: it stopped compositing the pointer and forwards
|
|
||||||
/// shape/state on the cursor planes — the client MUST draw the cursor locally.
|
|
||||||
/// `0x08` — the bit moved when `HOST_CAP_TEXT_INPUT` claimed `0x04` on main; testing the
|
|
||||||
/// old bit would mistake a text-input-capable host (e.g. Windows) for a cursor grant.
|
|
||||||
public var hostSupportsCursor: Bool {
|
|
||||||
hostCaps & 0x08 != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The host injects full-fidelity stylus input (`HOST_CAP_PEN`) — the gate for splitting
|
|
||||||
/// Apple Pencil out of the touch path onto the pen plane (``sendPen(_:)``).
|
|
||||||
public var hostSupportsPen: Bool {
|
|
||||||
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_PEN) != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One forwarded host-cursor shape (the cursor channel, ABI v11): straight-alpha RGBA,
|
|
||||||
/// `rgba.count == width * height * 4`, hotspot within the bitmap. Cache by `serial` —
|
|
||||||
/// states reference shapes by it and a re-shown serial never resends pixels.
|
|
||||||
public struct CursorShapeEvent: Sendable {
|
|
||||||
public let serial: UInt32
|
|
||||||
public let width: Int
|
|
||||||
public let height: Int
|
|
||||||
public let hotX: Int
|
|
||||||
public let hotY: Int
|
|
||||||
public let rgba: Data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-host-tick cursor state: position (host video px, the pointer/hotspot point),
|
|
||||||
/// visibility, and the host-driven relative-mode hint (an app grabbed/hid the pointer ⇒
|
|
||||||
/// run captured relative; clear ⇒ absolute, reappearing at `x`/`y`). Latest-wins.
|
|
||||||
public struct CursorStateEvent: Sendable {
|
|
||||||
public let serial: UInt32
|
|
||||||
public let visible: Bool
|
|
||||||
public let relativeHint: Bool
|
|
||||||
public let x: Int32
|
|
||||||
public let y: Int32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull the next forwarded cursor SHAPE (nil = timeout). Only a session connected with
|
|
||||||
/// `clientCaps` cursor bit against a `hostSupportsCursor` host receives any. Drain shape
|
|
||||||
/// AND state from ONE dedicated cursor thread (they share a lock).
|
|
||||||
public func nextCursorShape(timeoutMs: UInt32 = 0) throws -> CursorShapeEvent? {
|
|
||||||
cursorLock.lock()
|
|
||||||
defer { cursorLock.unlock() }
|
|
||||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
|
||||||
var out = PunktfunkCursorShape()
|
|
||||||
let rc = punktfunk_connection_next_cursor_shape(h, &out, timeoutMs)
|
|
||||||
switch rc {
|
|
||||||
case statusOK:
|
|
||||||
// Copy out of the ABI borrow (valid until the next shape call) immediately.
|
|
||||||
let bytes = out.rgba.map { Data(bytes: $0, count: Int(out.len)) } ?? Data()
|
|
||||||
return CursorShapeEvent(
|
|
||||||
serial: out.serial, width: Int(out.w), height: Int(out.h),
|
|
||||||
hotX: Int(out.hot_x), hotY: Int(out.hot_y), rgba: bytes)
|
|
||||||
case statusNoFrame:
|
|
||||||
return nil
|
|
||||||
case statusClosed:
|
|
||||||
throw PunktfunkClientError.closed
|
|
||||||
default:
|
|
||||||
throw PunktfunkClientError.status(rc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull the next cursor STATE (nil = timeout). Latest-wins — drain the queue and apply
|
|
||||||
/// only the newest. Same thread + gate as [`nextCursorShape`].
|
|
||||||
public func nextCursorState(timeoutMs: UInt32 = 0) throws -> CursorStateEvent? {
|
|
||||||
cursorLock.lock()
|
|
||||||
defer { cursorLock.unlock() }
|
|
||||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
|
||||||
var out = PunktfunkCursorState()
|
|
||||||
let rc = punktfunk_connection_next_cursor_state(h, &out, timeoutMs)
|
|
||||||
switch rc {
|
|
||||||
case statusOK:
|
|
||||||
return CursorStateEvent(
|
|
||||||
serial: out.serial,
|
|
||||||
visible: out.flags & 0x01 != 0,
|
|
||||||
relativeHint: out.flags & 0x02 != 0,
|
|
||||||
x: out.x, y: out.y)
|
|
||||||
case statusNoFrame:
|
|
||||||
return nil
|
|
||||||
case statusClosed:
|
|
||||||
throw PunktfunkClientError.closed
|
|
||||||
default:
|
|
||||||
throw PunktfunkClientError.status(rc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tell the host who renders the pointer (the §8 mid-stream mouse-model flip, ABI v12):
|
|
||||||
/// `clientDraws = true` — this client draws it locally (the desktop mouse model; the host
|
|
||||||
/// excludes the pointer from the video and forwards shape/state); `false` — the host
|
|
||||||
/// composites it into the video (the capture model, full fidelity). Idempotent,
|
|
||||||
/// latest-wins; harmless against hosts without the cursor cap. Fire-and-forget — errors
|
|
||||||
/// are swallowed (a closed session is the only failure and it moots the flip).
|
|
||||||
public func setCursorRender(clientDraws: Bool) {
|
|
||||||
cursorLock.lock()
|
|
||||||
defer { cursorLock.unlock() }
|
|
||||||
guard let h = liveHandle() else { return }
|
|
||||||
_ = punktfunk_connection_set_cursor_render(h, clientDraws)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||||
@@ -520,7 +396,6 @@ public final class PunktfunkConnection {
|
|||||||
audioChannels: UInt8 = 2,
|
audioChannels: UInt8 = 2,
|
||||||
videoCodecs: UInt8 = 0x02, // PUNKTFUNK_CODEC_HEVC — the codecs this client can decode
|
videoCodecs: UInt8 = 0x02, // PUNKTFUNK_CODEC_HEVC — the codecs this client can decode
|
||||||
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
|
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
|
||||||
clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally
|
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
timeoutMs: UInt32 = 10_000
|
timeoutMs: UInt32 = 10_000
|
||||||
) throws {
|
) throws {
|
||||||
@@ -540,18 +415,18 @@ public final class PunktfunkConnection {
|
|||||||
withOptionalCString(launchID) { launch in
|
withOptionalCString(launchID) { launch in
|
||||||
if let pin = pinSHA256 {
|
if let pin = pinSHA256 {
|
||||||
return pin.withUnsafeBytes { p in
|
return pin.withUnsafeBytes { p in
|
||||||
punktfunk_connect_ex9(
|
punktfunk_connect_ex8(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||||
videoCodecs, preferredCodec, clientCaps, launch,
|
videoCodecs, preferredCodec, launch,
|
||||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||||
cert, key, timeoutMs, &connectStatus)
|
cert, key, timeoutMs, &connectStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return punktfunk_connect_ex9(
|
return punktfunk_connect_ex8(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||||
videoCodecs, preferredCodec, clientCaps, launch,
|
videoCodecs, preferredCodec, launch,
|
||||||
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,16 +662,11 @@ public final class PunktfunkConnection {
|
|||||||
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
||||||
var ts = timespec()
|
var ts = timespec()
|
||||||
clock_gettime(CLOCK_REALTIME, &ts)
|
clock_gettime(CLOCK_REALTIME, &ts)
|
||||||
let pulledNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||||
// Receipt = the core's reassembly-completion stamp (ABI v9); the pull instant is
|
|
||||||
// kept separately so the client-queue wait is its own measured term. 0 would mean a
|
|
||||||
// pre-v9 core — impossible here (core and Kit ship in one binary), but fall back to
|
|
||||||
// the pull instant rather than record a 1970 receipt.
|
|
||||||
let receivedNs = frame.received_ns > 0 ? Int64(frame.received_ns) : pulledNs
|
|
||||||
return AccessUnit(
|
return AccessUnit(
|
||||||
data: data, ptsNs: frame.pts_ns,
|
data: data, ptsNs: frame.pts_ns,
|
||||||
frameIndex: frame.frame_index, flags: frame.flags,
|
frameIndex: frame.frame_index, flags: frame.flags,
|
||||||
receivedNs: receivedNs, pulledNs: pulledNs)
|
receivedNs: receivedNs)
|
||||||
case statusNoFrame:
|
case statusNoFrame:
|
||||||
return nil
|
return nil
|
||||||
case statusClosed:
|
case statusClosed:
|
||||||
@@ -1141,19 +1011,6 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_send_input(h, &ev)
|
_ = punktfunk_connection_send_input(h, &ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send one stylus sample batch (≤ `PUNKTFUNK_PEN_BATCH_MAX`, oldest first) on the pen
|
|
||||||
/// plane. Gate on ``hostSupportsPen`` — the core refuses toward a host without the cap.
|
|
||||||
/// Thread-safe; silently dropped after close (input is lossy by design).
|
|
||||||
public func sendPen(_ samples: [PunktfunkPenSample]) {
|
|
||||||
guard !samples.isEmpty else { return }
|
|
||||||
abiLock.lock()
|
|
||||||
defer { abiLock.unlock() }
|
|
||||||
guard let h = handle, !closeRequested else { return }
|
|
||||||
samples.withUnsafeBufferPointer { buf in
|
|
||||||
_ = punktfunk_connection_send_pen(h, buf.baseAddress, UInt32(buf.count))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signal a **deliberate** user-initiated quit before ``close()``: the connection closes with
|
/// Signal a **deliberate** user-initiated quit before ``close()``: the connection closes with
|
||||||
/// `QUIT_CLOSE_CODE` (81) so the host tears the session down immediately instead of holding the
|
/// `QUIT_CLOSE_CODE` (81) so the host tears the session down immediately instead of holding the
|
||||||
/// keep-alive linger for a reconnect. Call only from an explicit "Disconnect" action — NOT from a
|
/// keep-alive linger for a reconnect. Call only from an explicit "Disconnect" action — NOT from a
|
||||||
@@ -1176,12 +1033,10 @@ public final class PunktfunkConnection {
|
|||||||
feedbackLock.lock()
|
feedbackLock.lock()
|
||||||
statsLock.lock()
|
statsLock.lock()
|
||||||
clipboardLock.lock()
|
clipboardLock.lock()
|
||||||
cursorLock.lock()
|
|
||||||
abiLock.lock()
|
abiLock.lock()
|
||||||
let h = handle
|
let h = handle
|
||||||
handle = nil
|
handle = nil
|
||||||
abiLock.unlock()
|
abiLock.unlock()
|
||||||
cursorLock.unlock()
|
|
||||||
clipboardLock.unlock()
|
clipboardLock.unlock()
|
||||||
statsLock.unlock()
|
statsLock.unlock()
|
||||||
feedbackLock.unlock()
|
feedbackLock.unlock()
|
||||||
|
|||||||
@@ -110,11 +110,11 @@ public final class InputCapture {
|
|||||||
/// event itself is swallowed). Main queue.
|
/// event itself is swallowed). Main queue.
|
||||||
public var onToggleCapture: (() -> Void)?
|
public var onToggleCapture: (() -> Void)?
|
||||||
|
|
||||||
/// Fired on ⌃⌥⇧M (the mouse-model flip, capture ⇄ desktop — cross-client parity with the
|
/// Fired on ⌘⇧C (the client-side-cursor toggle — flips between the captured/disassociated
|
||||||
/// SDL clients' Ctrl+Alt+Shift+M; detected here, like ⌘⎋, so it works regardless of the
|
/// relative path and the visible-cursor absolute path; detected here, like ⌘⎋, so it works
|
||||||
/// current capture state and the event itself is swallowed). macOS only; the
|
/// regardless of the current capture state and the event itself is swallowed). macOS only;
|
||||||
/// absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
/// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
||||||
public var onToggleMouseMode: (() -> Void)?
|
public var onToggleCursor: (() -> Void)?
|
||||||
|
|
||||||
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
|
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
|
||||||
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||||
@@ -245,14 +245,13 @@ public final class InputCapture {
|
|||||||
self.onToggleCapture?()
|
self.onToggleCapture?()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// ⌃⌥⇧M flips the mouse model (capture ⇄ desktop — the SDL clients' identical
|
// ⌘⇧C toggles the client-side cursor (visible-cursor absolute path vs the
|
||||||
// chord). Detected in both capture states, like ⌘⎋, so the model can be set
|
// captured relative path). keyCode 8 = kVK_ANSI_C; layout-independent so it
|
||||||
// before engaging. keyCode 46 = kVK_ANSI_M; layout-independent. Suppress the M
|
// fires the same on any keyboard. Suppress the C (latched like ⌘⎋'s Esc) so it
|
||||||
// (latched like ⌘⎋'s Esc) so it doesn't type into the host, and swallow the
|
// doesn't type into the host, and swallow the event so it doesn't beep.
|
||||||
// event so it doesn't beep.
|
if event.keyCode == 8 /* C */, flags == [.command, .shift] {
|
||||||
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] {
|
self.suppressedVK = 0x43 // VK_C — the same physical C is en route via GC
|
||||||
self.suppressedVK = 0x4D // VK_M — the same physical M is en route via GC
|
self.onToggleCursor?()
|
||||||
self.onToggleMouseMode?()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S — the same set every other
|
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S — the same set every other
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/// How a physical mouse drives the host — the cross-client mouse model (the SDL clients'
|
|
||||||
/// `MouseMode` / `Settings::mouse_mode`, design/remote-desktop-sweep.md M1). Stored stringly
|
|
||||||
/// under `DefaultsKey.mouseMode`.
|
|
||||||
public enum MouseInputMode: String, CaseIterable, Sendable {
|
|
||||||
/// Pointer capture (disassociated, hidden cursor, relative deltas) — the game model,
|
|
||||||
/// and the default: the only cursor you see is the host's.
|
|
||||||
case capture
|
|
||||||
/// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and
|
|
||||||
/// motion is forwarded as absolute positions through the letterbox. The remote desktop
|
|
||||||
/// model. Requires a host injector with absolute support (not gamescope).
|
|
||||||
case desktop
|
|
||||||
}
|
|
||||||
@@ -57,8 +57,30 @@ public enum BrandFont {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget
|
public extension Color {
|
||||||
// extension links Shared alone and must render the same purple.
|
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
||||||
|
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
||||||
|
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
||||||
|
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
||||||
|
static let brand: Color = {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
return Color(UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||||
|
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||||
|
})
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
return Color(NSColor(name: nil) { appearance in
|
||||||
|
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||||
|
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||||
|
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||||
|
})
|
||||||
|
#else
|
||||||
|
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
||||||
|
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
||||||
|
#endif
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
public extension Font {
|
public extension Font {
|
||||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||||
|
|||||||
@@ -23,34 +23,6 @@ import os
|
|||||||
|
|
||||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// HOW a windowed (composited) macOS session pushes finished frames to glass — the DCP
|
|
||||||
/// "mismatched swapID's" kernel-panic saga's mechanism picker. Fullscreen always presents
|
|
||||||
/// `async` (direct-scanout promotion, lowest latency, no panic reports there); the windowed
|
|
||||||
/// mechanism is resolved per session by SessionPresenter (user setting +
|
|
||||||
/// PUNKTFUNK_WINDOWED_PRESENT env override) and routed here via `setWindowedPresent`.
|
|
||||||
///
|
|
||||||
/// - `async`: the CAMetalLayer image queue (`commandBuffer.present`) — the fastest composited
|
|
||||||
/// path and the PANIC TRIGGER on high-refresh displays (the out-of-band swaps race
|
|
||||||
/// WindowServer's compositor; it survived glass pacing and every codec).
|
|
||||||
/// - `transaction`: `CAMetalLayer.presentsWithTransaction` — the swap commits WITH the layer
|
|
||||||
/// tree, in lockstep with the compositor (Apple's documented remedy; validated no-panic on
|
|
||||||
/// the 240 Hz repro machine). The present is committed from the RENDER thread inside an
|
|
||||||
/// explicit CATransaction + flush — see `encodePresent` for why that beats the original
|
|
||||||
/// main-thread hop.
|
|
||||||
/// - `surface`: no image queue at all — render into a pooled IOSurface and swap it into a plain
|
|
||||||
/// CALayer's `contents` (the f407f418 PyroWave mitigation, resurrected format-aware:
|
|
||||||
/// rgba16Float + PQ tagging keeps HDR). WindowServer treats it as ordinary layer damage on
|
|
||||||
/// its own composite cadence. PROTOTYPE: whether the compositor honors PQ/EDR for plain-layer
|
|
||||||
/// IOSurface contents still needs an on-glass eyeball — the metal layer stays underneath with
|
|
||||||
/// `wantsExtendedDynamicRangeContent` as the EDR anchor.
|
|
||||||
enum WindowedPresentMode: String, Sendable {
|
|
||||||
case async
|
|
||||||
case transaction
|
|
||||||
case surface
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
|
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
|
||||||
/// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors
|
/// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors
|
||||||
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
||||||
@@ -226,8 +198,8 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
|||||||
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||||
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
||||||
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
||||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the tvOS planar (PyroWave) tone-map (the
|
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the
|
||||||
// no-HDR-headroom fallback). macOS keeps real HDR windowed now — see `WindowedPresentMode`.
|
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
|
||||||
static inline float3 pqToSdr(float3 pq) {
|
static inline float3 pqToSdr(float3 pq) {
|
||||||
const float m1 = 2610.0/16384.0;
|
const float m1 = 2610.0/16384.0;
|
||||||
const float m2 = 78.84375;
|
const float m2 = 78.84375;
|
||||||
@@ -258,7 +230,8 @@ fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
|||||||
|
|
||||||
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
||||||
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
||||||
// stream must land on an 8-bit surface: tvOS without HDR headroom. The passthrough planar
|
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions
|
||||||
|
// (the IOSurface present path — the DCP-panic mitigation — is BGRA8). The passthrough planar
|
||||||
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
||||||
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
||||||
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
||||||
@@ -286,51 +259,21 @@ public final class MetalVideoPresenter {
|
|||||||
public let layer: CAMetalLayer
|
public let layer: CAMetalLayer
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// WINDOWED-mode present coordination — the macOS DCP KERNEL PANIC mitigation.
|
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed
|
||||||
|
/// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions.
|
||||||
///
|
///
|
||||||
/// The panic ("mismatched swapID's" @UnifiedPipeline.cpp, WindowServer dies, machine reboots):
|
/// Why this exists — the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp,
|
||||||
/// the CAMetalLayer's ASYNCHRONOUS image queue (`commandBuffer.present(drawable)` — an
|
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
|
||||||
/// out-of-band flip, mandatory with `displaySyncEnabled=false`) diverges from WindowServer's
|
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
|
||||||
/// compositor on a high-refresh COMPOSITED (windowed) session — the compositor's notion of the
|
/// displays, and the race survives glass pacing — a fully serialized one-in-flight present
|
||||||
/// current swap and the layer's queued swap disagree, and the DCP asserts. It survived glass
|
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
|
||||||
/// pacing: a fully serialized one-in-flight present stream still panicked a 240 Hz Mac Studio
|
/// using the image queue entirely and present the way video players do: render the planar CSC
|
||||||
/// (2026-07-18, PyroWave), and a windowed HEVC session panicked the same machine 2026-07-21 —
|
/// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary
|
||||||
/// so it is the async image queue itself, at any pacing or codec, not a present rate.
|
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of
|
||||||
///
|
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout
|
||||||
/// The fix keeps the full render path (rgba16Float / PQ / EDR — real HDR is preserved) and
|
/// promotion, no compositing, no panic reports). Contents updates are transparent to the
|
||||||
/// only changes HOW the drawable is presented: `CAMetalLayer.presentsWithTransaction`. With it
|
/// layer below when nil, so flipping modes just covers/uncovers the metal layer.
|
||||||
/// set, we don't hand the drawable to the command buffer; we commit, wait until scheduled, then
|
public let surfaceLayer: CALayer = {
|
||||||
/// call `drawable.present()` INSIDE a CATransaction — the present is enrolled in Core
|
|
||||||
/// Animation's transaction and committed together with the layer tree, so the swap stays in
|
|
||||||
/// lockstep with the compositor instead of racing it (Apple's documented remedy for Metal
|
|
||||||
/// presentation drifting out of sync with CA). Fullscreen keeps the async path (direct-scanout
|
|
||||||
/// promotion, lowest latency, no compositor and no panic reports there).
|
|
||||||
///
|
|
||||||
/// 2026-07-21 latency rework: the mitigation MECHANISM is now a three-way pick
|
|
||||||
/// (`WindowedPresentMode`) and the transactional present commits from the RENDER thread —
|
|
||||||
/// see `encodePresent`. Staged under `stagingLock` (main pushes it via
|
|
||||||
/// `setComposited`→`setWindowedPresent`); the render thread drains it and toggles the layer
|
|
||||||
/// property + present style. `Active` is the render-thread copy so the layer property flips
|
|
||||||
/// exactly once per mode change.
|
|
||||||
private var windowedPresentStaged: WindowedPresentMode = .async
|
|
||||||
private var windowedPresentActive: WindowedPresentMode = .async
|
|
||||||
|
|
||||||
/// PUNKTFUNK_TXN_PRESENT=main — the ORIGINAL transactional present (commit →
|
|
||||||
/// waitUntilScheduled → hop to the MAIN thread and present inside its CATransaction), kept
|
|
||||||
/// as a field A/B lever. The default is the render-thread commit: the present harness
|
|
||||||
/// (2026-07-21, this saga) measured the main hop landing a runloop turn late on a busy main
|
|
||||||
/// thread, and an ACTIVE implicit transaction there NESTS the explicit one — presents batch
|
|
||||||
/// at runloop-iteration rate (the field's presents=55 @ fps=240, display_p50 18.6 ms).
|
|
||||||
/// Off-main commits measured immune to main-thread churn (~10 ms glass p50 at 240 Hz
|
|
||||||
/// full-size vs 14+ ms under a churned main hop).
|
|
||||||
private let txnPresentOnMain =
|
|
||||||
ProcessInfo.processInfo.environment["PUNKTFUNK_TXN_PRESENT"] == "main"
|
|
||||||
|
|
||||||
/// The WINDOWED-mode `surface` present target: a plain CALayer sized like `layer` (installed
|
|
||||||
/// as a sibling ABOVE it by SessionPresenter), fed IOSurfaces via `contents` inside explicit
|
|
||||||
/// CATransactions. Transparent (nil contents) whenever surface mode is off, so the metal
|
|
||||||
/// layer below shows through. See `WindowedPresentMode.surface`.
|
|
||||||
let surfaceLayer: CALayer = {
|
|
||||||
let l = CALayer()
|
let l = CALayer()
|
||||||
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
||||||
l.isOpaque = true
|
l.isOpaque = true
|
||||||
@@ -338,8 +281,8 @@ public final class MetalVideoPresenter {
|
|||||||
return l
|
return l
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// One IOSurface-backed render target of the windowed surface-present pool. All pool state
|
/// One IOSurface-backed render target of the windowed present pool. All pool state is
|
||||||
/// is RENDER-THREAD confined; only the immutable surface refs cross threads (contents swap).
|
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
|
||||||
private struct SurfaceSlot {
|
private struct SurfaceSlot {
|
||||||
let surface: IOSurfaceRef
|
let surface: IOSurfaceRef
|
||||||
let texture: MTLTexture
|
let texture: MTLTexture
|
||||||
@@ -349,52 +292,15 @@ public final class MetalVideoPresenter {
|
|||||||
|
|
||||||
private var surfacePool: [SurfaceSlot] = []
|
private var surfacePool: [SurfaceSlot] = []
|
||||||
private var surfacePoolSize: CGSize = .zero
|
private var surfacePoolSize: CGSize = .zero
|
||||||
private var surfacePoolHDR = false
|
|
||||||
private var surfaceSeq: UInt64 = 0
|
private var surfaceSeq: UInt64 = 0
|
||||||
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
||||||
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
||||||
private var lastHandedOff: Int?
|
private var lastHandedOff: Int?
|
||||||
|
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
|
||||||
/// Once-per-second decomposition of the ACTIVE windowed present path (the field-diagnosis
|
/// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`.
|
||||||
/// half of the DCP-latency work): scheduled/completed wait + commit/flush cost per present,
|
private var surfacePresentsStaged = false
|
||||||
/// and how many presents/swaps were issued. The pf-present line shows the GLASS side
|
/// Render-thread copy, so pool teardown happens exactly once on a mode flip.
|
||||||
/// (latchMs / dropped); this shows the ISSUE side. Logged via `presenterLog` only while a
|
private var surfacePresentsActive = false
|
||||||
/// windowed mechanism is active (zero cost fullscreen). Lock-guarded: transaction mode
|
|
||||||
/// records from the render thread, surface mode from Metal completion threads.
|
|
||||||
private final class WindowedPresentDiag: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var presents = 0
|
|
||||||
private var schedMs: [Double] = []
|
|
||||||
private var commitMs: [Double] = []
|
|
||||||
private var last = CACurrentMediaTime()
|
|
||||||
|
|
||||||
func record(schedMs sched: Double, commitMs commit: Double, mode: WindowedPresentMode) {
|
|
||||||
lock.lock()
|
|
||||||
presents += 1
|
|
||||||
schedMs.append(sched)
|
|
||||||
commitMs.append(commit)
|
|
||||||
let now = CACurrentMediaTime()
|
|
||||||
guard now - last >= 1 else {
|
|
||||||
lock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
last = now
|
|
||||||
let sSched = schedMs.sorted()
|
|
||||||
let sCommit = commitMs.sorted()
|
|
||||||
let line = String(
|
|
||||||
format: "pf-windowed mode=%@ presents=%d schedMs p50=%.2f max=%.2f "
|
|
||||||
+ "commitMs p50=%.2f max=%.2f",
|
|
||||||
mode.rawValue, presents, sSched[sSched.count / 2], sSched.last ?? 0,
|
|
||||||
sCommit[sCommit.count / 2], sCommit.last ?? 0)
|
|
||||||
presents = 0
|
|
||||||
schedMs.removeAll(keepingCapacity: true)
|
|
||||||
commitMs.removeAll(keepingCapacity: true)
|
|
||||||
lock.unlock()
|
|
||||||
presenterLog.info("\(line, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let windowedDiag = WindowedPresentDiag()
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let device: MTLDevice
|
private let device: MTLDevice
|
||||||
@@ -410,7 +316,7 @@ public final class MetalVideoPresenter {
|
|||||||
private let pipelinePlanar: MTLRenderPipelineState
|
private let pipelinePlanar: MTLRenderPipelineState
|
||||||
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
||||||
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
||||||
/// bgra8; tvOS without HDR headroom).
|
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents).
|
||||||
private let pipelinePlanarHDR: MTLRenderPipelineState
|
private let pipelinePlanarHDR: MTLRenderPipelineState
|
||||||
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||||
private var textureCache: CVMetalTextureCache?
|
private var textureCache: CVMetalTextureCache?
|
||||||
@@ -685,48 +591,17 @@ public final class MetalVideoPresenter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Park the windowed present mechanism (MAIN thread — the hosting view pushes its window
|
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
||||||
/// state on every layout; SessionPresenter resolves the mechanism per session). `.async` =
|
/// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents
|
||||||
/// FULLSCREEN (or the user opted out of the mitigation): the image queue. `.transaction` /
|
/// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path.
|
||||||
/// `.surface` = COMPOSITED (windowed) mitigation mechanisms — see `WindowedPresentMode`.
|
|
||||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||||
func setWindowedPresent(_ mode: WindowedPresentMode) {
|
public func setSurfacePresents(_ on: Bool) {
|
||||||
stagingLock.lock()
|
stagingLock.lock()
|
||||||
windowedPresentStaged = mode
|
surfacePresentsStaged = on
|
||||||
stagingLock.unlock()
|
stagingLock.unlock()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Deadline pacing only, RENDER THREAD: reconcile the layer with a decoded frame BEFORE a
|
|
||||||
/// drawable exists. The link vends from the layer's CURRENT config, and the layer starts
|
|
||||||
/// with `drawableSize` 0 (it never tracks bounds once set explicitly, and the sublayer's
|
|
||||||
/// frame isn't even laid out when the link spins up) — so leaving all reconciliation to the
|
|
||||||
/// render path (which needs a frame AND a vended drawable) deadlocks at session start:
|
|
||||||
/// every vend fails allocation at 0×0, the stash stays empty, no pair ever completes, and
|
|
||||||
/// the size is never set. The 2026-07-19 iPad black screen ("[CAMetalLayer nextDrawable]
|
|
||||||
/// returning nil because allocation failed" every refresh). Called on EVERY frame arrival:
|
|
||||||
/// drains the same staging the render path drains (both are idempotent about it) and
|
|
||||||
/// applies size + HDR config, so the next vend always matches the frame about to present —
|
|
||||||
/// this also makes a mid-session HDR flip cost at most one skipped vend instead of waiting
|
|
||||||
/// for a paired present to retag the layer.
|
|
||||||
func reconcileLayer(decodedSize: CGSize, isHDR: Bool) {
|
|
||||||
stagingLock.lock()
|
|
||||||
let targetFromLayout = drawableTarget
|
|
||||||
let newHdrMeta = pendingHdrMeta
|
|
||||||
pendingHdrMeta = nil
|
|
||||||
stagingLock.unlock()
|
|
||||||
configure(hdr: isHDR)
|
|
||||||
if let newHdrMeta {
|
|
||||||
self.lastHdrMeta = newHdrMeta
|
|
||||||
#if !os(tvOS)
|
|
||||||
if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
|
||||||
? targetFromLayout : decodedSize
|
|
||||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
|
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
|
||||||
/// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR`
|
/// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR`
|
||||||
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
||||||
@@ -742,15 +617,10 @@ public final class MetalVideoPresenter {
|
|||||||
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
|
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
|
||||||
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
|
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
|
||||||
/// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B).
|
/// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B).
|
||||||
///
|
|
||||||
/// `into drawable` (deadline pacing) supplies the CAMetalDisplayLink-vended drawable to
|
|
||||||
/// render into instead of calling `nextDrawable()` — see `encodePresent` for the format
|
|
||||||
/// guard that skips a vend the layer's config outran.
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func render(
|
public func render(
|
||||||
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
||||||
presentAtMediaTime: CFTimeInterval? = nil,
|
presentAtMediaTime: CFTimeInterval? = nil,
|
||||||
into drawable: CAMetalDrawable? = nil,
|
|
||||||
onPresented: ((Int64?) -> Void)? = nil
|
onPresented: ((Int64?) -> Void)? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
||||||
@@ -804,8 +674,7 @@ public final class MetalVideoPresenter {
|
|||||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||||
return encodePresent(
|
return encodePresent(
|
||||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||||
onPresented: onPresented,
|
|
||||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
||||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
||||||
keepAlive: [luma, chroma, pixelBuffer]
|
keepAlive: [luma, chroma, pixelBuffer]
|
||||||
@@ -824,17 +693,40 @@ public final class MetalVideoPresenter {
|
|||||||
func renderPlanar(
|
func renderPlanar(
|
||||||
_ planes: WaveletPlanes,
|
_ planes: WaveletPlanes,
|
||||||
presentAtMediaTime: CFTimeInterval? = nil,
|
presentAtMediaTime: CFTimeInterval? = nil,
|
||||||
into drawable: CAMetalDrawable? = nil,
|
|
||||||
onPresented: ((Int64?) -> Void)? = nil
|
onPresented: ((Int64?) -> Void)? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
stagingLock.lock()
|
stagingLock.lock()
|
||||||
let targetFromLayout = drawableTarget
|
let targetFromLayout = drawableTarget
|
||||||
|
#if os(macOS)
|
||||||
|
let surfaceMode = surfacePresentsStaged
|
||||||
|
#endif
|
||||||
stagingLock.unlock()
|
stagingLock.unlock()
|
||||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path —
|
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path;
|
||||||
// including macOS windowed sessions, which keep real HDR (the DCP mitigation is the
|
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
|
||||||
// transactional present in `encodePresent`, not a colour downgrade).
|
#if os(macOS)
|
||||||
|
configure(hdr: planes.pq && !surfaceMode)
|
||||||
|
#else
|
||||||
configure(hdr: planes.pq)
|
configure(hdr: planes.pq)
|
||||||
|
#endif
|
||||||
var csc = planes.csc
|
var csc = planes.csc
|
||||||
|
#if os(macOS)
|
||||||
|
if surfaceMode != surfacePresentsActive {
|
||||||
|
surfacePresentsActive = surfaceMode
|
||||||
|
presenterLog.info(
|
||||||
|
"stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)")
|
||||||
|
if !surfaceMode {
|
||||||
|
// Back to the metal path (fullscreen): drop the pool — at 5K it holds >100 MB,
|
||||||
|
// and re-entering windowed mode rebuilds it in one frame.
|
||||||
|
surfacePool.removeAll()
|
||||||
|
surfacePoolSize = .zero
|
||||||
|
lastHandedOff = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if surfaceMode {
|
||||||
|
return renderPlanarToSurface(
|
||||||
|
planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
||||||
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
||||||
// in-shader instead (the pipeline must match the drawable's pixel format).
|
// in-shader instead (the pipeline must match the drawable's pixel format).
|
||||||
@@ -850,8 +742,7 @@ public final class MetalVideoPresenter {
|
|||||||
return encodePresent(
|
return encodePresent(
|
||||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||||
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
||||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||||
onPresented: onPresented,
|
|
||||||
// The ring textures stay valid by ring depth; retaining them here also pins the
|
// The ring textures stay valid by ring depth; retaining them here also pins the
|
||||||
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||||
keepAlive: [planes.y, planes.cb, planes.cr]
|
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||||
@@ -863,20 +754,124 @@ public final class MetalVideoPresenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the
|
||||||
|
/// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a
|
||||||
|
/// plain CATransaction — an ordinary damaged-layer update on WindowServer's own composite
|
||||||
|
/// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply
|
||||||
|
/// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped
|
||||||
|
/// with CLOCK_REALTIME then — the closest observable analogue of "reached glass" here (the
|
||||||
|
/// composite follows within a refresh, so the meters' display stage reads slightly optimistic).
|
||||||
|
private func renderPlanarToSurface(
|
||||||
|
_ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform,
|
||||||
|
onPresented: ((Int64?) -> Void)?
|
||||||
|
) -> Bool {
|
||||||
|
let decodedSize = CGSize(width: planes.width, height: planes.height)
|
||||||
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
|
? targetFromLayout : decodedSize
|
||||||
|
ensureSurfacePool(size: targetSize)
|
||||||
|
guard let slotIndex = takeSurfaceSlot(),
|
||||||
|
let commandBuffer = queue.makeCommandBuffer()
|
||||||
|
else { return false }
|
||||||
|
let slot = surfacePool[slotIndex]
|
||||||
|
|
||||||
|
let pass = MTLRenderPassDescriptor()
|
||||||
|
pass.colorAttachments[0].texture = slot.texture
|
||||||
|
pass.colorAttachments[0].loadAction = .clear
|
||||||
|
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||||
|
pass.colorAttachments[0].storeAction = .store
|
||||||
|
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
encoder.setRenderPipelineState(planes.pq ? pipelinePlanarToneMap : pipelinePlanar)
|
||||||
|
encoder.setFragmentTexture(planes.y, index: 0)
|
||||||
|
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||||
|
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||||
|
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||||
|
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||||
|
encoder.endEncoding()
|
||||||
|
let surface = slot.surface
|
||||||
|
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||||
|
let keepAlive: [Any] = [planes.y, planes.cb, planes.cr]
|
||||||
|
commandBuffer.addCompletedHandler { _ in
|
||||||
|
_ = keepAlive // ring textures pinned until the GPU finished sampling
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
surfaceLayer.contents = surface
|
||||||
|
CATransaction.commit()
|
||||||
|
onPresented?(
|
||||||
|
Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
commandBuffer.commit()
|
||||||
|
lastHandedOff = slotIndex
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Re)build the pool at `size` — 4 BGRA8 IOSurface render targets (one on glass, one queued
|
||||||
|
/// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty;
|
||||||
|
/// the caller returns false and the ring's putBack + display-link retry take over.
|
||||||
|
private func ensureSurfacePool(size: CGSize) {
|
||||||
|
guard size != surfacePoolSize else { return }
|
||||||
|
surfacePool.removeAll()
|
||||||
|
surfacePoolSize = size
|
||||||
|
lastHandedOff = nil
|
||||||
|
let w = Int(size.width)
|
||||||
|
let h = Int(size.height)
|
||||||
|
guard w > 0, h > 0 else { return }
|
||||||
|
// 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||||
|
let bytesPerRow = ((w * 4) + 255) & ~255
|
||||||
|
let props: [String: Any] = [
|
||||||
|
kIOSurfaceWidth as String: w,
|
||||||
|
kIOSurfaceHeight as String: h,
|
||||||
|
kIOSurfaceBytesPerElement as String: 4,
|
||||||
|
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||||
|
kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA,
|
||||||
|
]
|
||||||
|
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||||
|
pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||||
|
desc.usage = [.renderTarget]
|
||||||
|
desc.storageMode = .shared
|
||||||
|
for _ in 0..<4 {
|
||||||
|
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||||
|
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||||
|
else {
|
||||||
|
surfacePool.removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||||
|
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||||
|
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||||
|
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||||
|
private func takeSurfaceSlot() -> Int? {
|
||||||
|
guard !surfacePool.isEmpty else { return nil }
|
||||||
|
var free: Int?
|
||||||
|
var busy: Int?
|
||||||
|
for i in surfacePool.indices where i != lastHandedOff {
|
||||||
|
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||||
|
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||||
|
} else {
|
||||||
|
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let pick = free ?? busy else { return nil }
|
||||||
|
surfaceSeq += 1
|
||||||
|
surfacePool[pick].seq = surfaceSeq
|
||||||
|
return pick
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||||
/// the present and the on-glass callback.
|
/// the present and the on-glass callback.
|
||||||
///
|
|
||||||
/// `providedDrawable` (deadline pacing) is the CAMetalDisplayLink-vended drawable to render
|
|
||||||
/// into instead of `nextDrawable()`. It was vended against the layer's config at vend time,
|
|
||||||
/// so after a mid-session reconfigure (HDR flip: `configure` above already retagged the
|
|
||||||
/// layer) its pixel format can lag the pipeline's attachment format — encoding would be a
|
|
||||||
/// Metal validation failure. The guard returns false instead: the drawable drops back to
|
|
||||||
/// the pool, the caller re-rings the frame, and the link's next vend carries the new format.
|
|
||||||
private func encodePresent(
|
private func encodePresent(
|
||||||
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||||
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
|
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
||||||
onPresented: ((Int64?) -> Void)?,
|
|
||||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||||
@@ -889,47 +884,11 @@ public final class MetalVideoPresenter {
|
|||||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
? targetFromLayout : decodedSize
|
? targetFromLayout : decodedSize
|
||||||
// Under a provided (link-vended) drawable this sizes the NEXT vend — the one in hand
|
|
||||||
// keeps its size, and a live-resize transient composites via contentsGravity as ever.
|
|
||||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
guard let drawable = layer.nextDrawable(),
|
||||||
// Windowed (composited) → the DCP swapID-panic mitigation mechanism (see
|
|
||||||
// `WindowedPresentMode`). Toggle the layer property BEFORE vending a drawable so the
|
|
||||||
// vend matches how it will be presented; drained here on the render thread, flipped
|
|
||||||
// exactly once per mode change.
|
|
||||||
stagingLock.lock()
|
|
||||||
let windowedMode = windowedPresentStaged
|
|
||||||
stagingLock.unlock()
|
|
||||||
if windowedMode != windowedPresentActive {
|
|
||||||
windowedPresentActive = windowedMode
|
|
||||||
layer.presentsWithTransaction = windowedMode == .transaction
|
|
||||||
if windowedMode != .surface, !surfacePool.isEmpty {
|
|
||||||
// Leaving surface mode (fullscreen entry / mechanism A/B): drop the pool — at 5K
|
|
||||||
// it holds >100 MB, and re-entering rebuilds it in one frame. SessionPresenter
|
|
||||||
// clears the surface layer's contents on main.
|
|
||||||
surfacePool.removeAll()
|
|
||||||
surfacePoolSize = .zero
|
|
||||||
lastHandedOff = nil
|
|
||||||
}
|
|
||||||
presenterLog.info(
|
|
||||||
"stage2: windowed present mode \(windowedMode.rawValue, privacy: .public) (DCP swapID-panic mitigation)")
|
|
||||||
}
|
|
||||||
if windowedMode == .surface {
|
|
||||||
// No image queue at all: render into a pooled IOSurface and swap it into the
|
|
||||||
// sibling layer's contents. The drawable/queue tail below never runs.
|
|
||||||
return encodeToSurface(
|
|
||||||
targetSize: targetSize, pipeline: pipeline, onPresented: onPresented,
|
|
||||||
keepAlive: keepAlive, bind: bind)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
if let providedDrawable,
|
|
||||||
providedDrawable.texture.pixelFormat != layer.pixelFormat {
|
|
||||||
return false // config outran the vend (HDR flip) — next vend has the new format
|
|
||||||
}
|
|
||||||
guard let drawable = providedDrawable ?? layer.nextDrawable(),
|
|
||||||
let commandBuffer = queue.makeCommandBuffer()
|
let commandBuffer = queue.makeCommandBuffer()
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
@@ -963,61 +922,6 @@ public final class MetalVideoPresenter {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
|
||||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
|
||||||
#if os(macOS)
|
|
||||||
if windowedPresentActive == .transaction {
|
|
||||||
// Windowed DCP mitigation: present the drawable THROUGH a Core Animation transaction
|
|
||||||
// (`presentsWithTransaction`, set above) instead of the async image queue, so the swap
|
|
||||||
// commits with the layer tree and stays in lockstep with the compositor (no out-of-band
|
|
||||||
// flip to race WindowServer's swaps). Wait until the GPU work is scheduled (contents
|
|
||||||
// will be ready — p50 ~0.1 ms), then present inside an EXPLICIT CATransaction ON THIS
|
|
||||||
// RENDER THREAD and `flush()`. `presentAtMediaTime` does not apply — the transaction
|
|
||||||
// paces.
|
|
||||||
//
|
|
||||||
// Threading history, because BOTH failure modes shipped or nearly shipped:
|
|
||||||
// • A bare `present()` from this thread (no transaction) never flushes — nothing
|
|
||||||
// commits a runloop-less thread's implicit transaction, so drawables are never
|
|
||||||
// released; after maximumDrawableCount vends `nextDrawable()` blocks forever and
|
|
||||||
// the stream FREEZES (the fullscreen→windowed switch did exactly this).
|
|
||||||
// • The explicit begin/commit alone is NOT enough either: this thread has an ACTIVE
|
|
||||||
// implicit transaction (the layer mutations above — drawableSize/colour — created
|
|
||||||
// it), so the explicit transaction NESTS inside it and its commit defers to the
|
|
||||||
// implicit one that never comes. The harness reproduced the exact freeze: every
|
|
||||||
// present reported presentedTime=0, nothing reached glass. `CATransaction.flush()`
|
|
||||||
// pushes the implicit transaction (present included) to the render server NOW.
|
|
||||||
// • The original fix hopped to MAIN and presented there — correct, but slow in the
|
|
||||||
// field (presents=55 @ fps=240, display_p50 18.6 ms on the 240 Hz Studio): each
|
|
||||||
// present lands a runloop turn late, and main's own implicit transaction batches
|
|
||||||
// enrolled presents at runloop-iteration rate. Kept as PUNKTFUNK_TXN_PRESENT=main.
|
|
||||||
// The off-main commit measured immune to main-thread churn in the harness
|
|
||||||
// (2026-07-21: glass p50 ~10 ms at 240 Hz full-size, cadence a clean 4.17 ms).
|
|
||||||
commandBuffer.commit()
|
|
||||||
let schedStart = CACurrentMediaTime()
|
|
||||||
commandBuffer.waitUntilScheduled()
|
|
||||||
let schedMs = (CACurrentMediaTime() - schedStart) * 1000
|
|
||||||
let commitStart = CACurrentMediaTime()
|
|
||||||
if txnPresentOnMain {
|
|
||||||
let presentedDrawable = drawable
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
CATransaction.begin()
|
|
||||||
CATransaction.setDisableActions(true)
|
|
||||||
presentedDrawable.present()
|
|
||||||
CATransaction.commit()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
CATransaction.begin()
|
|
||||||
CATransaction.setDisableActions(true)
|
|
||||||
drawable.present()
|
|
||||||
CATransaction.commit()
|
|
||||||
CATransaction.flush()
|
|
||||||
}
|
|
||||||
windowedDiag.record(
|
|
||||||
schedMs: schedMs, commitMs: (CACurrentMediaTime() - commitStart) * 1000,
|
|
||||||
mode: .transaction)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
|
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
|
||||||
// immediate otherwise. A target already in the past presents immediately — same thing.
|
// immediate otherwise. A target already in the past presents immediately — same thing.
|
||||||
if let presentAtMediaTime {
|
if let presentAtMediaTime {
|
||||||
@@ -1025,146 +929,12 @@ public final class MetalVideoPresenter {
|
|||||||
} else {
|
} else {
|
||||||
commandBuffer.present(drawable)
|
commandBuffer.present(drawable)
|
||||||
}
|
}
|
||||||
|
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||||
|
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||||
commandBuffer.commit()
|
commandBuffer.commit()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// The WINDOWED `surface` present tail (see `WindowedPresentMode.surface`): render with the
|
|
||||||
/// same per-frame pipeline into a pooled IOSurface and hand it to `surfaceLayer.contents`
|
|
||||||
/// from the command buffer's COMPLETION handler, inside an explicit CATransaction + flush
|
|
||||||
/// (the same off-main commit discipline as the transactional present — an ordinary
|
|
||||||
/// damaged-layer update on WindowServer's own composite cadence, no image queue anywhere).
|
|
||||||
/// RENDER THREAD. `onPresented` is stamped right after the contents swap commits — the
|
|
||||||
/// closest observable analogue of "reached glass" here (the composite follows within a
|
|
||||||
/// refresh, so the display-stage meters read slightly OPTIMISTIC in this mode).
|
|
||||||
///
|
|
||||||
/// The pool tracks `hdrActive`: bgra8 for SDR, rgba16Float tagged BT.2100 PQ for HDR —
|
|
||||||
/// `configure` already ran, so the caller's `pipeline` attachment format always matches.
|
|
||||||
/// HDR OPEN RISK (why this whole mode is a prototype): whether the compositor honors the
|
|
||||||
/// PQ tag + EDR for plain-CALayer IOSurface contents needs an on-glass eyeball; the metal
|
|
||||||
/// layer underneath keeps `wantsExtendedDynamicRangeContent` as the EDR anchor (the harness
|
|
||||||
/// measured the display's EDR headroom engaging with this arrangement).
|
|
||||||
private func encodeToSurface(
|
|
||||||
targetSize: CGSize, pipeline: MTLRenderPipelineState,
|
|
||||||
onPresented: ((Int64?) -> Void)?,
|
|
||||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
|
||||||
) -> Bool {
|
|
||||||
ensureSurfacePool(size: targetSize, hdr: hdrActive)
|
|
||||||
guard let slotIndex = takeSurfaceSlot(),
|
|
||||||
let commandBuffer = queue.makeCommandBuffer()
|
|
||||||
else { return false }
|
|
||||||
let slot = surfacePool[slotIndex]
|
|
||||||
|
|
||||||
let pass = MTLRenderPassDescriptor()
|
|
||||||
pass.colorAttachments[0].texture = slot.texture
|
|
||||||
pass.colorAttachments[0].loadAction = .clear
|
|
||||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
|
||||||
pass.colorAttachments[0].storeAction = .store
|
|
||||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
encoder.setRenderPipelineState(pipeline)
|
|
||||||
bind(encoder)
|
|
||||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
|
||||||
encoder.endEncoding()
|
|
||||||
let surface = slot.surface
|
|
||||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
|
||||||
let diag = windowedDiag
|
|
||||||
let commitStamp = CACurrentMediaTime()
|
|
||||||
commandBuffer.addCompletedHandler { _ in
|
|
||||||
_ = keepAlive // sources pinned until the GPU finished sampling
|
|
||||||
let completedAt = CACurrentMediaTime()
|
|
||||||
// Swap on THIS Metal completion thread: explicit transaction + flush, so the commit
|
|
||||||
// reaches the render server now, independent of main (completion handlers for one
|
|
||||||
// queue fire in execution order, so swaps can't reorder).
|
|
||||||
CATransaction.begin()
|
|
||||||
CATransaction.setDisableActions(true)
|
|
||||||
surfaceLayer.contents = surface
|
|
||||||
CATransaction.commit()
|
|
||||||
CATransaction.flush()
|
|
||||||
diag.record(
|
|
||||||
schedMs: (completedAt - commitStamp) * 1000,
|
|
||||||
commitMs: (CACurrentMediaTime() - completedAt) * 1000, mode: .surface)
|
|
||||||
onPresented?(Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
|
||||||
}
|
|
||||||
commandBuffer.commit()
|
|
||||||
lastHandedOff = slotIndex
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// (Re)build the pool at `size`/`hdr` — 4 IOSurface render targets (one on glass, one
|
|
||||||
/// committed in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the
|
|
||||||
/// pool empty; the caller returns false and the ring's putBack + display-link retry take
|
|
||||||
/// over.
|
|
||||||
private func ensureSurfacePool(size: CGSize, hdr: Bool) {
|
|
||||||
guard size != surfacePoolSize || hdr != surfacePoolHDR else { return }
|
|
||||||
surfacePool.removeAll()
|
|
||||||
surfacePoolSize = size
|
|
||||||
surfacePoolHDR = hdr
|
|
||||||
lastHandedOff = nil
|
|
||||||
let w = Int(size.width)
|
|
||||||
let h = Int(size.height)
|
|
||||||
guard w > 0, h > 0 else { return }
|
|
||||||
// rgba16Float (8 B/px) carries the PQ-encoded HDR samples; bgra8 the SDR ones. 256-byte
|
|
||||||
// row alignment satisfies both IOSurface and Metal linear-texture rules.
|
|
||||||
let bytesPerElement = hdr ? 8 : 4
|
|
||||||
let bytesPerRow = ((w * bytesPerElement) + 255) & ~255
|
|
||||||
let props: [String: Any] = [
|
|
||||||
kIOSurfaceWidth as String: w,
|
|
||||||
kIOSurfaceHeight as String: h,
|
|
||||||
kIOSurfaceBytesPerElement as String: bytesPerElement,
|
|
||||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
|
||||||
kIOSurfacePixelFormat as String: hdr
|
|
||||||
? kCVPixelFormatType_64RGBAHalf : kCVPixelFormatType_32BGRA,
|
|
||||||
]
|
|
||||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
|
||||||
pixelFormat: hdr ? .rgba16Float : .bgra8Unorm, width: w, height: h, mipmapped: false)
|
|
||||||
desc.usage = [.renderTarget]
|
|
||||||
desc.storageMode = .shared
|
|
||||||
for _ in 0..<4 {
|
|
||||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
|
||||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
|
||||||
else {
|
|
||||||
surfacePool.removeAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hdr, let name = CGColorSpace(name: CGColorSpace.itur_2100_PQ)?.name {
|
|
||||||
// Tag the surface BT.2100 PQ so the compositor interprets the half-float
|
|
||||||
// samples as PQ-encoded HDR (the CALayer-contents analogue of the metal
|
|
||||||
// layer's colorspace).
|
|
||||||
IOSurfaceSetValue(surface, "IOSurfaceColorSpace" as CFString, name)
|
|
||||||
}
|
|
||||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
|
||||||
}
|
|
||||||
// The EDR request rides the SURFACE layer too (its contents are what composite); the
|
|
||||||
// metal layer underneath keeps its own from configureColor as the anchor. Layer flags
|
|
||||||
// are committed by the next swap's transaction flush.
|
|
||||||
surfaceLayer.wantsExtendedDynamicRangeContent = hdr
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
|
||||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
|
||||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
|
||||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
|
||||||
private func takeSurfaceSlot() -> Int? {
|
|
||||||
guard !surfacePool.isEmpty else { return nil }
|
|
||||||
var free: Int?
|
|
||||||
var busy: Int?
|
|
||||||
for i in surfacePool.indices where i != lastHandedOff {
|
|
||||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
|
||||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
|
||||||
} else {
|
|
||||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let pick = free ?? busy else { return nil }
|
|
||||||
surfaceSeq += 1
|
|
||||||
surfacePool[pick].seq = surfaceSeq
|
|
||||||
return pick
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
|
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
|
||||||
/// draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
/// draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
||||||
private func makeTexture(
|
private func makeTexture(
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
|
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
|
||||||
// (explicit VTDecompressionSession decode → CAMetalLayer) is the default — deadline-paced
|
// (explicit VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's
|
||||||
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
|
// CADisplayLink) is the default — glass-paced stage-3 on tvOS + iOS, arrival-paced stage-2 on
|
||||||
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer — the
|
// macOS (see PresenterChoice.platformDefault); stage-1 (StreamPump → AVSampleBufferDisplayLayer)
|
||||||
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
|
// is the Metal-unavailable / DEBUG fallback. The views own the platform bits — capture,
|
||||||
// Stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback.
|
// window/scale tracking, and constructing the display link — and delegate the shared presenter
|
||||||
// The views own the platform bits — capture, window/scale tracking, and constructing the
|
// lifecycle here.
|
||||||
// display link (arrival/glass pacing only; deadline pacing runs its own CAMetalDisplayLink) —
|
|
||||||
// and delegate the shared presenter lifecycle here.
|
|
||||||
//
|
//
|
||||||
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
|
||||||
|
|
||||||
@@ -30,17 +28,15 @@ public final class DisplayLinkProxy: NSObject {
|
|||||||
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which presenter a session runs. Stage-2/3/4 are the same Metal pipeline with different present
|
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
|
||||||
/// pacing (`PresentPacing` — see Stage2Pipeline for the full tradeoff): stage-2 presents on frame
|
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
|
||||||
/// arrival, stage-3 gates presents on the on-glass callback, stage-4 presents into
|
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
|
||||||
/// CAMetalDisplayLink-vended drawables (deadline pacing — iOS/tvOS only; see `PresentPacing`'s
|
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
|
||||||
/// doc for why the vsync-latching platforms need it). Stage-1 (compressed video straight to the
|
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||||
/// system layer) is a DEBUG-only diagnostic. Internal (not private) for unit tests.
|
|
||||||
enum PresenterChoice: Equatable {
|
enum PresenterChoice: Equatable {
|
||||||
case stage1
|
case stage1
|
||||||
case stage2
|
case stage2
|
||||||
case stage3
|
case stage3
|
||||||
case stage4
|
|
||||||
|
|
||||||
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
||||||
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
||||||
@@ -54,109 +50,55 @@ enum PresenterChoice: Equatable {
|
|||||||
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
|
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
|
||||||
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
|
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
|
||||||
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage — an
|
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage — an
|
||||||
/// explicit "stage2" must stay a faithful A/B of arrival pacing. "stage4" resolves only on
|
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
|
||||||
/// iOS/tvOS: macOS's present path is entangled with the sync-off/DCP-panic saga (see
|
|
||||||
/// MetalVideoPresenter's init) and stays on its proven pacings until deadline presents are
|
|
||||||
/// deliberately validated there — a synced "stage4" value maps back to the platform default.
|
|
||||||
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
|
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
|
||||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||||
switch raw {
|
switch raw {
|
||||||
case "stage1": return allowStage1 ? .stage1 : nil
|
case "stage1": return allowStage1 ? .stage1 : nil
|
||||||
case "stage2": return .stage2
|
case "stage2": return .stage2
|
||||||
case "stage3": return .stage3
|
case "stage3": return .stage3
|
||||||
case "stage4":
|
|
||||||
#if os(macOS)
|
|
||||||
return nil
|
|
||||||
#else
|
|
||||||
return .stage4
|
|
||||||
#endif
|
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
|
/// tvOS and iOS/iPadOS default to GLASS pacing: their layers ALWAYS vsync-latch presents into
|
||||||
///
|
/// the FIFO image queue (`displaySyncEnabled` is macOS-only API), so whenever the panel runs
|
||||||
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
|
/// near the stream rate — an Apple TV's fixed 60 Hz fed a 60 fps stream by construction; an
|
||||||
/// (`displaySyncEnabled` is macOS-only API), and at stream rate ≈ panel rate — an Apple TV's
|
/// iPhone/iPad fed a stream at the panel rate, which VRR (default on, preferred = stream rate)
|
||||||
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate)
|
/// makes the COMMON case — arrival pacing pins the queue at ~`maximumDrawableCount` and every
|
||||||
/// steering the panel to the stream — that queue's depth is STICKY: one burst fills it and,
|
/// frame rides ~2–3 refreshes of it (measured: ~50 ms on Apple TV; 23–30 ms at 120 Hz on
|
||||||
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued
|
/// ProMotion iPads — the 2026-07 iPad Pro 2752×2064@120 field report read display 23.1 ms on
|
||||||
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder
|
/// arrival vs 14 ms glass). The Settings picker can still force stage-2 for an A/B. macOS
|
||||||
/// read ~30 ms display on arrival (~3 refreshes of queue), 22–28 ms glass-gated at depth 2
|
/// keeps stage-2: with the layer's sync off, presents are out-of-band flips that don't queue,
|
||||||
/// (a standing queue of 2 — the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass
|
/// so arrival is genuinely lowest-latency there.
|
||||||
/// pacing (stage-3) bounds the queue but presents still serialize on the on-glass callback;
|
|
||||||
/// deadline pacing (stage-4) is the fix for the remainder: one CAMetalDisplayLink-vended
|
|
||||||
/// drawable per refresh, presented the moment a frame decodes — the queue cannot exist and
|
|
||||||
/// nothing waits on callbacks (see `PresentPacing.deadline`).
|
|
||||||
///
|
|
||||||
/// tvOS joined iOS on the deadline engine in the 2026-07 presentation rebuild
|
|
||||||
/// (design/apple-presentation-rebuild.md — the engine is field-proven on iOS and strictly
|
|
||||||
/// simpler than the glass gate it replaces; `PUNKTFUNK_PRESENTER=stage3` remains the
|
|
||||||
/// fallback lever if a TV-specific issue surfaces). macOS keeps stage-2: with the layer's
|
|
||||||
/// sync off, presents are out-of-band flips that don't queue, so arrival is genuinely
|
|
||||||
/// lowest-latency there.
|
|
||||||
static var platformDefault: PresenterChoice {
|
static var platformDefault: PresenterChoice {
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(tvOS) || os(iOS)
|
||||||
.stage4
|
.stage3
|
||||||
#else
|
#else
|
||||||
.stage2
|
.stage2
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user's presentation INTENT — what replaced the visible stage picker in the 2026-07
|
|
||||||
/// rebuild (design/apple-presentation-rebuild.md). Two intents, one engine per platform:
|
|
||||||
///
|
|
||||||
/// - `.latency` (the default): every frame shows as soon as the display can take it — the
|
|
||||||
/// newest-wins zero-queue store; network/decode jitter appears as the occasional repeat or
|
|
||||||
/// drop. This is the configuration the whole 2026-07 pacing saga optimized.
|
|
||||||
/// - `.smooth(buffer:)`: a small deliberate jitter buffer (`FrameStore.fifo`) evens the present
|
|
||||||
/// cadence at the cost of `buffer` refresh intervals of added display latency — which the HUD
|
|
||||||
/// SHOWS (only the OS floor is shaved, never the user's chosen buffer). `buffer` ∈ 1…3;
|
|
||||||
/// the "Automatic" setting (stored 0) currently maps to 2.
|
|
||||||
///
|
|
||||||
/// Mechanism stays internal: intents map onto `PresentPacing`/`FrameStore.Policy` per platform
|
|
||||||
/// in `SessionPresenter.start`; the stage ladder survives only as the PUNKTFUNK_PRESENTER debug
|
|
||||||
/// env lever. Internal (not private) for unit tests.
|
|
||||||
enum PresentPriority: Equatable {
|
|
||||||
case latency
|
|
||||||
case smooth(buffer: Int)
|
|
||||||
|
|
||||||
/// Resolve from the persisted settings: `DefaultsKey.presentPriority` ("latency" default;
|
|
||||||
/// anything but "smooth" — unset, garbage, a synced unknown future value — falls back to
|
|
||||||
/// latency) and `DefaultsKey.smoothBuffer` (0/out-of-range = Automatic = 2).
|
|
||||||
static func resolve(setting: String?, bufferSetting: Int?) -> PresentPriority {
|
|
||||||
guard setting == "smooth" else { return .latency }
|
|
||||||
let raw = bufferSetting ?? 0
|
|
||||||
return .smooth(buffer: (1...3).contains(raw) ? raw : 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The frame hand-off policy this intent runs (see `FrameStore`).
|
|
||||||
var storePolicy: FrameStorePolicy {
|
|
||||||
switch self {
|
|
||||||
case .latency: return .newestWins
|
|
||||||
case .smooth(let buffer): return .fifo(capacity: buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class SessionPresenter {
|
final class SessionPresenter {
|
||||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||||
/// default, macOS PyroWave sessions ALSO get glass gating — for SMOOTHNESS, not as the panic
|
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||||
/// fix (that is the windowed transactional present — see `setComposited`). PyroWave's wavelet
|
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
|
||||||
/// decode is near-instant Metal compute, so a network clump presents within the same
|
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
|
||||||
/// millisecond, and it is the codec that sustains stream rates above the panel's refresh; the
|
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
|
||||||
/// glass gate admits one presented-but-undisplayed swap at a time (serialized on the on-glass
|
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
|
||||||
/// callback, 100 ms stale backstop) so those bursts coalesce in the newest-wins ring instead
|
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
|
||||||
/// of flooding the queue. (Glass pacing was ALSO the original DCP-panic mitigation attempt —
|
/// decode is near-instant Metal compute, so a network clump of frames presents within the
|
||||||
/// disproven: a fully serialized stream still panicked, which is why the real fix moved to the
|
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
|
||||||
/// present mechanism.) An explicit stage-2 pick (setting/env) still forces arrival pacing —
|
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
|
||||||
/// that A/B lever must stay honest. VideoToolbox codecs keep arrival pacing: decode latency
|
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
|
||||||
/// spaces their presents.
|
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
|
||||||
|
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
|
||||||
|
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
|
||||||
|
/// of stage-2 defaults there predate any panic report.
|
||||||
static func pacing(
|
static func pacing(
|
||||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||||
) -> PresentPacing {
|
) -> PresentPacing {
|
||||||
if choice == .stage4 { return .deadline }
|
|
||||||
if choice == .stage3 { return .glass }
|
if choice == .stage3 { return .glass }
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if explicit == nil, codec == .pyrowave { return .glass }
|
if explicit == nil, codec == .pyrowave { return .glass }
|
||||||
@@ -164,42 +106,31 @@ final class SessionPresenter {
|
|||||||
return .arrival
|
return .arrival
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The glass gate's in-flight present budget (`PresentGate` capacity): 1 everywhere.
|
/// The glass gate's in-flight present budget (`PresentGate` capacity) for this platform.
|
||||||
///
|
///
|
||||||
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning
|
/// - iOS/iPadOS: 2 — one flip scanning out plus one queued for the next latch. A decoded
|
||||||
/// out + one queued, predicted ~5–8 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
|
/// frame presents immediately (the gate is open in steady state) and latches the very next
|
||||||
/// 22–28 ms vs depth 1's 14: any second gate slot becomes a STANDING queue — a burst fills
|
/// vsync, so the present cadence never serializes on the on-glass callback's own latency —
|
||||||
/// it, and with presents and latches then running at the same rate the occupancy never
|
/// depth 1's extra-refresh cost (the field-measured 14 ms display stage at 120 Hz, vs a
|
||||||
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded
|
/// ~half-refresh floor). The queue still can't build past two flips, so arrival pacing's
|
||||||
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue
|
/// FIFO saturation (23–30 ms) stays gone.
|
||||||
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
/// - tvOS: 1 — the proven fixed-60-Hz config. Depth 2 should shorten its display stage the
|
||||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
/// same way but is unmeasured there; A/B first (`PUNKTFUNK_GATE_DEPTH=2`), then flip.
|
||||||
|
/// - macOS: pinned to 1, env ignored — glass pacing exists there as the DCP swapID
|
||||||
|
/// kernel-panic mitigation (see `pacing`), and STRICT present serialization is its point.
|
||||||
///
|
///
|
||||||
#if os(macOS)
|
/// `PUNKTFUNK_GATE_DEPTH` (1…3) overrides on iOS/tvOS for on-device A/B, mirroring the other
|
||||||
/// Resolve the windowed (composited) present MECHANISM for this session — the DCP
|
/// presenter env levers. Internal (not private) for unit tests.
|
||||||
/// swapID-panic mitigation picker (see `WindowedPresentMode`). The
|
|
||||||
/// `PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface` env lever wins (dev A/B);
|
|
||||||
/// otherwise the user's safe-present setting: ON/unset → `.transaction` (the validated
|
|
||||||
/// mitigation), OFF → `.async` (the fast pre-mitigation path — the panic returns on
|
|
||||||
/// affected high-refresh setups; the Settings caption says so). `.surface` is currently
|
|
||||||
/// env-only (prototype — HDR-composite verification owed). Fullscreen always presents
|
|
||||||
/// async regardless (`setComposited`). Internal (not private) for unit tests.
|
|
||||||
static func windowedPresentMode(setting: Bool?, env: String?) -> WindowedPresentMode {
|
|
||||||
if let env, let mode = WindowedPresentMode(rawValue: env) { return mode }
|
|
||||||
return (setting ?? true) ? .transaction : .async
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
|
||||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — a deeper gate only builds
|
|
||||||
/// a standing queue (see above), and macOS glass pacing exists for PyroWave smoothness
|
|
||||||
/// (see `pacing`), where depth 1 is the point. Internal (not private) for unit tests.
|
|
||||||
static func gateDepth(env: String?) -> Int {
|
static func gateDepth(env: String?) -> Int {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return 1
|
return 1
|
||||||
#else
|
#else
|
||||||
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
|
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
|
||||||
|
#if os(tvOS)
|
||||||
return 1
|
return 1
|
||||||
|
#else
|
||||||
|
return 2
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,16 +139,10 @@ final class SessionPresenter {
|
|||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
private var metalLayer: CAMetalLayer?
|
private var metalLayer: CAMetalLayer?
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// The windowed present MECHANISM this session runs while composited (resolved once per
|
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
|
||||||
/// session in `start` — the user's safe-present setting + the PUNKTFUNK_WINDOWED_PRESENT
|
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
|
||||||
/// dev override) and the routing last pushed to the pipeline — see `setComposited` (the DCP
|
|
||||||
/// swapID-panic mitigation). Main-thread only, like all of this.
|
|
||||||
private var windowedMode: WindowedPresentMode = .transaction
|
|
||||||
private var windowedPresentApplied: WindowedPresentMode = .async
|
|
||||||
/// The windowed `surface` present target (sibling above `metalLayer`, transparent while
|
|
||||||
/// unused) — installed whenever stage-2 runs so a mechanism flip never has to mutate the
|
|
||||||
/// layer tree mid-session.
|
|
||||||
private var surfaceLayer: CALayer?
|
private var surfaceLayer: CALayer?
|
||||||
|
private var surfacePresentsActive = false
|
||||||
#endif
|
#endif
|
||||||
private var connection: PunktfunkConnection?
|
private var connection: PunktfunkConnection?
|
||||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||||
@@ -242,7 +167,6 @@ final class SessionPresenter {
|
|||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
presentFloorMeter: LatencyMeter? = nil,
|
|
||||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?,
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
@@ -251,76 +175,47 @@ final class SessionPresenter {
|
|||||||
stop()
|
stop()
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|
||||||
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
|
// Presenter choice — the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
|
||||||
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
|
// decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
|
||||||
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
|
// where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
|
||||||
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
|
// pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
|
||||||
// PresenterChoice.platformDefault — deadline on iOS/tvOS, arrival on macOS — overridable
|
// on macOS — see PresenterChoice.platformDefault); the settings picker is the live A/B.
|
||||||
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
|
// Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
|
||||||
// value is deliberately ignored). The user-facing choice is the INTENT
|
// default (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||||
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
|
|
||||||
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
|
|
||||||
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
|
|
||||||
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
|
|
||||||
// fallback).
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let allowStage1 = true
|
let allowStage1 = true
|
||||||
#else
|
#else
|
||||||
let allowStage1 = false
|
let allowStage1 = false
|
||||||
#endif
|
#endif
|
||||||
let explicit = PresenterChoice.explicit(
|
let explicit = PresenterChoice.explicit(
|
||||||
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
|
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||||
allowStage1: allowStage1)
|
allowStage1: allowStage1)
|
||||||
let choice = explicit ?? PresenterChoice.platformDefault
|
let choice = explicit ?? PresenterChoice.platformDefault
|
||||||
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
|
|
||||||
let priority = PresentPriority.resolve(
|
|
||||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presentPriority),
|
|
||||||
bufferSetting: UserDefaults.standard.object(forKey: DefaultsKey.smoothBuffer) as? Int)
|
|
||||||
// macOS smoothness rides arrival pacing + forced vsync scheduling; under a glass-paced
|
|
||||||
// macOS session (the PyroWave DCP mitigation) the gate already serializes on the
|
|
||||||
// display, so the FIFO alone provides the buffering.
|
|
||||||
#if os(macOS)
|
|
||||||
let vsyncPaced = priority != .latency && pacing == .arrival
|
|
||||||
#else
|
|
||||||
let vsyncPaced = false
|
|
||||||
#endif
|
|
||||||
if choice != .stage1,
|
if choice != .stage1,
|
||||||
let pipeline = Stage2Pipeline(
|
let pipeline = Stage2Pipeline(
|
||||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
presentFloorMeter: presentFloorMeter,
|
pacing: Self.pacing(
|
||||||
pacing: pacing,
|
for: choice, explicit: explicit, codec: connection.videoCodec),
|
||||||
gateDepth: Self.gateDepth(
|
gateDepth: Self.gateDepth(
|
||||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
|
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) {
|
||||||
storePolicy: priority.storePolicy,
|
|
||||||
vsyncPaced: vsyncPaced) {
|
|
||||||
let metal = pipeline.layer
|
let metal = pipeline.layer
|
||||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||||
baseLayer.addSublayer(metal)
|
baseLayer.addSublayer(metal)
|
||||||
metalLayer = metal
|
metalLayer = metal
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
windowedPresentApplied = .async
|
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
|
||||||
// Resolve THIS session's windowed mechanism once (setting + dev env lever) —
|
// contents) while the metal path presents, covering it while surface presents run.
|
||||||
// `setComposited` routes between it and fullscreen-async from every layout.
|
|
||||||
windowedMode = Self.windowedPresentMode(
|
|
||||||
setting: UserDefaults.standard.object(
|
|
||||||
forKey: DefaultsKey.windowedSafePresent) as? Bool,
|
|
||||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_WINDOWED_PRESENT"])
|
|
||||||
// The surface present target sits ABOVE the metal layer: transparent (nil contents)
|
|
||||||
// unless the surface mechanism actually presents, covering it while it does.
|
|
||||||
baseLayer.addSublayer(pipeline.surfaceLayer)
|
baseLayer.addSublayer(pipeline.surfaceLayer)
|
||||||
surfaceLayer = pipeline.surfaceLayer
|
surfaceLayer = pipeline.surfaceLayer
|
||||||
|
surfacePresentsActive = false
|
||||||
#endif
|
#endif
|
||||||
stage2 = pipeline
|
stage2 = pipeline
|
||||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||||
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||||
// link's own report of the current refresh period (tracks VRR rate changes).
|
// link's own report of the current refresh period (tracks VRR rate changes).
|
||||||
// DEADLINE pacing needs neither: its CAMetalDisplayLink (pipeline-owned) is the vsync
|
|
||||||
// clock, and every one of its updates re-checks the ring, which IS the retry tick —
|
|
||||||
// a second link would only fight it over the frame-rate hint.
|
|
||||||
if pacing != .deadline {
|
|
||||||
let proxy = DisplayLinkProxy { [weak self] link in
|
let proxy = DisplayLinkProxy { [weak self] link in
|
||||||
self?.stage2?.renderTick(
|
self?.stage2?.renderTick(
|
||||||
targetMediaTime: link.targetTimestamp,
|
targetMediaTime: link.targetTimestamp,
|
||||||
@@ -329,7 +224,6 @@ final class SessionPresenter {
|
|||||||
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
||||||
link.add(to: .main, forMode: .common)
|
link.add(to: .main, forMode: .common)
|
||||||
stage2Link = link
|
stage2Link = link
|
||||||
}
|
|
||||||
syncFrameRate(hz: connection.currentMode().refreshHz)
|
syncFrameRate(hz: connection.currentMode().refreshHz)
|
||||||
pipeline.start(
|
pipeline.start(
|
||||||
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||||
@@ -357,12 +251,7 @@ final class SessionPresenter {
|
|||||||
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
||||||
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
||||||
private func syncFrameRate(hz: UInt32) {
|
private func syncFrameRate(hz: UInt32) {
|
||||||
guard hz > 0 else { return }
|
guard hz > 0, let link = stage2Link else { return }
|
||||||
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
|
|
||||||
// applied from the link's own thread — see Stage2Pipeline.setFrameRateHint). A no-op
|
|
||||||
// under arrival/glass pacing, where the CADisplayLink below is the one hinted link.
|
|
||||||
stage2?.setFrameRateHint(hz: Float(hz))
|
|
||||||
guard let link = stage2Link else { return }
|
|
||||||
let hzF = Float(hz)
|
let hzF = Float(hz)
|
||||||
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -456,23 +345,19 @@ final class SessionPresenter {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
||||||
/// every layout, which fullscreen transitions always trigger). A COMPOSITED (windowed)
|
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
|
||||||
/// session presents through this session's resolved mitigation mechanism (`windowedMode` —
|
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
|
||||||
/// transactional by default, see `windowedPresentMode`) instead of the async image queue —
|
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
|
||||||
/// the DCP "mismatched swapID's" kernel-panic mitigation (see `MetalVideoPresenter`; the
|
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
|
||||||
/// async-swap race survives glass pacing, so pacing alone was not enough). ALL codecs:
|
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
|
||||||
/// PyroWave hit it 2026-07-18 and windowed HEVC hit the same 240 Hz Mac Studio 2026-07-21 —
|
/// HDR/EDR presentation has no surface-contents equivalent wired.
|
||||||
/// it is the async image queue itself, not any codec or present rate. Fullscreen keeps the
|
|
||||||
/// async path (direct scanout, lowest latency, no panic there). The full HDR/EDR render
|
|
||||||
/// path is preserved in every mechanism.
|
|
||||||
func setComposited(_ composited: Bool) {
|
func setComposited(_ composited: Bool) {
|
||||||
guard let stage2 else { return }
|
guard let stage2, let connection else { return }
|
||||||
let mode: WindowedPresentMode = composited ? windowedMode : .async
|
let wantsSurface = composited && connection.videoCodec == .pyrowave
|
||||||
guard mode != windowedPresentApplied else { return }
|
guard wantsSurface != surfacePresentsActive else { return }
|
||||||
let wasSurface = windowedPresentApplied == .surface
|
surfacePresentsActive = wantsSurface
|
||||||
windowedPresentApplied = mode
|
stage2.setSurfacePresents(wantsSurface)
|
||||||
stage2.setWindowedPresent(mode)
|
if !wantsSurface {
|
||||||
if wasSurface {
|
|
||||||
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
||||||
// entry shows the previous frame until the next present — no black flash).
|
// entry shows the previous frame until the next present — no black flash).
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
@@ -499,7 +384,7 @@ final class SessionPresenter {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
surfaceLayer?.removeFromSuperlayer()
|
surfaceLayer?.removeFromSuperlayer()
|
||||||
surfaceLayer = nil
|
surfaceLayer = nil
|
||||||
windowedPresentApplied = .async
|
surfacePresentsActive = false
|
||||||
#endif
|
#endif
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,11 @@
|
|||||||
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
||||||
// period ahead by construction, falling back to immediate when the link data is stale — a
|
// period ahead by construction, falling back to immediate when the link data is stale — a
|
||||||
// schedule can never sit far in the future holding drawables hostage.
|
// schedule can never sit far in the future holding drawables hostage.
|
||||||
// • Present PACING is the stage-2/3/4 presenter split (`PresentPacing`, chosen per session by
|
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
||||||
// SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||||
// frame arrival; stage-3 additionally gates presents on the on-glass callback (`PresentGate`)
|
// frame arrival; stage-3 additionally gates presents to a bounded number of undisplayed
|
||||||
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into
|
// drawables (the gate depth — see PresentPacing + SessionPresenter.gateDepth) so the layer's
|
||||||
// CAMetalDisplayLink-vended drawables the moment a frame decodes — deadline pacing, where the
|
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||||
// queue cannot exist at all — see PresentPacing's doc for the full rationale. Under deadline
|
|
||||||
// pacing the render thread below is fed by BOTH the decoder callback and the link's per-refresh
|
|
||||||
// updates (which vend the drawable), and the V-Sync policy/vsync clock don't apply.
|
|
||||||
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||||
//
|
//
|
||||||
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
||||||
@@ -44,7 +41,6 @@ import Foundation
|
|||||||
import Metal
|
import Metal
|
||||||
import PunktfunkShared
|
import PunktfunkShared
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
import os
|
|
||||||
|
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||||
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for
|
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for
|
||||||
@@ -52,127 +48,33 @@ import os
|
|||||||
/// stdout is the cheapest reliable capture channel.
|
/// stdout is the cheapest reliable capture channel.
|
||||||
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
||||||
|
|
||||||
/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") — the
|
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
||||||
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
|
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
||||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
private final class ReadyRing: @unchecked Sendable {
|
||||||
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
|
||||||
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
|
||||||
|
|
||||||
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
|
||||||
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
|
||||||
/// replaced the visible stage picker):
|
|
||||||
///
|
|
||||||
/// - `.newestWins` (Prioritize lowest latency, the default): a 1-slot ring — the decoder
|
|
||||||
/// overwrites (drops the older undisplayed frame), the render thread takes-and-clears. Zero
|
|
||||||
/// store by construction: any deeper app-held buffer ahead of a latch-paced display becomes a
|
|
||||||
/// STANDING queue costing one full refresh per slot, forever (the depth-2 gate post-mortem —
|
|
||||||
/// see SessionPresenter.gateDepth).
|
|
||||||
/// - `.fifo(capacity: K)` (Prioritize smoothness): a small deliberate jitter buffer. The
|
|
||||||
/// decoder appends; overflow drops the OLDEST (bounded added latency — the newest keeps
|
|
||||||
/// flowing); the render thread pops the oldest ONE per present opportunity, so the cadence is
|
|
||||||
/// the display's. `take` withholds frames until the buffer has PREROLLED to capacity once —
|
|
||||||
/// without preroll a steady stream drains every frame on arrival and headroom never builds —
|
|
||||||
/// and re-arms preroll when it runs dry (an underflow: the previous frame persists on glass,
|
|
||||||
/// a repeat by omission, while headroom rebuilds). Each buffered frame ≈ one refresh interval
|
|
||||||
/// of jitter absorbed for one interval of added display latency, which the metrics SHOW —
|
|
||||||
/// only the OS present floor is shaved from the HUD, never the user's chosen buffer.
|
|
||||||
///
|
|
||||||
/// Sendable; lock-guarded — decoder callbacks and the render thread cross here.
|
|
||||||
public enum FrameStorePolicy: Sendable, Equatable {
|
|
||||||
case newestWins
|
|
||||||
case fifo(capacity: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
public final class FrameStore<Frame>: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let capacity: Int // 1 = newest-wins semantics
|
private var frame: ReadyFrame?
|
||||||
private let isFifo: Bool
|
/// Ring submissions since the last `drainSubmitted` — the decode rate for the
|
||||||
private var frames: [Frame] = []
|
/// PUNKTFUNK_PRESENT_DEBUG stat line.
|
||||||
private var prerolled = false
|
|
||||||
/// Submissions since the last `drainSubmitted` — the decode rate for the pf-present line.
|
|
||||||
private var submitted = 0
|
private var submitted = 0
|
||||||
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and
|
func submit(_ f: ReadyFrame) {
|
||||||
/// runs-dry that re-armed preroll.
|
lock.lock(); frame = f; submitted += 1; lock.unlock()
|
||||||
private var overflowDrops = 0
|
|
||||||
private var underflows = 0
|
|
||||||
|
|
||||||
public init(policy: FrameStorePolicy) {
|
|
||||||
switch policy {
|
|
||||||
case .newestWins:
|
|
||||||
capacity = 1
|
|
||||||
isFifo = false
|
|
||||||
case .fifo(let k):
|
|
||||||
capacity = max(1, k)
|
|
||||||
isFifo = true
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func submit(_ f: Frame) {
|
|
||||||
lock.lock()
|
|
||||||
if isFifo {
|
|
||||||
frames.append(f)
|
|
||||||
if frames.count > capacity {
|
|
||||||
frames.removeFirst() // oldest goes — bounded latency, the newest keeps flowing
|
|
||||||
overflowDrops += 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
frames = [f] // newest wins; the replaced frame is the intended drop point
|
|
||||||
}
|
|
||||||
submitted += 1
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func drainSubmitted() -> Int {
|
func drainSubmitted() -> Int {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
let n = submitted; submitted = 0; return n
|
||||||
|
}
|
||||||
|
func take() -> ReadyFrame? {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
let f = frame; frame = nil; return f
|
||||||
|
}
|
||||||
|
/// Return a frame the display link took but could not present (a transient `nextDrawable`
|
||||||
|
/// failure). Kept only while the slot is still empty — a newer decoded frame wins, so
|
||||||
|
/// newest-ready ordering is preserved. Without this, a failed render silently LOSES the
|
||||||
|
/// frame, and under the host's infinite GOP a static scene sends no replacement until the
|
||||||
|
/// next damage — the stale picture would persist.
|
||||||
|
func putBack(_ f: ReadyFrame) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
if frame == nil { frame = f }
|
||||||
let n = submitted
|
|
||||||
submitted = 0
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take-and-reset the smoothness counters (the pf-present `qDrop`/`qDry` stats).
|
|
||||||
func drainSmoothing() -> (overflowDrops: Int, underflows: Int) {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
let out = (overflowDrops, underflows)
|
|
||||||
overflowDrops = 0
|
|
||||||
underflows = 0
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func take() -> Frame? {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
if isFifo {
|
|
||||||
if !prerolled {
|
|
||||||
guard frames.count >= capacity else { return nil } // still building headroom
|
|
||||||
prerolled = true
|
|
||||||
}
|
|
||||||
guard !frames.isEmpty else {
|
|
||||||
underflows += 1 // ran dry — repeat by omission, rebuild headroom
|
|
||||||
prerolled = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return frames.removeFirst()
|
|
||||||
}
|
|
||||||
let f = frames.first
|
|
||||||
frames.removeAll(keepingCapacity: true)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a frame the render thread took but could not present (no drawable yet, or a
|
|
||||||
/// transient render failure). Newest-wins keeps it only while the slot is still empty — a
|
|
||||||
/// newer decoded frame wins; FIFO reinserts it at the FRONT (it is the oldest; a transient
|
|
||||||
/// capacity+1 is trimmed by the next submit). Without this, a failed present silently LOSES
|
|
||||||
/// the frame, and under the host's infinite GOP a static scene sends no replacement until
|
|
||||||
/// the next damage — the stale picture would persist.
|
|
||||||
func putBack(_ f: Frame) {
|
|
||||||
lock.lock()
|
|
||||||
if isFifo {
|
|
||||||
frames.insert(f, at: 0)
|
|
||||||
} else if frames.isEmpty {
|
|
||||||
frames = [f]
|
|
||||||
}
|
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,24 +117,13 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
||||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||||
/// "fixed-interval" jitter reports).
|
/// "fixed-interval" jitter reports).
|
||||||
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
|
/// - `glass` (stage-3, the tvOS + iOS default): at most a small BOUNDED number of presented-but-
|
||||||
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
/// undisplayed drawables in flight (`PresentGate`; the depth — 1 or 2 — is per-platform, see
|
||||||
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
/// `SessionPresenter.gateDepth`). The render thread presents only while a gate slot is free (a
|
||||||
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
/// drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
||||||
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
||||||
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||||
/// explicit, correct frame drops. The residual cost: presents serialize on the on-glass
|
/// explicit, correct frame drops.
|
||||||
/// callback, whose own delivery latency pushes each present ~a refresh past the frame's decode
|
|
||||||
/// (the field-measured 14 ms display stage at 120 Hz vs the ~half-refresh floor).
|
|
||||||
/// - `deadline` (stage-4, the iOS/iPadOS default; iOS/tvOS only — see
|
|
||||||
/// `PresenterChoice.explicit`): a CAMetalDisplayLink vends ONE drawable per refresh
|
|
||||||
/// (`preferredFrameLatency` 1) into a newest-wins hand-off slot, and the render thread pairs
|
|
||||||
/// it with the newest decoded frame THE MOMENT either half arrives — usually the frame, into
|
|
||||||
/// an already-vended drawable. The image queue cannot exist (one vended drawable in flight,
|
|
||||||
/// ever), nothing serializes on the on-glass callback (the link's next vend is the pace), and
|
|
||||||
/// the present is deadline-timed by the system to latch the upcoming refresh. This is the only
|
|
||||||
/// pacing whose steady state can reach the sub-refresh display floor on the always-vsync-latch
|
|
||||||
/// platforms; `arrival`/`glass` remain the on-device A/B rungs.
|
|
||||||
///
|
///
|
||||||
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
|
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
|
||||||
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
|
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
|
||||||
@@ -241,122 +132,6 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
public enum PresentPacing: Sendable {
|
public enum PresentPacing: Sendable {
|
||||||
case arrival
|
case arrival
|
||||||
case glass
|
case glass
|
||||||
case deadline
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Newest-wins 1-slot hand-off box (the generic sibling of `ReadyRing`): deadline pacing's
|
|
||||||
/// drawable stash — the link thread `put`s each update's vended drawable (replacing an
|
|
||||||
/// unpresented older one, which just returns to the layer's pool), the render thread `take`s.
|
|
||||||
/// `putBack` returns a taken value only while the slot is still empty, so a fresher `put` from
|
|
||||||
/// the other thread is never clobbered by a stale return. Internal (not private) for unit tests.
|
|
||||||
/// Sendable; lock-guarded.
|
|
||||||
final class LatestBox<T>: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var value: T?
|
|
||||||
func put(_ v: T) { lock.lock(); value = v; lock.unlock() }
|
|
||||||
func putBack(_ v: T) {
|
|
||||||
lock.lock()
|
|
||||||
if value == nil { value = v }
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
func take() -> T? {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
let v = value
|
|
||||||
value = nil
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
|
|
||||||
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
|
|
||||||
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
|
|
||||||
/// floor is PINNED at the stream rate — no idle ramp-down: with a low floor the link idles toward
|
|
||||||
/// it on a static scene (infinite GOP ⇒ no frames), and the first damage frame after idle would
|
|
||||||
/// wait out a slow tick before it could present. Empty wakes at stream rate are near-free; the
|
|
||||||
/// PANEL still idles via VRR because no presents happen. Sendable; lock-guarded.
|
|
||||||
private final class FrameRateHint: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var pending: CAFrameRateRange?
|
|
||||||
func stage(hz: Float) {
|
|
||||||
guard hz > 0 else { return }
|
|
||||||
lock.lock()
|
|
||||||
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
func drain() -> CAFrameRateRange? {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
let p = pending
|
|
||||||
pending = nil
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
|
|
||||||
/// vended drawable (newest wins) and nudges the render thread — which also wakes on decoder
|
|
||||||
/// arrivals, so whichever half completes the (frame, drawable) pair triggers the present. Also
|
|
||||||
/// applies the staged frame-rate hint from the link's own thread. Retained by the link thread's
|
|
||||||
/// closure (the link holds it weak); captures only the shared boxes, never the pipeline — the
|
|
||||||
/// same no-self-capture rule as the pump/render threads.
|
|
||||||
private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
|
||||||
private let stash: LatestBox<CAMetalDrawable>
|
|
||||||
private let renderSignal: DispatchSemaphore
|
|
||||||
private let hint: FrameRateHint
|
|
||||||
private let stats: PresentDebugStats?
|
|
||||||
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vend→glass
|
|
||||||
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
|
|
||||||
/// shown display/e2e numbers. Self-adapting — reads ~2 refresh periods composited today,
|
|
||||||
/// would read ~1 under direct-to-display, tracks VRR rate changes.
|
|
||||||
private let floorMeter: LatencyMeter?
|
|
||||||
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert —
|
|
||||||
/// reads 1 while vendLeadMs sits at ~2 periods ⇒ the scheduler ignores the request while
|
|
||||||
/// the layer is composited (the promotion hunt); reads 2 ⇒ the system clamped it outright.
|
|
||||||
private var loggedEffective = false
|
|
||||||
|
|
||||||
init(
|
|
||||||
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
|
||||||
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
|
|
||||||
) {
|
|
||||||
self.stash = stash
|
|
||||||
self.renderSignal = renderSignal
|
|
||||||
self.hint = hint
|
|
||||||
self.stats = stats
|
|
||||||
self.floorMeter = floorMeter
|
|
||||||
}
|
|
||||||
|
|
||||||
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
|
||||||
if let range = hint.drain(), link.preferredFrameRateRange != range {
|
|
||||||
link.preferredFrameRateRange = range
|
|
||||||
}
|
|
||||||
// Re-assert the minimum-latency request every update (cheap compare): it was set once
|
|
||||||
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
|
|
||||||
// thing the vendLeadMs stat exists to catch — belt and braces.
|
|
||||||
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
|
|
||||||
if !loggedEffective {
|
|
||||||
loggedEffective = true
|
|
||||||
let range = link.preferredFrameRateRange
|
|
||||||
let msg = String(
|
|
||||||
format: "deadline link up: effective preferredFrameLatency=%.2f "
|
|
||||||
+ "range=%.0f-%.0f preferred=%.0f",
|
|
||||||
link.preferredFrameLatency, range.minimum, range.maximum, range.preferred ?? 0)
|
|
||||||
presentLog.info("\(msg, privacy: .public)")
|
|
||||||
}
|
|
||||||
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
|
|
||||||
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
|
|
||||||
stats?.vendLead(ms: leadS * 1000)
|
|
||||||
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
|
|
||||||
// now − lead) — its 1 s p50 is the OS present floor SessionModel shaves off.
|
|
||||||
if leadS > 0, let floorMeter {
|
|
||||||
var ts = timespec()
|
|
||||||
clock_gettime(CLOCK_REALTIME, &ts)
|
|
||||||
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
|
||||||
floorMeter.record(
|
|
||||||
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
|
|
||||||
}
|
|
||||||
stash.put(update.drawable)
|
|
||||||
renderSignal.signal()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables.
|
/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables.
|
||||||
@@ -431,20 +206,10 @@ final class PresentGate: @unchecked Sendable {
|
|||||||
private final class PresentDebugStats: @unchecked Sendable {
|
private final class PresentDebugStats: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var last = CACurrentMediaTime()
|
private var last = CACurrentMediaTime()
|
||||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0
|
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
|
||||||
private var maxRenderMs = 0.0
|
private var maxRenderMs = 0.0
|
||||||
private var lastGlassNs: Int64 = 0
|
private var lastGlassNs: Int64 = 0
|
||||||
private var glassDeltasMs: [Double] = []
|
private var glassDeltasMs: [Double] = []
|
||||||
/// Present-issue → on-glass delay per frame (system presentedTime minus the render call's
|
|
||||||
/// start) — the DIRECT decomposition of the display stage: ring/pairing wait lives upstream
|
|
||||||
/// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here;
|
|
||||||
/// a healthy latch reads under one period.
|
|
||||||
private var latchMs: [Double] = []
|
|
||||||
/// Deadline pacing: the link's own pipeline depth — `targetPresentationTimestamp - now` at
|
|
||||||
/// each update. ~1 period means preferredFrameLatency=1 is honored (a vended drawable can
|
|
||||||
/// reach glass at the NEXT refresh); ~2 periods means the system is running a frame ahead
|
|
||||||
/// and one whole refresh of the display stage lives INSIDE the link, not in our pairing.
|
|
||||||
private var vendLeadMs: [Double] = []
|
|
||||||
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
||||||
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
|
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
|
||||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
|
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
|
||||||
@@ -458,14 +223,6 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
/// is normal, it just shows the gate working.
|
/// is normal, it just shows the gate working.
|
||||||
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
||||||
|
|
||||||
/// Deadline pacing: a decoded frame is waiting but the link hasn't vended this interval's
|
|
||||||
/// drawable yet — the frame presents on the link's next update. A high count just means
|
|
||||||
/// decode outruns the link's phase; the wait is bounded by one refresh.
|
|
||||||
func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() }
|
|
||||||
|
|
||||||
/// Deadline pacing, LINK thread: one update's vend-to-target distance (see `vendLeadMs`).
|
|
||||||
func vendLead(ms: Double) { lock.lock(); vendLeadMs.append(ms); lock.unlock() }
|
|
||||||
|
|
||||||
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
if rendered {
|
if rendered {
|
||||||
@@ -479,61 +236,42 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func presented(atNs: Int64?, issuedNs: Int64) {
|
func presented(atNs: Int64?) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
||||||
if let atNs {
|
if let atNs {
|
||||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||||
lastGlassNs = atNs
|
lastGlassNs = atNs
|
||||||
latchMs.append(Double(atNs - issuedNs) / 1e6)
|
|
||||||
} else {
|
} else {
|
||||||
dropped += 1
|
dropped += 1
|
||||||
}
|
}
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) {
|
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
let now = CACurrentMediaTime()
|
let now = CACurrentMediaTime()
|
||||||
guard now - last >= 1 else { lock.unlock(); return }
|
guard now - last >= 1 else { lock.unlock(); return }
|
||||||
last = now
|
last = now
|
||||||
let decoded = ring.drainSubmitted()
|
let decoded = ring.drainSubmitted()
|
||||||
let smoothing = ring.drainSmoothing()
|
|
||||||
let deltas = glassDeltasMs.sorted()
|
let deltas = glassDeltasMs.sorted()
|
||||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||||
let dMax = deltas.last ?? 0
|
let dMax = deltas.last ?? 0
|
||||||
let latches = latchMs.sorted()
|
|
||||||
let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2]
|
|
||||||
let latchMax = latches.last ?? 0
|
|
||||||
let vends = vendLeadMs.sorted()
|
|
||||||
let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2]
|
|
||||||
let vendMax = vends.last ?? 0
|
|
||||||
let inflightMax = maxInFlight
|
let inflightMax = maxInFlight
|
||||||
let line = String(
|
let line = String(
|
||||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
|
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
|
||||||
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
|
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||||
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
|
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
|
||||||
+ "vendLeadMs p50=%.2f max=%.2f",
|
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
|
||||||
decoded, ok, failed, empty, gated, noDrawable, dropped,
|
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
|
||||||
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
|
|
||||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
|
|
||||||
vendP50, vendMax)
|
|
||||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
|
|
||||||
maxRenderMs = 0
|
maxRenderMs = 0
|
||||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||||
glassDeltasMs.removeAll(keepingCapacity: true)
|
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||||
latchMs.removeAll(keepingCapacity: true)
|
|
||||||
vendLeadMs.removeAll(keepingCapacity: true)
|
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
// Console.app first (the on-device readout — see presentLog); stdout only under the env
|
|
||||||
// lever (the CLI client's capture channel).
|
|
||||||
presentLog.info("\(line, privacy: .public)")
|
|
||||||
if presentDebug {
|
|
||||||
print(line)
|
print(line)
|
||||||
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's
|
/// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's
|
||||||
/// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in
|
/// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in
|
||||||
@@ -558,27 +296,18 @@ private final class DecodeReport: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public final class Stage2Pipeline {
|
public final class Stage2Pipeline {
|
||||||
private let ring: FrameStore<ReadyFrame>
|
private let ring = ReadyRing()
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
private let decoder: VideoDecoder
|
private let decoder: VideoDecoder
|
||||||
/// Present cadence — `.arrival` (stage-2), `.glass` (stage-3, the present gate) or
|
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
||||||
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime;
|
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||||
/// SessionPresenter resolves it per session (see PresentPacing).
|
|
||||||
private let pacing: PresentPacing
|
private let pacing: PresentPacing
|
||||||
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
||||||
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
||||||
private let gateDepth: Int
|
private let gateDepth: Int
|
||||||
/// macOS smoothness: pace presents onto the vsync grid (`present(at:)` via the VsyncClock),
|
|
||||||
/// at most one per vsync, so the FIFO store drains on the display's cadence rather than on
|
|
||||||
/// arrival. Ignored under `.deadline` (the link IS the cadence there).
|
|
||||||
private let vsyncPaced: Bool
|
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
/// The measured OS present floor (deadline pacing only): each link update's vend→glass lead
|
|
||||||
/// is recorded here, and its p50 is what SessionModel subtracts from the shown display/e2e
|
|
||||||
/// numbers — the pipeline-depth cost no client controls (design/apple-presentation-rebuild.md).
|
|
||||||
private let presentFloorMeter: LatencyMeter?
|
|
||||||
private let recovery = KeyframeRecovery()
|
private let recovery = KeyframeRecovery()
|
||||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||||
/// binds the live connection + arming flag (see DecodeReport).
|
/// binds the live connection + arming flag (see DecodeReport).
|
||||||
@@ -609,9 +338,6 @@ public final class Stage2Pipeline {
|
|||||||
private let vsyncClock = VsyncClock()
|
private let vsyncClock = VsyncClock()
|
||||||
private let renderStopped = DispatchSemaphore(value: 0)
|
private let renderStopped = DispatchSemaphore(value: 0)
|
||||||
private var renderJoinable = false
|
private var renderJoinable = false
|
||||||
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
|
|
||||||
/// Created unconditionally (cheap); only the deadline link thread drains it.
|
|
||||||
private let frameRateHint = FrameRateHint()
|
|
||||||
|
|
||||||
/// The Metal layer the hosting view installs + sizes.
|
/// The Metal layer the hosting view installs + sizes.
|
||||||
public var layer: CAMetalLayer { presenter.layer }
|
public var layer: CAMetalLayer { presenter.layer }
|
||||||
@@ -628,22 +354,16 @@ public final class Stage2Pipeline {
|
|||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
presentFloorMeter: LatencyMeter? = nil,
|
|
||||||
pacing: PresentPacing = .arrival,
|
pacing: PresentPacing = .arrival,
|
||||||
gateDepth: Int = 1,
|
gateDepth: Int = 1
|
||||||
storePolicy: FrameStorePolicy = .newestWins,
|
|
||||||
vsyncPaced: Bool = false
|
|
||||||
) {
|
) {
|
||||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.pacing = pacing
|
self.pacing = pacing
|
||||||
self.gateDepth = gateDepth
|
self.gateDepth = gateDepth
|
||||||
self.vsyncPaced = vsyncPaced
|
|
||||||
self.ring = FrameStore(policy: storePolicy)
|
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
self.presentFloorMeter = presentFloorMeter
|
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let renderSignal = renderSignal
|
let renderSignal = renderSignal
|
||||||
@@ -818,17 +538,6 @@ public final class Stage2Pipeline {
|
|||||||
pumpJoinable = true
|
pumpJoinable = true
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
// The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink
|
|
||||||
// vends the drawables and its per-refresh updates co-drive the render thread — see
|
|
||||||
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
|
|
||||||
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
|
|
||||||
// streams to Console.app via presentLog — the on-device pacing decomposition).
|
|
||||||
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
|
|
||||||
if pacing == .deadline {
|
|
||||||
startDeadlinePresenter(debugStats: debugStats)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The render thread: one present per display-link signal. It owns every layer format/colour/
|
// The render thread: one present per display-link signal. It owns every layer format/colour/
|
||||||
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
|
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
|
||||||
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
|
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
|
||||||
@@ -843,21 +552,16 @@ public final class Stage2Pipeline {
|
|||||||
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
||||||
// Resolved once per session.
|
// Resolved once per session.
|
||||||
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
||||||
// `vsyncPaced` (macOS smoothness) FORCES vsync scheduling — the FIFO store must drain
|
let vsyncEnabled = presentMode == "vsync"
|
||||||
// on the display cadence, one frame per vsync, or the buffer degenerates to arrival.
|
|
||||||
let vsyncPaced = vsyncPaced
|
|
||||||
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
|
|
||||||
|| (presentMode != "immediate"
|
|| (presentMode != "immediate"
|
||||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||||
|
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||||
let vsyncClock = vsyncClock
|
let vsyncClock = vsyncClock
|
||||||
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
|
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
|
||||||
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
|
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||||
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
||||||
let renderThread = Thread {
|
let renderThread = Thread {
|
||||||
defer { renderStopped.signal() }
|
defer { renderStopped.signal() }
|
||||||
// macOS smoothness: the vsync this thread last presented onto — at most ONE present
|
|
||||||
// per vsync so the FIFO drains on the display's cadence. Thread-confined.
|
|
||||||
var lastPresentTarget: CFTimeInterval = 0
|
|
||||||
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
||||||
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||||
// without a per-iteration pool every presented frame's drawable object (plus its
|
// without a per-iteration pool every presented frame's drawable object (plus its
|
||||||
@@ -867,15 +571,6 @@ public final class Stage2Pipeline {
|
|||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Smoothness pacing: this vsync's present slot already taken — the frame stays
|
|
||||||
// in the store, and the next display-link tick re-signals. (Tolerance well under
|
|
||||||
// any refresh period; a stale clock ⇒ nil target ⇒ no dedup, present flows.)
|
|
||||||
if vsyncPaced, let t = vsyncClock.nextVsync(after: CACurrentMediaTime()),
|
|
||||||
abs(t - lastPresentTarget) < 0.002 {
|
|
||||||
debugStats?.gatedWake()
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
||||||
// keep coalescing there (newest wins, the intended drop point) and the presented
|
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||||
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
||||||
@@ -896,7 +591,6 @@ public final class Stage2Pipeline {
|
|||||||
let presentAt = vsyncEnabled
|
let presentAt = vsyncEnabled
|
||||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||||
let renderStarted = CACurrentMediaTime()
|
let renderStarted = CACurrentMediaTime()
|
||||||
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
|
||||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||||
// then re-signal so the freshest waiting ring frame goes out immediately.
|
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||||
@@ -914,7 +608,7 @@ public final class Stage2Pipeline {
|
|||||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||||
// so no skew offset applies.
|
// so no skew offset applies.
|
||||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||||
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
debugStats?.presented(atNs: presentedNs)
|
||||||
}
|
}
|
||||||
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
||||||
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||||
@@ -933,8 +627,6 @@ public final class Stage2Pipeline {
|
|||||||
if !rendered {
|
if !rendered {
|
||||||
gate?.release() // no present registered — its handler will never fire
|
gate?.release() // no present registered — its handler will never fire
|
||||||
ring.putBack(frame)
|
ring.putBack(frame)
|
||||||
} else if vsyncPaced, let presentAt {
|
|
||||||
lastPresentTarget = presentAt // this vsync's slot is now taken
|
|
||||||
}
|
}
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
} }
|
} }
|
||||||
@@ -945,168 +637,16 @@ public final class Stage2Pipeline {
|
|||||||
renderThread.start()
|
renderThread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deadline pacing's present half (stage-4 — see `PresentPacing.deadline`): a
|
|
||||||
/// CAMetalDisplayLink on its own runloop thread vends ONE drawable per refresh into the
|
|
||||||
/// newest-wins stash, and the render thread pairs it with the newest decoded frame the
|
|
||||||
/// moment either half completes the pair — the common case is a decoded frame presenting
|
|
||||||
/// instantly into an already-vended drawable, which the system then latches at the upcoming
|
|
||||||
/// refresh (`preferredFrameLatency` 1). No image queue can form (one vended drawable in
|
|
||||||
/// flight, ever) and nothing serializes on the on-glass callback. An unpresented stashed
|
|
||||||
/// drawable is simply replaced by the next update (back to the layer's pool), so the stash
|
|
||||||
/// is never stale by more than a refresh while the link runs.
|
|
||||||
///
|
|
||||||
/// Threading mirrors the arrival/glass half: neither thread captures `self`; the link is
|
|
||||||
/// created, driven and invalidated entirely on its own thread (CAMetalDisplayLink is only
|
|
||||||
/// ever touched there — the frame-rate hint crosses via `FrameRateHint`); the link thread's
|
|
||||||
/// runloop iterations each drain an autorelease pool (a vended CAMetalDrawable is
|
|
||||||
/// autoreleased like a `nextDrawable()` one — see the render loop's identical rule); the
|
|
||||||
/// 100 ms runloop horizon is the stop-flag poll, so teardown is bounded without a join.
|
|
||||||
private func startDeadlinePresenter(debugStats: PresentDebugStats?) {
|
|
||||||
let token = token
|
|
||||||
let ring = ring
|
|
||||||
let renderSignal = renderSignal
|
|
||||||
let renderStopped = renderStopped
|
|
||||||
let presenter = presenter
|
|
||||||
let endToEndMeter = endToEndMeter
|
|
||||||
let displayMeter = displayMeter
|
|
||||||
let offsetNs = offsetNs
|
|
||||||
let hint = frameRateHint
|
|
||||||
let layer = presenter.layer
|
|
||||||
let stash = LatestBox<CAMetalDrawable>()
|
|
||||||
|
|
||||||
let floorMeter = presentFloorMeter
|
|
||||||
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
|
||||||
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
|
|
||||||
// drawableSize for the whole connect window: every vend fails allocation and the system
|
|
||||||
// logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per
|
|
||||||
// refresh until the first frame arrives. Before that frame there is nothing to present
|
|
||||||
// anyway, and the first frame waits at most one refresh for the first vend.
|
|
||||||
let startLink: () -> Void = {
|
|
||||||
let linkThread = Thread {
|
|
||||||
let delegate = DeadlineLinkDelegate(
|
|
||||||
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
|
||||||
floorMeter: floorMeter)
|
|
||||||
let link = CAMetalDisplayLink(metalLayer: layer)
|
|
||||||
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
|
||||||
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
|
||||||
link.delegate = delegate // weak — this closure is the strong ref
|
|
||||||
link.add(to: RunLoop.current, forMode: .default)
|
|
||||||
while !token.isStopped {
|
|
||||||
autoreleasepool {
|
|
||||||
_ = RunLoop.current.run(
|
|
||||||
mode: .default, before: Date(timeIntervalSinceNow: 0.1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
link.invalidate()
|
|
||||||
}
|
|
||||||
linkThread.name = "punktfunk-stage4-link"
|
|
||||||
linkThread.qualityOfService = .userInteractive
|
|
||||||
linkThread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
let renderThread = Thread {
|
|
||||||
defer { renderStopped.signal() }
|
|
||||||
// Whether startLink ran — render-thread confined (only this thread triggers it).
|
|
||||||
var linkLive = false
|
|
||||||
// Per-iteration autorelease pool — same contract as the arrival/glass loop (the
|
|
||||||
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
|
|
||||||
while !token.isStopped { autoreleasepool {
|
|
||||||
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Present needs the PAIR — frame first. The frame drives the layer reconcile,
|
|
||||||
// which must run even when NO drawable is vended yet: the link vends from the
|
|
||||||
// layer's CURRENT config, so drawableSize/format have to be right before a vend
|
|
||||||
// can succeed at all (see reconcileLayer — the session-start bootstrap, where
|
|
||||||
// the layer still has its initial 0×0 size and every vend fails allocation).
|
|
||||||
guard !token.isStopped, let frame = ring.take() else {
|
|
||||||
debugStats?.emptyWake()
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch frame.image {
|
|
||||||
case .video(let pixelBuffer, let isHDR):
|
|
||||||
presenter.reconcileLayer(
|
|
||||||
decodedSize: CGSize(
|
|
||||||
width: CVPixelBufferGetWidth(pixelBuffer),
|
|
||||||
height: CVPixelBufferGetHeight(pixelBuffer)),
|
|
||||||
isHDR: isHDR)
|
|
||||||
case .planar(let planes):
|
|
||||||
presenter.reconcileLayer(
|
|
||||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
|
||||||
isHDR: planes.pq)
|
|
||||||
}
|
|
||||||
// First frame: the layer now has a real config — start vending (see startLink).
|
|
||||||
if !linkLive {
|
|
||||||
linkLive = true
|
|
||||||
startLink()
|
|
||||||
}
|
|
||||||
guard let drawable = stash.take() else {
|
|
||||||
// No vend yet (session start: the reconcile above just unblocked the
|
|
||||||
// allocator, the link's next update delivers; steady state: decode beat the
|
|
||||||
// link's phase). putBack keeps newest-wins — a fresher decode replaces this
|
|
||||||
// frame while it waits, and the update's signal retries the pairing.
|
|
||||||
ring.putBack(frame)
|
|
||||||
debugStats?.noDrawableWake()
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let renderStarted = CACurrentMediaTime()
|
|
||||||
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
|
||||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
|
||||||
let atNs = presentedNs
|
|
||||||
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
|
|
||||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
|
||||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
|
||||||
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
|
||||||
}
|
|
||||||
let rendered: Bool
|
|
||||||
switch frame.image {
|
|
||||||
case .video(let pixelBuffer, let isHDR):
|
|
||||||
rendered = presenter.render(
|
|
||||||
pixelBuffer, isHDR: isHDR, into: drawable, onPresented: onGlass)
|
|
||||||
case .planar(let planes):
|
|
||||||
rendered = presenter.renderPlanar(
|
|
||||||
planes, into: drawable, onPresented: onGlass)
|
|
||||||
}
|
|
||||||
debugStats?.renderReturned(
|
|
||||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
|
||||||
if !rendered {
|
|
||||||
// The vended drawable is spent either way (an unused/mismatched one drops
|
|
||||||
// back to the pool); the frame retries on the link's next vend. A format
|
|
||||||
// mismatch (mid-session HDR flip caught between the layer reconfigure and
|
|
||||||
// the next vend) self-heals the same way — see encodePresent's guard.
|
|
||||||
ring.putBack(frame)
|
|
||||||
}
|
|
||||||
debugStats?.flushIfDue(ring: ring, gate: nil)
|
|
||||||
} }
|
|
||||||
}
|
|
||||||
renderThread.name = "punktfunk-stage2-render"
|
|
||||||
renderThread.qualityOfService = .userInteractive
|
|
||||||
renderJoinable = true
|
|
||||||
renderThread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
|
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
|
||||||
/// and nudge the render thread. The nudge is NOT the presentation trigger — frame arrival is
|
/// and nudge the render thread. The nudge is NOT the presentation trigger — frame arrival is
|
||||||
/// (see the header) — it only retries a frame a transient `nextDrawable` failure put back into
|
/// (see the header) — it only retries a frame a transient `nextDrawable` failure put back into
|
||||||
/// the ring, which matters under the host's infinite GOP where a static scene sends no
|
/// the ring, which matters under the host's infinite GOP where a static scene sends no
|
||||||
/// replacement frame. Arrival/glass pacing only — deadline sessions have no CADisplayLink
|
/// replacement frame.
|
||||||
/// (their CAMetalDisplayLink's updates are both clock and retry).
|
|
||||||
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
||||||
vsyncClock.set(target: targetMediaTime, period: period)
|
vsyncClock.set(target: targetMediaTime, period: period)
|
||||||
renderSignal.signal()
|
renderSignal.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MAIN thread (SessionPresenter — session start + every layout/Reconfigure): hint the
|
|
||||||
/// deadline link with the stream cadence. Staged; the link's own thread applies it (see
|
|
||||||
/// `FrameRateHint`). No-op under arrival/glass pacing, where the hosting view's CADisplayLink
|
|
||||||
/// is the hinted link.
|
|
||||||
public func setFrameRateHint(hz: Float) {
|
|
||||||
frameRateHint.stage(hz: hz)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
||||||
/// `MetalVideoPresenter.setDrawableTarget`).
|
/// `MetalVideoPresenter.setDrawableTarget`).
|
||||||
public func setDrawableTarget(_ size: CGSize) {
|
public func setDrawableTarget(_ size: CGSize) {
|
||||||
@@ -1114,15 +654,15 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Forward the windowed present mechanism (MAIN thread — see
|
/// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` — the
|
||||||
/// `MetalVideoPresenter.setWindowedPresent`, the DCP swapID-panic mitigation).
|
/// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`.
|
||||||
func setWindowedPresent(_ mode: WindowedPresentMode) {
|
public var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||||
presenter.setWindowedPresent(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The windowed `surface` present target the hosting SessionPresenter installs as a sibling
|
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||||
/// ABOVE `layer` (transparent while unused — see `MetalVideoPresenter.surfaceLayer`).
|
/// `MetalVideoPresenter.setSurfacePresents`).
|
||||||
var surfaceLayer: CALayer { presenter.surfaceLayer }
|
public func setSurfacePresents(_ on: Bool) {
|
||||||
|
presenter.setSurfacePresents(on)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||||
@@ -1169,7 +709,7 @@ public final class Stage2Pipeline {
|
|||||||
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||||
private static func makePyroWavePump(
|
private static func makePyroWavePump(
|
||||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||||
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
|
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
||||||
device: MTLDevice, queue: MTLCommandQueue,
|
device: MTLDevice, queue: MTLCommandQueue,
|
||||||
decodeMeter: LatencyMeter?,
|
decodeMeter: LatencyMeter?,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
@@ -1213,9 +753,7 @@ public final class Stage2Pipeline {
|
|||||||
let chunkAligned =
|
let chunkAligned =
|
||||||
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||||
let ptsNs = au.ptsNs
|
let ptsNs = au.ptsNs
|
||||||
// Decode stage starts at the PULL (matching the VT path's FrameContext —
|
let receivedNs = au.receivedNs
|
||||||
// receipt→pull is the HUD's separate client-queue term, ABI v9 split).
|
|
||||||
let receivedNs = au.pulledNs
|
|
||||||
let flags = au.flags
|
let flags = au.flags
|
||||||
let submitted = decoder.decode(
|
let submitted = decoder.decode(
|
||||||
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||||
|
|||||||
@@ -65,21 +65,19 @@ public enum Stage444Probe {
|
|||||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
||||||
|
|
||||||
var produced: OSType = 0
|
var produced: OSType = 0
|
||||||
// SYNCHRONOUS decode — no `._EnableAsynchronousDecompression`, so the output callback
|
let done = DispatchSemaphore(value: 0)
|
||||||
// runs on THIS thread before DecodeFrame returns. The async flag + semaphore wait it
|
|
||||||
// replaced tripped the Thread Performance Checker on every first connect: VideoToolbox's
|
|
||||||
// callback thread carries no QoS class, and the userInteractive connect Task blocked on
|
|
||||||
// it through the semaphore (a priority inversion). A one-shot 256×256 probe gains
|
|
||||||
// nothing from decode parallelism; the lazy statics still cache the result.
|
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
session, sampleBuffer: sample,
|
session, sampleBuffer: sample,
|
||||||
flags: [], infoFlagsOut: nil
|
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
|
||||||
) { status, _, imageBuffer, _, _ in
|
) { status, _, imageBuffer, _, _ in
|
||||||
if status == noErr, let imageBuffer {
|
if status == noErr, let imageBuffer {
|
||||||
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||||
}
|
}
|
||||||
|
done.signal()
|
||||||
}
|
}
|
||||||
guard status == noErr else { return false }
|
guard status == noErr else { return false }
|
||||||
|
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
||||||
|
_ = done.wait(timeout: .now() + 1.0)
|
||||||
return produced == want || produced == fullRangeSibling
|
return produced == want || produced == fullRangeSibling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,9 @@ public enum ReadyImage: @unchecked Sendable {
|
|||||||
public struct ReadyFrame: @unchecked Sendable {
|
public struct ReadyFrame: @unchecked Sendable {
|
||||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||||
public let ptsNs: UInt64
|
public let ptsNs: UInt64
|
||||||
/// Client `CLOCK_REALTIME` instant the AU left `nextAU` (`AccessUnit.pulledNs`, threaded
|
/// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
|
||||||
/// through the decode via the frame refcon), in nanoseconds — the decode stage's start
|
/// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
|
||||||
/// point. (Named for its historical role; since the ABI v9 receipt split the true
|
/// didn't stamp receipt) — the decode-stage meter then drops the sample via its sanity guard.
|
||||||
/// reassembly receipt lives on `AccessUnit.receivedNs`, and receipt→pull is the HUD's own
|
|
||||||
/// client-queue term.) 0 when unknown (a caller that didn't stamp) — the decode-stage meter
|
|
||||||
/// then drops the sample via its sanity guard.
|
|
||||||
public let receivedNs: Int64
|
public let receivedNs: Int64
|
||||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||||
public let decodedNs: Int64
|
public let decodedNs: Int64
|
||||||
@@ -170,11 +167,7 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
var infoOut = VTDecodeInfoFlags()
|
var infoOut = VTDecodeInfoFlags()
|
||||||
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
||||||
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
||||||
// The decode stage starts at the PULL (the AU leaving nextAU), not the reassembly
|
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||||
// receipt: both consumers — the decode-stage meter and the ABR decode signal — are
|
|
||||||
// specified from the pull, and the receipt→pull wait is the HUD's separate client-queue
|
|
||||||
// term (see AccessUnit.pulledNs).
|
|
||||||
let ctx = FrameContext(receivedNs: au.pulledNs, flags: au.flags)
|
|
||||||
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
// Apple Pencil → state-full wire pen samples (design/pen-tablet-input.md §7).
|
|
||||||
//
|
|
||||||
// Every sample carries the COMPLETE pen state (in-range/touching/buttons + all axes) — the
|
|
||||||
// host diffs consecutive samples and synthesizes down/up/button transitions itself, so a lost
|
|
||||||
// datagram self-heals and this file never sends edge events. Three sources feed one stream:
|
|
||||||
// UITouch contacts (with coalesced samples for full 240 Hz fidelity), the hover gesture
|
|
||||||
// (zOffset > 0 distinguishes a hovering Pencil from a trackpad pointer), and
|
|
||||||
// UIPencilInteraction (squeeze held → barrel 1, double-tap → a momentary barrel 2 —
|
|
||||||
// Apple Pencil has no hardware eraser end or barrel buttons, so these mappings are how
|
|
||||||
// host-side apps get their stylus button/eraser affordances).
|
|
||||||
//
|
|
||||||
// HEARTBEAT (wire contract — see `PunktfunkPenSample` in punktfunk_core.h): while the pen is
|
|
||||||
// in range or touching, the last sample repeats every ≤100 ms even when nothing changed.
|
|
||||||
// UIKit is silent for a stationary Pencil, and the host force-releases the stroke after
|
|
||||||
// 200 ms without samples (its dead-client failsafe) — the timer keeps a held stroke alive.
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import PunktfunkCore
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
|
||||||
enum Phase { case down, move, up, cancel }
|
|
||||||
|
|
||||||
/// One assembled batch (≤ `PUNKTFUNK_PEN_BATCH_MAX` samples) ready for the connection.
|
|
||||||
var send: (([PunktfunkPenSample]) -> Void)?
|
|
||||||
/// View-space point → normalized [0,1] video coordinates (the letterbox mapping the
|
|
||||||
/// touch path already uses). nil until a mode is negotiated — samples are dropped then.
|
|
||||||
var videoNorm: ((CGPoint) -> (Float, Float)?)?
|
|
||||||
|
|
||||||
private var inRange = false
|
|
||||||
private var touching = false
|
|
||||||
/// Squeeze held (mapped to wire BARREL1).
|
|
||||||
private var squeezeHeld = false
|
|
||||||
/// Whether this device/Pencil pair has demonstrated hover — decides what a lift means:
|
|
||||||
/// hover-capable hardware keeps proximity (the hover recognizer owns the exit), anything
|
|
||||||
/// else leaves range on lift so the host never parks a phantom hovering pen.
|
|
||||||
private var sawHover = false
|
|
||||||
/// A hover gesture is live right now (routes ended-state hover callbacks to us even when
|
|
||||||
/// the recognizer's final zOffset reads 0).
|
|
||||||
private(set) var hoverActive = false
|
|
||||||
private var last = PencilStream.idleSample()
|
|
||||||
private var heartbeat: Timer?
|
|
||||||
|
|
||||||
// MARK: - Contact path (UITouch, `.pencil` only)
|
|
||||||
|
|
||||||
func touches(_ touches: Set<UITouch>, event: UIEvent?, phase: Phase, in view: UIView) {
|
|
||||||
// At most one Pencil exists; a set with several is UIKit batching phases of the same
|
|
||||||
// stylus — the last one carries the freshest state.
|
|
||||||
guard let touch = touches.max(by: { $0.timestamp < $1.timestamp }) else { return }
|
|
||||||
switch phase {
|
|
||||||
case .down, .move:
|
|
||||||
touching = true
|
|
||||||
inRange = true
|
|
||||||
// Coalesced samples restore the Pencil's full capture rate (UIKit delivers at
|
|
||||||
// display cadence); oldest first, `dt_us` preserving their spacing.
|
|
||||||
let raw = event?.coalescedTouches(for: touch) ?? [touch]
|
|
||||||
var batch: [PunktfunkPenSample] = []
|
|
||||||
var prevTs: TimeInterval?
|
|
||||||
for t in raw.suffix(Int(PUNKTFUNK_PEN_BATCH_MAX)) {
|
|
||||||
guard let s = contactSample(t, in: view, prevTs: prevTs) else { continue }
|
|
||||||
prevTs = t.timestamp
|
|
||||||
batch.append(s)
|
|
||||||
}
|
|
||||||
emit(batch)
|
|
||||||
case .up:
|
|
||||||
touching = false
|
|
||||||
// Hover-capable hardware: lift back to hover, the recognizer exits range later.
|
|
||||||
// Otherwise a lift IS the range exit (mirror of the host's GameStream heuristic).
|
|
||||||
inRange = sawHover
|
|
||||||
var s = last
|
|
||||||
s.pressure = 0
|
|
||||||
s.state = stateBits()
|
|
||||||
if let posSample = contactSample(touch, in: view, prevTs: nil) {
|
|
||||||
s.x = posSample.x
|
|
||||||
s.y = posSample.y
|
|
||||||
}
|
|
||||||
emit([s])
|
|
||||||
case .cancel:
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Hover path (forwarded from the view's hover recognizer)
|
|
||||||
|
|
||||||
/// Returns whether the event was consumed as Pencil hover; `false` hands it back to the
|
|
||||||
/// pointer path. A hovering Pencil reports `zOffset > 0`; trackpad/mouse hover is 0.
|
|
||||||
func maybeHover(_ r: UIHoverGestureRecognizer, in view: UIView) -> Bool {
|
|
||||||
switch r.state {
|
|
||||||
case .began, .changed:
|
|
||||||
guard r.zOffset > 0 || hoverActive else { return false }
|
|
||||||
hoverActive = true
|
|
||||||
sawHover = true
|
|
||||||
inRange = true
|
|
||||||
touching = false
|
|
||||||
guard let (x, y) = videoNorm?(r.location(in: view)) else { return true }
|
|
||||||
var s = PencilStream.idleSample()
|
|
||||||
s.state = stateBits()
|
|
||||||
s.x = x
|
|
||||||
s.y = y
|
|
||||||
s.distance = UInt16((r.zOffset.clamped(to: 0...1) * 65534).rounded())
|
|
||||||
s.tilt_deg = Self.tiltDeg(altitude: r.altitudeAngle)
|
|
||||||
s.azimuth_deg = Self.azimuthDeg(r.azimuthAngle(in: view))
|
|
||||||
if #available(iOS 17.5, *) { s.roll_deg = Self.rollDeg(r.rollAngle) }
|
|
||||||
emit([s])
|
|
||||||
return true
|
|
||||||
case .ended, .cancelled, .failed:
|
|
||||||
guard hoverActive else { return false }
|
|
||||||
hoverActive = false
|
|
||||||
if !touching { release() }
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return hoverActive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UIPencilInteractionDelegate (squeeze → barrel 1 held, tap → barrel 2 click)
|
|
||||||
|
|
||||||
@available(iOS 17.5, *)
|
|
||||||
func pencilInteraction(
|
|
||||||
_ interaction: UIPencilInteraction, didReceiveSqueeze squeeze: UIPencilInteraction.Squeeze
|
|
||||||
) {
|
|
||||||
switch squeeze.phase {
|
|
||||||
case .began:
|
|
||||||
squeezeHeld = true
|
|
||||||
case .ended, .cancelled:
|
|
||||||
squeezeHeld = false
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard inRange || touching else { return }
|
|
||||||
var s = last
|
|
||||||
s.state = stateBits()
|
|
||||||
emit([s])
|
|
||||||
}
|
|
||||||
|
|
||||||
func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
|
|
||||||
guard inRange || touching else { return }
|
|
||||||
// A momentary barrel-2 click: press + release as two state-full samples in ONE batch
|
|
||||||
// — the host's tracker emits the button press and release in order.
|
|
||||||
var press = last
|
|
||||||
press.state = stateBits() | UInt8(PUNKTFUNK_PEN_BARREL2)
|
|
||||||
var releaseS = last
|
|
||||||
releaseS.state = stateBits()
|
|
||||||
emit([press, releaseS])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
|
||||||
|
|
||||||
/// Session stop / view teardown: leave range so the host lifts anything held.
|
|
||||||
func reset() {
|
|
||||||
if inRange || touching { release() }
|
|
||||||
sawHover = false
|
|
||||||
hoverActive = false
|
|
||||||
squeezeHeld = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func release() {
|
|
||||||
touching = false
|
|
||||||
inRange = false
|
|
||||||
var s = last
|
|
||||||
s.pressure = 0
|
|
||||||
s.state = 0
|
|
||||||
emit([s])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sample assembly
|
|
||||||
|
|
||||||
private func contactSample(
|
|
||||||
_ t: UITouch, in view: UIView, prevTs: TimeInterval?
|
|
||||||
) -> PunktfunkPenSample? {
|
|
||||||
guard let (x, y) = videoNorm?(t.location(in: view)) else { return nil }
|
|
||||||
var s = PencilStream.idleSample()
|
|
||||||
s.state = stateBits()
|
|
||||||
s.x = x
|
|
||||||
s.y = y
|
|
||||||
// maximumPossibleForce is 0 until the system knows the stylus — full force then
|
|
||||||
// (binary-stylus semantics, matching the host's unknown-pressure rule).
|
|
||||||
let maxForce = t.maximumPossibleForce
|
|
||||||
s.pressure =
|
|
||||||
maxForce > 0
|
|
||||||
? UInt16((Double(t.force / maxForce).clamped(to: 0...1) * 65535).rounded())
|
|
||||||
: UInt16.max
|
|
||||||
s.distance = 0
|
|
||||||
s.tilt_deg = Self.tiltDeg(altitude: t.altitudeAngle)
|
|
||||||
s.azimuth_deg = Self.azimuthDeg(t.azimuthAngle(in: view))
|
|
||||||
if #available(iOS 17.5, *) { s.roll_deg = Self.rollDeg(t.rollAngle) }
|
|
||||||
if let prevTs {
|
|
||||||
s.dt_us = UInt16(((t.timestamp - prevTs) * 1_000_000).clamped(to: 0...65535))
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stateBits() -> UInt8 {
|
|
||||||
var bits: UInt8 = 0
|
|
||||||
if inRange || touching { bits |= UInt8(PUNKTFUNK_PEN_IN_RANGE) }
|
|
||||||
if touching { bits |= UInt8(PUNKTFUNK_PEN_TOUCHING) }
|
|
||||||
if squeezeHeld { bits |= UInt8(PUNKTFUNK_PEN_BARREL1) }
|
|
||||||
return bits
|
|
||||||
}
|
|
||||||
|
|
||||||
private func emit(_ batch: [PunktfunkPenSample]) {
|
|
||||||
guard !batch.isEmpty else { return }
|
|
||||||
last = batch[batch.count - 1]
|
|
||||||
last.dt_us = 0
|
|
||||||
send?(batch)
|
|
||||||
armHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The ≤100 ms keepalive while in range (see the file header). 80 ms leaves headroom
|
|
||||||
/// under the host's 200 ms failsafe even with one lost datagram.
|
|
||||||
private func armHeartbeat() {
|
|
||||||
heartbeat?.invalidate()
|
|
||||||
guard inRange || touching else {
|
|
||||||
heartbeat = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
heartbeat = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) {
|
|
||||||
[weak self] _ in
|
|
||||||
guard let self, self.inRange || self.touching else {
|
|
||||||
self?.heartbeat?.invalidate()
|
|
||||||
self?.heartbeat = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.send?([self.last])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Angle conversions
|
|
||||||
|
|
||||||
/// Altitude (π/2 = perpendicular) → wire tilt-from-normal in degrees, 0...90.
|
|
||||||
private static func tiltDeg(altitude: CGFloat) -> UInt8 {
|
|
||||||
UInt8((90 - altitude * 180 / .pi).rounded().clamped(to: 0...90))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apple azimuth (0 along the view's +x axis, clockwise, y-down) → wire azimuth
|
|
||||||
/// (0 = north/up on screen, clockwise): +90° offset.
|
|
||||||
private static func azimuthDeg(_ apple: CGFloat) -> UInt16 {
|
|
||||||
let deg = (apple * 180 / .pi + 90).truncatingRemainder(dividingBy: 360)
|
|
||||||
return UInt16((deg + 360).truncatingRemainder(dividingBy: 360).rounded()) % 360
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pencil Pro roll (radians, −π...π) → wire barrel roll 0...359°.
|
|
||||||
private static func rollDeg(_ roll: CGFloat) -> UInt16 {
|
|
||||||
let deg = (roll * 180 / .pi).truncatingRemainder(dividingBy: 360)
|
|
||||||
return UInt16(((deg + 360).truncatingRemainder(dividingBy: 360)).rounded()) % 360
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All-unknown baseline: sentinel angles/distance, tool = pen (the eraser is host-side
|
|
||||||
/// state driven by the squeeze/tap mappings, not a hardware end).
|
|
||||||
private static func idleSample() -> PunktfunkPenSample {
|
|
||||||
PunktfunkPenSample(
|
|
||||||
x: 0, y: 0, pressure: 0,
|
|
||||||
distance: UInt16(PUNKTFUNK_PEN_DISTANCE_UNKNOWN),
|
|
||||||
azimuth_deg: UInt16(PUNKTFUNK_PEN_ANGLE_UNKNOWN),
|
|
||||||
roll_deg: UInt16(PUNKTFUNK_PEN_ANGLE_UNKNOWN),
|
|
||||||
dt_us: 0, state: 0,
|
|
||||||
tool: UInt8(PUNKTFUNK_PEN_TOOL_PEN),
|
|
||||||
tilt_deg: UInt8(PUNKTFUNK_PEN_TILT_UNKNOWN),
|
|
||||||
_reserved: (0, 0, 0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Comparable {
|
|
||||||
fileprivate func clamped(to range: ClosedRange<Self>) -> Self {
|
|
||||||
min(max(self, range.lowerBound), range.upperBound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -38,11 +38,10 @@ private let streamInputDebug =
|
|||||||
/// dragged deltas become the relative motion StreamLayerView forwards), and hide it.
|
/// dragged deltas become the relative motion StreamLayerView forwards), and hide it.
|
||||||
/// hide/unhide and associate are balanced via `captured`.
|
/// hide/unhide and associate are balanced via `captured`.
|
||||||
///
|
///
|
||||||
/// In the DESKTOP mouse model (absolute pointer, remote-desktop-sweep M1) this is a no-op:
|
/// In CLIENT-SIDE-CURSOR mode (gamescope, whose capture carries no host cursor) this is a
|
||||||
/// the pointer stays free (entering and leaving the stream at will) and StreamLayerView
|
/// no-op: the local cursor stays visible and free, and StreamLayerView forwards ABSOLUTE
|
||||||
/// forwards ABSOLUTE positions instead; the local cursor is hidden only while over the view
|
/// positions instead — the visible system cursor IS the on-screen cursor. `disassociate`
|
||||||
/// (cursor rects). `disassociate` selects between the two; `release()` only undoes what
|
/// selects between the two; `release()` only undoes what `capture` actually did.
|
||||||
/// `capture` actually did.
|
|
||||||
private final class CursorCapture {
|
private final class CursorCapture {
|
||||||
private var captured = false
|
private var captured = false
|
||||||
/// Whether the engaged capture actually disassociated+hid (false in cursor-visible mode),
|
/// Whether the engaged capture actually disassociated+hid (false in cursor-visible mode),
|
||||||
@@ -94,7 +93,6 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
private let presentFloorMeter: LatencyMeter?
|
|
||||||
|
|
||||||
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
||||||
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
||||||
@@ -117,8 +115,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil
|
||||||
presentFloorMeter: LatencyMeter? = nil
|
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
@@ -131,7 +128,6 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
self.presentFloorMeter = presentFloorMeter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeNSView(context: Context) -> StreamLayerView {
|
public func makeNSView(context: Context) -> StreamLayerView {
|
||||||
@@ -142,7 +138,6 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
view.presentFloorMeter = presentFloorMeter
|
|
||||||
view.onResizeTarget = onResizeTarget
|
view.onResizeTarget = onResizeTarget
|
||||||
view.onDecodedSize = onDecodedSize
|
view.onDecodedSize = onDecodedSize
|
||||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -156,7 +151,6 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
view.presentFloorMeter = presentFloorMeter
|
|
||||||
view.onResizeTarget = onResizeTarget
|
view.onResizeTarget = onResizeTarget
|
||||||
view.onDecodedSize = onDecodedSize
|
view.onDecodedSize = onDecodedSize
|
||||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||||
@@ -178,7 +172,6 @@ public final class StreamLayerView: NSView {
|
|||||||
var endToEndMeter: LatencyMeter?
|
var endToEndMeter: LatencyMeter?
|
||||||
var decodeMeter: LatencyMeter?
|
var decodeMeter: LatencyMeter?
|
||||||
var displayMeter: LatencyMeter?
|
var displayMeter: LatencyMeter?
|
||||||
var presentFloorMeter: LatencyMeter?
|
|
||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
@@ -208,43 +201,14 @@ public final class StreamLayerView: NSView {
|
|||||||
/// forwarded). Main-thread only.
|
/// forwarded). Main-thread only.
|
||||||
public private(set) var captured = false
|
public private(set) var captured = false
|
||||||
|
|
||||||
/// Desktop (absolute) mouse model — remote-desktop-sweep M1: when true the pointer is
|
/// Client-side-cursor mode: when true the local system cursor stays VISIBLE over the
|
||||||
/// never disassociated (it enters and leaves the stream freely) and the mouse monitor
|
/// stream and the mouse monitor forwards ABSOLUTE positions (the visible cursor is the
|
||||||
/// forwards ABSOLUTE positions through the letterbox; the local cursor is hidden only
|
/// on-screen cursor — gamescope draws none, so no double cursor); when false the existing
|
||||||
/// while over this view (cursor rects — the host's composited cursor, tracking our
|
/// captured/disassociated relative path runs unchanged. Initialized at session start from
|
||||||
/// sends, is the one you see) and reappears the moment it leaves. When false the
|
/// the `cursorMode` setting + the host's resolved compositor, toggled live by ⌘⇧C. A live
|
||||||
/// captured/disassociated relative path runs unchanged. Initialized at session start
|
/// flip re-engages capture in the new mode so disassociation + the abs/rel choice swap
|
||||||
/// from the `mouseMode` setting gated by the host's resolved compositor (gamescope's
|
/// atomically. Main-thread only.
|
||||||
/// EIS is relative-only — absolute sends would be dropped, so it pins to capture);
|
private var cursorVisible = false
|
||||||
/// flipped live by ⌃⌥⇧M. A live flip re-engages capture in the new model so
|
|
||||||
/// disassociation + the abs/rel choice swap atomically. Main-thread only.
|
|
||||||
private var desktopMouse = false
|
|
||||||
/// Cursor channel (M2): the host forwards shape/state and WE draw the pointer. Active
|
|
||||||
/// when the Welcome carried `HOST_CAP_CURSOR` (only sessions that advertised the client
|
|
||||||
/// cap get it). Shapes cache by serial; state is latest-wins. Main-thread only.
|
|
||||||
private var cursorChannelActive = false
|
|
||||||
/// A forwarded host cursor shape, cached RAW (not as a finished `NSCursor`) so the pointer can be
|
|
||||||
/// (re)built at the CURRENT video-fit scale — see `scaledCursor`. The host forwards the bitmap in
|
|
||||||
/// host FRAMEBUFFER pixels, whose size tracks the host's display scaling (32 px at 100%, 96 px at
|
|
||||||
/// 300% DPI); scaling by the video fit keeps the pointer sized to the streamed desktop at any host
|
|
||||||
/// scaling instead of ballooning on a high-DPI host.
|
|
||||||
private struct HostCursorShape {
|
|
||||||
let cg: CGImage
|
|
||||||
let width: Int
|
|
||||||
let height: Int
|
|
||||||
let hotX: Int
|
|
||||||
let hotY: Int
|
|
||||||
}
|
|
||||||
private var hostCursors: [UInt32: HostCursorShape] = [:]
|
|
||||||
private var cursorState: PunktfunkConnection.CursorStateEvent?
|
|
||||||
/// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip);
|
|
||||||
/// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse
|
|
||||||
/// model, so the chord, engage/release, and session start all reconcile through one path.
|
|
||||||
private var sentClientDraws: Bool?
|
|
||||||
/// M3 hint tracking: edge-triggered so a manual ⌃⌥⇧M isn't fought — the override latch
|
|
||||||
/// holds until the HOST's intent next changes.
|
|
||||||
private var lastHint: Bool?
|
|
||||||
private var hintOverride = false
|
|
||||||
/// One-shot auto-engage request (stream start, trust confirmed) — attempted as soon
|
/// One-shot auto-engage request (stream start, trust confirmed) — attempted as soon
|
||||||
/// as the view is in a window with real bounds, then dropped, so it can never fire
|
/// as the view is in a window with real bounds, then dropped, so it can never fire
|
||||||
/// surprisingly later (e.g. on a resize).
|
/// surprisingly later (e.g. on a resize).
|
||||||
@@ -470,9 +434,9 @@ public final class StreamLayerView: NSView {
|
|||||||
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
|
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
|
||||||
// frontmost), stay released so the NEXT click retries — never latch captured=true over
|
// frontmost), stay released so the NEXT click retries — never latch captured=true over
|
||||||
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
|
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
|
||||||
// In the desktop mouse model there is no grab (the pointer stays free) — capture
|
// In client-side-cursor mode there is no grab (the cursor stays visible) — capture
|
||||||
// always engages and the monitor forwards absolute positions instead.
|
// always engages and the monitor forwards absolute positions instead.
|
||||||
guard cursorCapture.capture(in: self, disassociate: !desktopMouse) else { return }
|
guard cursorCapture.capture(in: self, disassociate: !cursorVisible) else { return }
|
||||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||||
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
|
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
|
||||||
// delta (the monitor isn't up yet), and the engage click's suppression latch is
|
// delta (the monitor isn't up yet), and the engage click's suppression latch is
|
||||||
@@ -480,9 +444,7 @@ public final class StreamLayerView: NSView {
|
|||||||
installMouseMonitor()
|
installMouseMonitor()
|
||||||
captured = true
|
captured = true
|
||||||
window?.makeFirstResponder(self)
|
window?.makeFirstResponder(self)
|
||||||
window?.invalidateCursorRects(for: self) // desktop model: hide-over-view engages
|
|
||||||
notifyCaptureChange(true)
|
notifyCaptureChange(true)
|
||||||
reconcileCursorRender()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func releaseCapture() {
|
private func releaseCapture() {
|
||||||
@@ -491,194 +453,7 @@ public final class StreamLayerView: NSView {
|
|||||||
cursorCapture.release()
|
cursorCapture.release()
|
||||||
inputCapture?.setForwarding(false)
|
inputCapture?.setForwarding(false)
|
||||||
captured = false
|
captured = false
|
||||||
window?.invalidateCursorRects(for: self)
|
|
||||||
notifyCaptureChange(false)
|
notifyCaptureChange(false)
|
||||||
reconcileCursorRender() // released ⇒ the host composites the pointer again
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A fully transparent cursor for the desktop mouse model's hide-over-view rect —
|
|
||||||
/// an empty 1×1 image draws nothing.
|
|
||||||
private static let invisibleCursor = NSCursor(
|
|
||||||
image: NSImage(size: NSSize(width: 1, height: 1)), hotSpot: .zero)
|
|
||||||
|
|
||||||
/// Desktop mouse model: the local cursor is hidden while over the stream (the host's
|
|
||||||
/// composited cursor, tracking our absolute sends, is the one you see) and reappears
|
|
||||||
/// the moment it leaves the view — AppKit applies/removes the rect's cursor for us,
|
|
||||||
/// so there is no hide/unhide balancing to get wrong. Capture model instead hides
|
|
||||||
/// globally via `CursorCapture` (the pointer can't leave the view there).
|
|
||||||
override public func resetCursorRects() {
|
|
||||||
if captured && desktopMouse {
|
|
||||||
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
|
|
||||||
// video); hidden host pointer (or no shape yet) = invisible. Without the channel,
|
|
||||||
// M1 behavior: invisible local cursor, the composited host cursor is the visible one.
|
|
||||||
if cursorChannelActive, let st = cursorState, st.visible,
|
|
||||||
let shape = hostCursors[st.serial] {
|
|
||||||
addCursorRect(bounds, cursor: scaledCursor(shape))
|
|
||||||
} else {
|
|
||||||
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
super.resetCursorRects()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tell the host who renders the pointer (the §8 mid-stream render flip): we draw it only
|
|
||||||
/// while the DESKTOP model is engaged (the local OS cursor wears the host shape); under
|
|
||||||
/// the capture model — and while released — the host composites it into the video (full
|
|
||||||
/// fidelity, the pre-channel look). One edge-detected reconciler, called from every
|
|
||||||
/// transition (chord, engage/release, session start).
|
|
||||||
private func reconcileCursorRender() {
|
|
||||||
guard cursorChannelActive, let connection else { return }
|
|
||||||
let clientDraws = captured && desktopMouse
|
|
||||||
guard sentClientDraws != clientDraws else { return }
|
|
||||||
sentClientDraws = clientDraws
|
|
||||||
connection.setCursorRender(clientDraws: clientDraws)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip the mouse model with the atomic release/re-engage swap; `reappearAt` (host video
|
|
||||||
/// px — the M3 hand-back position) warps the local pointer so leaving relative lands the
|
|
||||||
/// cursor exactly where the host last had it.
|
|
||||||
private func setDesktopMouse(_ on: Bool, reappearAt: (x: Int32, y: Int32)?) {
|
|
||||||
guard desktopMouse != on else { return }
|
|
||||||
let wasCaptured = captured
|
|
||||||
if wasCaptured { releaseCapture() }
|
|
||||||
desktopMouse = on
|
|
||||||
if wasCaptured { engageCapture(fromClick: false) }
|
|
||||||
window?.invalidateCursorRects(for: self)
|
|
||||||
if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) {
|
|
||||||
CGWarpMouseCursorPosition(sp)
|
|
||||||
}
|
|
||||||
reconcileCursorRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The single cursor pull thread (both planes share the connection's cursor lock):
|
|
||||||
/// latest-wins state at a short timeout + a non-blocking shape poll per iteration.
|
|
||||||
/// Exits when the connection closes; events hop to main where all cursor state lives.
|
|
||||||
private func startCursorPump(_ connection: PunktfunkConnection) {
|
|
||||||
let thread = Thread { [weak self] in
|
|
||||||
while true {
|
|
||||||
do {
|
|
||||||
var newest: PunktfunkConnection.CursorStateEvent?
|
|
||||||
if let st = try connection.nextCursorState(timeoutMs: 100) {
|
|
||||||
newest = st
|
|
||||||
while let more = try connection.nextCursorState(timeoutMs: 0) {
|
|
||||||
newest = more // drain — latest wins
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while let shape = try connection.nextCursorShape(timeoutMs: 0) {
|
|
||||||
DispatchQueue.main.async { self?.applyCursorShape(shape) }
|
|
||||||
}
|
|
||||||
if let st = newest {
|
|
||||||
DispatchQueue.main.async { self?.applyCursorState(st) }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return // connection closed — the session is over
|
|
||||||
}
|
|
||||||
if self == nil { return }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thread.name = "pf-cursor-pump"
|
|
||||||
thread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) {
|
|
||||||
guard let shape = Self.makeShape(ev) else {
|
|
||||||
streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hostCursors.count >= 64 { hostCursors.removeAll() } // degenerate host: reset
|
|
||||||
hostCursors[ev.serial] = shape
|
|
||||||
if cursorState?.serial == ev.serial {
|
|
||||||
window?.invalidateCursorRects(for: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyCursorState(_ ev: PunktfunkConnection.CursorStateEvent) {
|
|
||||||
let prev = cursorState
|
|
||||||
cursorState = ev
|
|
||||||
if prev?.visible != ev.visible || prev?.serial != ev.serial {
|
|
||||||
window?.invalidateCursorRects(for: self)
|
|
||||||
}
|
|
||||||
// M3 host-driven auto-flip is DISABLED: `relative_hint` is derived from host cursor
|
|
||||||
// VISIBILITY, and Windows hides the pointer for ordinary desktop activity (clicking,
|
|
||||||
// typing) — not just when a game grabs it. Acting on those transients flipped
|
|
||||||
// desktop→capture→desktop, which warped the cursor to view-centre and flushed held
|
|
||||||
// buttons (a spurious button-up ~200 ms into every press → broke window drags). Until
|
|
||||||
// the host exposes a real pointer-LOCK signal (ClipCursor/raw-input, not visibility),
|
|
||||||
// the mouse model is user-driven only (⌃⌥⇧M). The hint still rides the wire, unused.
|
|
||||||
_ = (lastHint, hintOverride)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode a forwarded straight-alpha RGBA shape into a CGImage + hotspot. The on-screen SIZE is
|
|
||||||
/// NOT baked in here — it is applied per-use in `scaledCursor` from the live video-fit scale, so
|
|
||||||
/// the same shape re-fits across window resizes / retina moves without a re-forward.
|
|
||||||
private static func makeShape(_ ev: PunktfunkConnection.CursorShapeEvent) -> HostCursorShape? {
|
|
||||||
let (w, h) = (ev.width, ev.height)
|
|
||||||
guard w > 0, h > 0, ev.rgba.count >= w * h * 4,
|
|
||||||
let provider = CGDataProvider(data: ev.rgba as CFData),
|
|
||||||
let cg = CGImage(
|
|
||||||
width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 32,
|
|
||||||
bytesPerRow: w * 4, space: CGColorSpaceCreateDeviceRGB(),
|
|
||||||
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
|
|
||||||
provider: provider, decode: nil, shouldInterpolate: false,
|
|
||||||
intent: .defaultIntent)
|
|
||||||
else { return nil }
|
|
||||||
return HostCursorShape(
|
|
||||||
cg: cg, width: w, height: h,
|
|
||||||
hotX: min(ev.hotX, w - 1), hotY: min(ev.hotY, h - 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Points-per-host-pixel: the exact factor the video frame is aspect-fit into the view (the same
|
|
||||||
/// `AVMakeRect` fit `hostPoint`/`cgScreenPoint` use). The host forwards the pointer bitmap in host
|
|
||||||
/// framebuffer pixels — the mode we drive is in the client's BACKING pixels, so on retina this is
|
|
||||||
/// ~1/backingScale and the pointer lands at its TRUE size relative to the streamed desktop
|
|
||||||
/// (crisp, 1:1 with the video) rather than the 2×-inflated pixel-as-points it used to be. Because
|
|
||||||
/// the bitmap grows with the host's display scaling (96 px at 300% DPI), scaling by this is what
|
|
||||||
/// keeps a high-DPI host from forwarding a giant pointer. Falls back to 1 before the first
|
|
||||||
/// mode/layout.
|
|
||||||
private func cursorFitScale() -> CGFloat {
|
|
||||||
guard let connection else { return 1 }
|
|
||||||
let mode = connection.currentMode()
|
|
||||||
guard mode.width > 0, mode.height > 0, bounds.width > 0, bounds.height > 0 else { return 1 }
|
|
||||||
let fit = AVMakeRect(
|
|
||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), insideRect: bounds)
|
|
||||||
guard fit.width > 0 else { return 1 }
|
|
||||||
return fit.width / CGFloat(mode.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the `NSCursor` for a cached shape at the CURRENT video-fit scale (see `cursorFitScale`).
|
|
||||||
/// Both the image size and the hotspot scale together so the click point stays true.
|
|
||||||
private func scaledCursor(_ shape: HostCursorShape) -> NSCursor {
|
|
||||||
let scale = cursorFitScale()
|
|
||||||
let sw = max(1, (CGFloat(shape.width) * scale).rounded())
|
|
||||||
let sh = max(1, (CGFloat(shape.height) * scale).rounded())
|
|
||||||
let image = NSImage(cgImage: shape.cg, size: NSSize(width: sw, height: sh))
|
|
||||||
let hot = NSPoint(
|
|
||||||
x: min(CGFloat(shape.hotX) * scale, sw - 1),
|
|
||||||
y: min(CGFloat(shape.hotY) * scale, sh - 1))
|
|
||||||
return NSCursor(image: image, hotSpot: hot)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Host video px → CG GLOBAL screen coordinates (top-left origin, the
|
|
||||||
/// `CGWarpMouseCursorPosition` convention `CursorCapture` established) through the
|
|
||||||
/// aspect-fit letterbox — the inverse direction of `hostPoint(from:)`.
|
|
||||||
private func cgScreenPoint(forHostX hx: Int32, _ hy: Int32) -> CGPoint? {
|
|
||||||
guard let connection, let window else { return nil }
|
|
||||||
let mode = connection.currentMode()
|
|
||||||
guard mode.width > 0, mode.height > 0 else { return nil }
|
|
||||||
let fit = AVMakeRect(
|
|
||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
|
||||||
insideRect: bounds)
|
|
||||||
guard fit.width > 0, fit.height > 0 else { return nil }
|
|
||||||
let u = (CGFloat(hx) / CGFloat(mode.width)).clamped(to: 0...1)
|
|
||||||
let v = (CGFloat(hy) / CGFloat(mode.height)).clamped(to: 0...1)
|
|
||||||
let videoMinYTop = bounds.height - fit.maxY
|
|
||||||
let pTop = CGPoint(x: fit.minX + u * fit.width, y: videoMinYTop + v * fit.height)
|
|
||||||
let inView = CGPoint(x: pTop.x, y: bounds.height - pTop.y)
|
|
||||||
let inWindow = convert(inView, to: nil)
|
|
||||||
let onScreen = window.convertPoint(toScreen: inWindow)
|
|
||||||
let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
|
|
||||||
return CGPoint(x: onScreen.x, y: primaryHeight - onScreen.y)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single local monitor for motion + buttons, installed only while captured. A local
|
/// A single local monitor for motion + buttons, installed only while captured. A local
|
||||||
@@ -692,12 +467,12 @@ public final class StreamLayerView: NSView {
|
|||||||
/// via IOHID. Events are returned (not swallowed): the cursor is frozen, so they're
|
/// via IOHID. Events are returned (not swallowed): the cursor is frozen, so they're
|
||||||
/// inert locally.
|
/// inert locally.
|
||||||
///
|
///
|
||||||
/// In the desktop mouse model the cursor is NOT frozen, so bare `.mouseMoved` events are
|
/// In client-side-cursor mode the cursor is NOT frozen, so bare `.mouseMoved` events are
|
||||||
/// only generated while `window.acceptsMouseMovedEvents` is true — we enable it here and
|
/// only generated while `window.acceptsMouseMovedEvents` is true — we enable it here and
|
||||||
/// restore it on removal so absolute hover-motion keeps flowing without a click held.
|
/// restore it on removal so absolute hover-motion keeps flowing without a click held.
|
||||||
private func installMouseMonitor() {
|
private func installMouseMonitor() {
|
||||||
guard mouseEventMonitor == nil else { return }
|
guard mouseEventMonitor == nil else { return }
|
||||||
if desktopMouse {
|
if cursorVisible {
|
||||||
savedAcceptsMouseMoved = window?.acceptsMouseMovedEvents
|
savedAcceptsMouseMoved = window?.acceptsMouseMovedEvents
|
||||||
window?.acceptsMouseMovedEvents = true
|
window?.acceptsMouseMovedEvents = true
|
||||||
}
|
}
|
||||||
@@ -709,8 +484,8 @@ public final class StreamLayerView: NSView {
|
|||||||
guard let self, self.captured, let ic = self.inputCapture else { return event }
|
guard let self, self.captured, let ic = self.inputCapture else { return event }
|
||||||
switch event.type {
|
switch event.type {
|
||||||
case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||||||
if self.desktopMouse {
|
if self.cursorVisible {
|
||||||
// Desktop mouse model: forward the ABSOLUTE position (mapped through the
|
// Client-side cursor: forward the ABSOLUTE position (mapped through the
|
||||||
// aspect-fit letterbox into host pixels), the same path the iPad pointer
|
// aspect-fit letterbox into host pixels), the same path the iPad pointer
|
||||||
// fallback uses. Events in the letterbox bars are dropped (nil host point).
|
// fallback uses. Events in the letterbox bars are dropped (nil host point).
|
||||||
if let p = self.hostPoint(from: event) {
|
if let p = self.hostPoint(from: event) {
|
||||||
@@ -828,25 +603,14 @@ public final class StreamLayerView: NSView {
|
|||||||
// be a cursor trap with dead input.
|
// be a cursor trap with dead input.
|
||||||
self?.releaseCapture()
|
self?.releaseCapture()
|
||||||
}
|
}
|
||||||
// ⌃⌥⇧M flips the mouse model (capture ⇄ desktop) live — the SDL clients' identical
|
// ⌘⇧C flips the client-side cursor live. Only the key window's stream owns it (same
|
||||||
// chord. Only the key window's stream owns it (same guard as the ⌘⎋ capture toggle).
|
// guard as the ⌘⎋ capture toggle). Re-engage capture in the new mode so disassociation
|
||||||
// Re-engage capture in the new model so disassociation and the absolute/relative
|
// and the absolute/relative forwarding choice swap atomically — releaseCapture restores
|
||||||
// forwarding choice swap atomically — releaseCapture restores the old model's grab
|
// the old mode's grab (if any), engageCapture installs the new one.
|
||||||
// (if any), engageCapture installs the new one. On a gamescope host the chord is a
|
// ⌘⇧C would flip the client-side cursor live — NEUTERED while the feature is disabled
|
||||||
// no-op: its EIS grants only a relative pointer, so the desktop model's absolute
|
// (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
|
||||||
// sends would be silently dropped (pointer stuck = "all input dead").
|
// input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
|
||||||
capture.onToggleMouseMode = { [weak self] in
|
capture.onToggleCursor = {}
|
||||||
guard let self, self.window?.isKeyWindow == true,
|
|
||||||
let conn = self.connection else { return }
|
|
||||||
guard conn.resolvedCompositor != .gamescope else {
|
|
||||||
streamInputLog.info("mouse-mode chord ignored: gamescope host is relative-only")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// A manual flip outranks the standing host hint until the hint next CHANGES.
|
|
||||||
self.hintOverride = true
|
|
||||||
self.setDesktopMouse(!self.desktopMouse, reappearAt: nil)
|
|
||||||
streamInputLog.info("chord: mouse mode \(self.desktopMouse ? "desktop" : "capture", privacy: .public)")
|
|
||||||
}
|
|
||||||
// The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by
|
// The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by
|
||||||
// the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout.
|
// the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout.
|
||||||
capture.onReleaseCapture = { [weak self] in
|
capture.onReleaseCapture = { [weak self] in
|
||||||
@@ -873,26 +637,15 @@ public final class StreamLayerView: NSView {
|
|||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
|
|
||||||
// Desktop (absolute) mouse model — resolved at session start from the mouseMode
|
// Client-side cursor is TEMPORARILY DISABLED. It positions the host cursor with ABSOLUTE
|
||||||
// setting, gated by the host's compositor: gamescope's input socket (EIS) grants
|
// events, but gamescope's input socket (EIS) grants only a relative pointer, so those are
|
||||||
// only a relative pointer, so absolute sends would be silently dropped there
|
// silently dropped — the pointer never moves and clicks/scroll land on the stuck position
|
||||||
// (pointer stuck = "all input dead") — pinned to capture. ⌃⌥⇧M flips it live.
|
// (looks like "all input dead"). gamescope is exactly the compositor Auto enabled it for.
|
||||||
let mode = MouseInputMode(
|
// Forced off until per-compositor gating (KWin/GNOME/Sway have absolute) or a synthetic-
|
||||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? ""
|
// cursor-over-relative path lands; the resolution logic below is kept for that. See the
|
||||||
) ?? .capture
|
// ⌘⇧C handler (also neutered) and the cursorMode setting (hidden).
|
||||||
let absOK = connection.resolvedCompositor != .gamescope
|
cursorVisible = false
|
||||||
desktopMouse = mode == .desktop && absOK
|
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||||
if mode == .desktop && !absOK {
|
|
||||||
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
|
|
||||||
}
|
|
||||||
// Cursor channel (M2): the host stopped compositing the pointer — drain its shape/
|
|
||||||
// state planes and draw the pointer as the real NSCursor (plus the M3 auto-flip).
|
|
||||||
if connection.hostSupportsCursor {
|
|
||||||
cursorChannelActive = true
|
|
||||||
streamInputLog.info("cursor channel negotiated — host cursor renders locally")
|
|
||||||
startCursorPump(connection)
|
|
||||||
reconcileCursorRender() // initial render mode (a capture-model start composites)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
|
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
|
||||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||||
@@ -908,7 +661,6 @@ public final class StreamLayerView: NSView {
|
|||||||
endToEndMeter: endToEndMeter,
|
endToEndMeter: endToEndMeter,
|
||||||
decodeMeter: decodeMeter,
|
decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
presentFloorMeter: presentFloorMeter,
|
|
||||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
@@ -941,9 +693,9 @@ public final class StreamLayerView: NSView {
|
|||||||
private func layoutPresenter() {
|
private func layoutPresenter() {
|
||||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||||
// Present routing tracks the window's composited state (fullscreen transitions always
|
// Present routing tracks the window's composited state (fullscreen transitions always
|
||||||
// re-layout, so this stays current): a windowed session presents through a Core Animation
|
// re-layout, so this stays current): windowed PyroWave presents via surface contents —
|
||||||
// transaction — the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited).
|
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
|
||||||
// A view not yet in a window counts as composited (the safe default).
|
// not yet in a window counts as composited (the safe default).
|
||||||
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
||||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||||
// bounds — a pre-window layout would report point-sized dimensions.
|
// bounds — a pre-window layout would report point-sized dimensions.
|
||||||
@@ -952,11 +704,6 @@ public final class StreamLayerView: NSView {
|
|||||||
matchFollower?.noteSize(
|
matchFollower?.noteSize(
|
||||||
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
|
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
|
||||||
}
|
}
|
||||||
// The video-fit scale just changed (resize / retina move); rebuild the worn host pointer at
|
|
||||||
// the new scale so it tracks the video instead of freezing at its build-time size.
|
|
||||||
if captured, desktopMouse, cursorChannelActive {
|
|
||||||
window?.invalidateCursorRects(for: self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidChangeBackingProperties() {
|
public override func viewDidChangeBackingProperties() {
|
||||||
@@ -987,14 +734,6 @@ public final class StreamLayerView: NSView {
|
|||||||
matchFollower = nil
|
matchFollower = nil
|
||||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||||
connection = nil
|
connection = nil
|
||||||
// Cursor-channel state is per-session: without this reset a next session against a
|
|
||||||
// host WITHOUT the cap would wear this session's stale shapes (`cursorChannelActive`
|
|
||||||
// stayed latched true across sessions).
|
|
||||||
cursorChannelActive = false
|
|
||||||
cursorState = nil
|
|
||||||
hostCursors.removeAll()
|
|
||||||
sentClientDraws = nil
|
|
||||||
window?.invalidateCursorRects(for: self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
private let presentFloorMeter: LatencyMeter?
|
|
||||||
|
|
||||||
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
|
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
|
||||||
/// captured-state ⌃⌥⇧D combo is detected by the macOS NSEvent monitor only); on iOS a
|
/// captured-state ⌃⌥⇧D combo is detected by the macOS NSEvent monitor only); on iOS a
|
||||||
@@ -78,8 +77,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil
|
||||||
presentFloorMeter: LatencyMeter? = nil
|
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
@@ -91,7 +89,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
self.presentFloorMeter = presentFloorMeter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeUIViewController(context: Context) -> StreamViewController {
|
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||||
@@ -101,7 +98,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
controller.presentFloorMeter = presentFloorMeter
|
|
||||||
controller.onResizeTarget = onResizeTarget
|
controller.onResizeTarget = onResizeTarget
|
||||||
controller.onDecodedSize = onDecodedSize
|
controller.onDecodedSize = onDecodedSize
|
||||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -114,7 +110,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
controller.presentFloorMeter = presentFloorMeter
|
|
||||||
controller.onResizeTarget = onResizeTarget
|
controller.onResizeTarget = onResizeTarget
|
||||||
controller.onDecodedSize = onDecodedSize
|
controller.onDecodedSize = onDecodedSize
|
||||||
if controller.connection !== connection {
|
if controller.connection !== connection {
|
||||||
@@ -150,7 +145,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
var endToEndMeter: LatencyMeter?
|
var endToEndMeter: LatencyMeter?
|
||||||
var decodeMeter: LatencyMeter?
|
var decodeMeter: LatencyMeter?
|
||||||
var displayMeter: LatencyMeter?
|
var displayMeter: LatencyMeter?
|
||||||
var presentFloorMeter: LatencyMeter?
|
|
||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
@@ -333,13 +327,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
guard self?.captureEnabled == true else { return }
|
guard self?.captureEnabled == true else { return }
|
||||||
connection?.send(event)
|
connection?.send(event)
|
||||||
}
|
}
|
||||||
// Apple Pencil → the stylus plane, only against a pen-capable host (elsewhere the
|
|
||||||
// Pencil stays a finger, exactly as before). Same trust gate as touch.
|
|
||||||
streamView.penEnabled = connection.hostSupportsPen
|
|
||||||
streamView.onPenBatch = { [weak self, weak connection] batch in
|
|
||||||
guard self?.captureEnabled == true else { return }
|
|
||||||
connection?.sendPen(batch)
|
|
||||||
}
|
|
||||||
// Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll.
|
// Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll.
|
||||||
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
|
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
|
||||||
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
|
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
|
||||||
@@ -419,7 +406,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
endToEndMeter: endToEndMeter,
|
endToEndMeter: endToEndMeter,
|
||||||
decodeMeter: decodeMeter,
|
decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
presentFloorMeter: presentFloorMeter,
|
|
||||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
@@ -506,8 +492,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
// onTouchEvent can still deliver the button-up.
|
// onTouchEvent can still deliver the button-up.
|
||||||
streamView.resetTouchInput()
|
streamView.resetTouchInput()
|
||||||
streamView.onTouchEvent = nil
|
streamView.onTouchEvent = nil
|
||||||
streamView.onPenBatch = nil // after reset — the pen's leave-range sample rides it
|
|
||||||
streamView.penEnabled = false
|
|
||||||
streamView.onPointerMoveAbs = nil
|
streamView.onPointerMoveAbs = nil
|
||||||
streamView.onPointerButton = nil
|
streamView.onPointerButton = nil
|
||||||
streamView.onScroll = nil
|
streamView.onScroll = nil
|
||||||
@@ -701,12 +685,6 @@ final class StreamLayerUIView: UIView {
|
|||||||
/// Direct fingers / Pencil → wire events: real touches in passthrough mode, or the
|
/// Direct fingers / Pencil → wire events: real touches in passthrough mode, or the
|
||||||
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
|
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
|
||||||
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
|
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
|
||||||
/// Apple Pencil → state-full pen sample batches (the stylus plane). Active only while
|
|
||||||
/// `penEnabled`; without it the Pencil stays on the finger path exactly as before.
|
|
||||||
var onPenBatch: (([PunktfunkPenSample]) -> Void)?
|
|
||||||
/// The host advertised `HOST_CAP_PEN`, so Pencil input splits out of the finger path onto
|
|
||||||
/// the pen plane — independent of the touch-input mode (drawing must not depend on it).
|
|
||||||
var penEnabled = false
|
|
||||||
/// Indirect pointer (mouse/trackpad with no lock) → absolute cursor moves.
|
/// Indirect pointer (mouse/trackpad with no lock) → absolute cursor moves.
|
||||||
var onPointerMoveAbs: ((HostPoint) -> Void)?
|
var onPointerMoveAbs: ((HostPoint) -> Void)?
|
||||||
/// Indirect-pointer buttons (GameStream ids: 1=left 3=right); `down` = press.
|
/// Indirect-pointer buttons (GameStream ids: 1=left 3=right); `down` = press.
|
||||||
@@ -730,21 +708,10 @@ final class StreamLayerUIView: UIView {
|
|||||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||||
/// the NEXT touch, so one gesture never splits across input models.
|
/// the NEXT touch, so one gesture never splits across input models.
|
||||||
private var fingerRoute: TouchInputMode?
|
private var fingerRoute: TouchInputMode?
|
||||||
/// The Apple Pencil pipeline (contacts + hover + squeeze/tap → pen samples).
|
|
||||||
private lazy var pencil: PencilStream = {
|
|
||||||
let stream = PencilStream()
|
|
||||||
stream.send = { [weak self] batch in self?.onPenBatch?(batch) }
|
|
||||||
stream.videoNorm = { [weak self] point in
|
|
||||||
guard let h = self?.hostPoint(from: point) else { return nil }
|
|
||||||
return (Float(h.x) / Float(max(h.w - 1, 1)), Float(h.y) / Float(max(h.h - 1, 1)))
|
|
||||||
}
|
|
||||||
return stream
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// Release anything the touch-driven mouse holds and forget gesture state — session stop.
|
/// Release anything the touch-driven mouse holds and forget gesture state — session stop.
|
||||||
func resetTouchInput() {
|
func resetTouchInput() {
|
||||||
touchMouse.reset()
|
touchMouse.reset()
|
||||||
pencil.reset() // leaves range → the host lifts anything still inked
|
|
||||||
fingerRoute = nil
|
fingerRoute = nil
|
||||||
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
|
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
|
||||||
}
|
}
|
||||||
@@ -781,11 +748,6 @@ final class StreamLayerUIView: UIView {
|
|||||||
scrollPan.allowedScrollTypesMask = .all
|
scrollPan.allowedScrollTypesMask = .all
|
||||||
scrollPan.allowedTouchTypes = []
|
scrollPan.allowedTouchTypes = []
|
||||||
addGestureRecognizer(scrollPan)
|
addGestureRecognizer(scrollPan)
|
||||||
// Pencil squeeze / double-tap → the pen plane's barrel buttons (no-op while
|
|
||||||
// `penEnabled` is false — PencilStream ignores interactions out of range).
|
|
||||||
let pencilInteraction = UIPencilInteraction()
|
|
||||||
pencilInteraction.delegate = pencil
|
|
||||||
addInteraction(pencilInteraction)
|
|
||||||
#endif
|
#endif
|
||||||
backgroundColor = .black
|
backgroundColor = .black
|
||||||
}
|
}
|
||||||
@@ -810,32 +772,17 @@ final class StreamLayerUIView: UIView {
|
|||||||
private enum TouchKind { case down, move, up, cancel }
|
private enum TouchKind { case down, move, up, cancel }
|
||||||
|
|
||||||
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
|
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
|
||||||
/// the host cursor as an absolute mouse; a Pencil goes to the pen plane when the host
|
/// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host
|
||||||
/// supports it; everything else (direct finger — and the Pencil toward a pen-less host)
|
/// touch. Mixed batches are possible, so partition rather than branch on the first touch.
|
||||||
/// is a host touch. Mixed batches are possible, so partition rather than branch on the
|
|
||||||
/// first touch.
|
|
||||||
private func route(_ touches: Set<UITouch>, event: UIEvent?, kind: TouchKind) {
|
private func route(_ touches: Set<UITouch>, event: UIEvent?, kind: TouchKind) {
|
||||||
var fingers: Set<UITouch> = []
|
var fingers: Set<UITouch> = []
|
||||||
var pencilTouches: Set<UITouch> = []
|
|
||||||
for touch in touches {
|
for touch in touches {
|
||||||
if touch.type == .indirectPointer {
|
if touch.type == .indirectPointer {
|
||||||
handleIndirectPointer(touch, event: event, kind: kind)
|
handleIndirectPointer(touch, event: event, kind: kind)
|
||||||
} else if penEnabled, touch.type == .pencil {
|
|
||||||
pencilTouches.insert(touch)
|
|
||||||
} else {
|
} else {
|
||||||
fingers.insert(touch)
|
fingers.insert(touch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !pencilTouches.isEmpty {
|
|
||||||
let phase: PencilStream.Phase =
|
|
||||||
switch kind {
|
|
||||||
case .down: .down
|
|
||||||
case .move: .move
|
|
||||||
case .up: .up
|
|
||||||
case .cancel: .cancel
|
|
||||||
}
|
|
||||||
pencil.touches(pencilTouches, event: event, phase: phase, in: self)
|
|
||||||
}
|
|
||||||
if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
|
if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,11 +854,8 @@ final class StreamLayerUIView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Button-less mouse/trackpad movement (no lock) → absolute cursor move — unless it is a
|
/// Button-less mouse/trackpad movement (no lock) → absolute cursor move.
|
||||||
/// hovering PENCIL (`zOffset > 0`) on a pen-capable host, which becomes in-range pen
|
|
||||||
/// samples (hover preview with distance/tilt/azimuth) instead of a cursor move.
|
|
||||||
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
|
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
|
||||||
if penEnabled, pencil.maybeHover(recognizer, in: self) { return }
|
|
||||||
switch recognizer.state {
|
switch recognizer.state {
|
||||||
case .began, .changed:
|
case .began, .changed:
|
||||||
if let h = hostPoint(from: recognizer.location(in: self)) { onPointerMoveAbs?(h) }
|
if let h = hostPoint(from: recognizer.location(in: self)) { onPointerMoveAbs?(h) }
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
// The brand color, in the dependency-free foundation so EVERY process can use it — the widget
|
|
||||||
// extension links PunktfunkShared alone (never PunktfunkKit's Rust staticlib), and before this
|
|
||||||
// moved here the Live Activity / widgets fell back to `.tint` = system blue.
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
public extension Color {
|
|
||||||
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
|
||||||
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
|
||||||
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
|
||||||
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
|
||||||
static let brand: Color = {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
return Color(UIColor { traits in
|
|
||||||
traits.userInterfaceStyle == .dark
|
|
||||||
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
||||||
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
||||||
})
|
|
||||||
#elseif canImport(AppKit)
|
|
||||||
return Color(NSColor(name: nil) { appearance in
|
|
||||||
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
|
||||||
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
||||||
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
||||||
})
|
|
||||||
#else
|
|
||||||
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
|
||||||
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@@ -50,36 +50,17 @@ public enum DefaultsKey {
|
|||||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||||
public static let micChannel = "punktfunk.micChannel"
|
public static let micChannel = "punktfunk.micChannel"
|
||||||
/// LEGACY (2026-07 presentation rebuild — design/apple-presentation-rebuild.md): the old
|
/// Which presenter runs a session: "stage2" (default — explicit decode + Metal present on
|
||||||
/// user-visible stage picker's key. No longer read — the presenter is resolved from
|
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing — the experimental
|
||||||
/// `presentPriority` below; the stage ladder survives only as the
|
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
|
||||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old
|
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
||||||
/// value is documented, not mysterious.
|
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
|
||||||
public static let presenter = "punktfunk.presenter"
|
public static let presenter = "punktfunk.presenter"
|
||||||
/// The user's presentation intent: "latency" (default — every frame shows as soon as the
|
|
||||||
/// display can; jitter appears as the occasional repeat/drop) or "smooth" (a small client
|
|
||||||
/// jitter buffer evens the cadence at the cost of added, visible display latency).
|
|
||||||
/// Resolved once per session by SessionPresenter — see PresentPriority.
|
|
||||||
public static let presentPriority = "punktfunk.presentPriority"
|
|
||||||
/// Smoothness's jitter-buffer capacity in frames: 0 = Automatic (currently 2), or 1…3.
|
|
||||||
/// Each buffered frame adds ~one refresh interval of display latency and absorbs ~one
|
|
||||||
/// interval of arrival jitter. Only meaningful when `presentPriority` is "smooth".
|
|
||||||
public static let smoothBuffer = "punktfunk.smoothBuffer"
|
|
||||||
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
||||||
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||||
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
|
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
|
||||||
public static let vsync = "punktfunk.vsync"
|
public static let vsync = "punktfunk.vsync"
|
||||||
/// macOS: present WINDOWED sessions in lockstep with the system compositor (the DCP
|
|
||||||
/// "mismatched swapID's" kernel-panic mitigation — see SessionPresenter.windowedPresentMode
|
|
||||||
/// and the MetalVideoPresenter saga notes). ON/unset (the default): windowed presents ride
|
|
||||||
/// a Core Animation transaction — validated panic-free on the 240 Hz repro machine, at a
|
|
||||||
/// small display-latency cost vs the raw path. OFF: windowed sessions keep the fast async
|
|
||||||
/// image queue — ON AFFECTED SETUPS (high-refresh displays) THAT PATH KERNEL-PANICS THE
|
|
||||||
/// WHOLE MAC, which is why the default is ON. Fullscreen always presents async (fast path)
|
|
||||||
/// regardless. Resolved once per session; PUNKTFUNK_WINDOWED_PRESENT=async|transaction|
|
|
||||||
/// surface overrides it for dev A/B.
|
|
||||||
public static let windowedSafePresent = "punktfunk.windowedSafePresent"
|
|
||||||
/// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor,
|
/// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor,
|
||||||
/// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical
|
/// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical
|
||||||
/// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off,
|
/// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off,
|
||||||
@@ -94,11 +75,8 @@ public enum DefaultsKey {
|
|||||||
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
||||||
public static let enable444 = "punktfunk.enable444"
|
public static let enable444 = "punktfunk.enable444"
|
||||||
public static let hosts = "punktfunk.hosts"
|
public static let hosts = "punktfunk.hosts"
|
||||||
/// Physical-mouse model (macOS): "capture" (pointer lock + relative, the default) or
|
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||||
/// "desktop" (uncaptured absolute pointer) — the cross-client `mouse_mode`. Replaces the
|
public static let cursorMode = "punktfunk.cursorMode"
|
||||||
/// never-shipped "punktfunk.cursorMode" (auto/always/never client-side-cursor setting,
|
|
||||||
/// which was hidden while disabled and had no readers).
|
|
||||||
public static let mouseMode = "punktfunk.mouseMode"
|
|
||||||
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
||||||
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
||||||
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import XCTest
|
|||||||
import QuartzCore
|
import QuartzCore
|
||||||
@testable import PunktfunkKit
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
/// Present pacing: the stage-3 bounded in-flight `PresentGate`, the stage-4 `LatestBox`
|
/// Stage-3 present pacing: the bounded in-flight `PresentGate` (depth 1 + depth 2), the
|
||||||
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting +
|
/// stage-1/2/3 `PresenterChoice` resolution (setting + PUNKTFUNK_PRESENTER env override + the
|
||||||
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only
|
/// release-build stage-1 gate), and the per-platform glass-gate depth.
|
||||||
/// stage-4 gate), and the per-platform glass-gate depth.
|
|
||||||
final class PresentPacingTests: XCTestCase {
|
final class PresentPacingTests: XCTestCase {
|
||||||
// MARK: - PresentGate
|
// MARK: - PresentGate
|
||||||
|
|
||||||
@@ -22,10 +21,8 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Depth 2 (the PUNKTFUNK_GATE_DEPTH ladder rung — no longer a default; see
|
/// Depth 2 (the iOS default): a second present may queue behind the flip scanning out — the
|
||||||
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): a second present may queue
|
/// bound only bites at the THIRD. One release (a glass callback) reopens exactly one slot.
|
||||||
/// behind the flip scanning out — the bound only bites at the THIRD. One release (a glass
|
|
||||||
/// callback) reopens exactly one slot.
|
|
||||||
func testGateDepthTwoAdmitsTwoInFlightPresents() {
|
func testGateDepthTwoAdmitsTwoInFlightPresents() {
|
||||||
let gate = PresentGate(capacity: 2)
|
let gate = PresentGate(capacity: 2)
|
||||||
XCTAssertTrue(gate.tryAcquire(now: 0))
|
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||||
@@ -75,143 +72,16 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
XCTAssertEqual(gate.drainForced(), 0)
|
XCTAssertEqual(gate.drainForced(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PresentPriority (the user-facing latency/smoothness intent)
|
|
||||||
|
|
||||||
/// Resolution from the persisted settings: anything but an explicit "smooth" is latency
|
|
||||||
/// (the default), and the buffer setting maps 0/out-of-range/garbage to Automatic (2).
|
|
||||||
func testPresentPriorityResolution() {
|
|
||||||
XCTAssertEqual(PresentPriority.resolve(setting: nil, bufferSetting: nil), .latency)
|
|
||||||
XCTAssertEqual(PresentPriority.resolve(setting: "latency", bufferSetting: 3), .latency)
|
|
||||||
XCTAssertEqual(PresentPriority.resolve(setting: "garbage", bufferSetting: nil), .latency)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: nil),
|
|
||||||
.smooth(buffer: 2), "unset buffer = Automatic = 2")
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: 0), .smooth(buffer: 2))
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: 1), .smooth(buffer: 1))
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: 3), .smooth(buffer: 3))
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.resolve(setting: "smooth", bufferSetting: 9),
|
|
||||||
.smooth(buffer: 2), "out-of-range buffer = Automatic")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The intent→store mapping: latency runs the zero-queue newest-wins slot, smoothness the
|
|
||||||
/// FIFO jitter buffer at the resolved capacity.
|
|
||||||
func testPresentPriorityStorePolicy() {
|
|
||||||
XCTAssertEqual(PresentPriority.latency.storePolicy, .newestWins)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresentPriority.smooth(buffer: 3).storePolicy, .fifo(capacity: 3))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - FrameStore (the decoded-frame hand-off, both intents)
|
|
||||||
|
|
||||||
/// Newest-wins (latency): submit replaces the undisplayed frame, take clears, putBack
|
|
||||||
/// restores only into an empty slot — the exact pre-rebuild ReadyRing semantics.
|
|
||||||
func testFrameStoreNewestWins() {
|
|
||||||
let store = FrameStore<Int>(policy: .newestWins)
|
|
||||||
XCTAssertNil(store.take())
|
|
||||||
store.submit(1)
|
|
||||||
store.submit(2)
|
|
||||||
XCTAssertEqual(store.take(), 2, "the newer decode replaces the undisplayed frame")
|
|
||||||
XCTAssertNil(store.take())
|
|
||||||
store.putBack(7)
|
|
||||||
store.submit(8) // a fresh decode beats the putBack
|
|
||||||
store.putBack(7)
|
|
||||||
XCTAssertEqual(store.take(), 8)
|
|
||||||
XCTAssertEqual(store.drainSubmitted(), 3)
|
|
||||||
let smoothing = store.drainSmoothing()
|
|
||||||
XCTAssertEqual(smoothing.overflowDrops, 0)
|
|
||||||
XCTAssertEqual(smoothing.underflows, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FIFO (smoothness): take withholds frames until the buffer has PREROLLED to capacity —
|
|
||||||
/// without preroll a steady stream drains on arrival and headroom never builds — then pops
|
|
||||||
/// oldest-first.
|
|
||||||
func testFrameStoreFifoPrerollsToCapacity() {
|
|
||||||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
|
||||||
store.submit(1)
|
|
||||||
XCTAssertNil(store.take(), "one frame buffered — still building headroom")
|
|
||||||
store.submit(2)
|
|
||||||
XCTAssertEqual(store.take(), 1, "prerolled — pops the OLDEST")
|
|
||||||
store.submit(3)
|
|
||||||
XCTAssertEqual(store.take(), 2, "steady state: one in, oldest out")
|
|
||||||
XCTAssertEqual(store.take(), 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FIFO overflow drops the OLDEST (bounded added latency, the newest keeps flowing) and
|
|
||||||
/// counts it; running dry counts an underflow and re-arms preroll so headroom rebuilds.
|
|
||||||
func testFrameStoreFifoOverflowAndUnderflow() {
|
|
||||||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
|
||||||
store.submit(1)
|
|
||||||
store.submit(2)
|
|
||||||
store.submit(3) // full — 1 (the oldest) goes
|
|
||||||
XCTAssertEqual(store.take(), 2)
|
|
||||||
XCTAssertEqual(store.take(), 3)
|
|
||||||
XCTAssertNil(store.take(), "ran dry — an underflow, preroll re-arms")
|
|
||||||
store.submit(4)
|
|
||||||
XCTAssertNil(store.take(), "rebuilding headroom after the underflow")
|
|
||||||
store.submit(5)
|
|
||||||
XCTAssertEqual(store.take(), 4)
|
|
||||||
let smoothing = store.drainSmoothing()
|
|
||||||
XCTAssertEqual(smoothing.overflowDrops, 1)
|
|
||||||
XCTAssertEqual(smoothing.underflows, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FIFO putBack reinserts at the FRONT — a frame the render thread couldn't present is
|
|
||||||
/// still the oldest, so present order is preserved.
|
|
||||||
func testFrameStoreFifoPutBackPreservesOrder() {
|
|
||||||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
|
||||||
store.submit(1)
|
|
||||||
store.submit(2)
|
|
||||||
let f = store.take()
|
|
||||||
XCTAssertEqual(f, 1)
|
|
||||||
store.putBack(f!)
|
|
||||||
XCTAssertEqual(store.take(), 1, "the returned frame stays first out")
|
|
||||||
XCTAssertEqual(store.take(), 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - LatestBox (stage-4's drawable hand-off)
|
|
||||||
|
|
||||||
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
|
|
||||||
/// layer's pool by release), `take` empties the slot.
|
|
||||||
func testLatestBoxNewestWins() {
|
|
||||||
let box = LatestBox<Int>()
|
|
||||||
XCTAssertNil(box.take())
|
|
||||||
box.put(1)
|
|
||||||
box.put(2)
|
|
||||||
XCTAssertEqual(box.take(), 2, "a fresher put replaces the unpresented value")
|
|
||||||
XCTAssertNil(box.take(), "take empties the slot")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `putBack` fills only an EMPTY slot: the render thread returning a drawable it took but
|
|
||||||
/// didn't present must never clobber a fresher one the link vended in between.
|
|
||||||
func testLatestBoxPutBackNeverClobbersAFresherPut() {
|
|
||||||
let box = LatestBox<Int>()
|
|
||||||
box.put(1)
|
|
||||||
let stale = box.take()
|
|
||||||
XCTAssertEqual(stale, 1)
|
|
||||||
box.putBack(stale!)
|
|
||||||
XCTAssertEqual(box.take(), 1, "putBack into a still-empty slot restores the value")
|
|
||||||
box.put(2)
|
|
||||||
let taken = box.take()
|
|
||||||
box.put(3) // the link vends a fresher drawable while the render thread holds `taken`
|
|
||||||
box.putBack(taken!)
|
|
||||||
XCTAssertEqual(box.take(), 3, "the fresher vend wins over the stale return")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PresenterChoice
|
// MARK: - PresenterChoice
|
||||||
|
|
||||||
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching
|
/// The platform default: glass-paced stage-3 where the layer always vsync-latches (iOS,
|
||||||
/// platforms where any bounded-FIFO pacing keeps a standing queue — tvOS joined in the
|
/// tvOS — arrival pacing saturates the FIFO image queue there), arrival stage-2 on macOS
|
||||||
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
|
/// (sync-off presents don't queue). No selection / garbage falls back to it.
|
||||||
/// No selection / garbage falls back to it.
|
|
||||||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
func testPresenterChoiceFallsBackToPlatformDefault() {
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(macOS)
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
|
||||||
#else
|
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
||||||
|
#else
|
||||||
|
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
|
||||||
#endif
|
#endif
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
||||||
@@ -236,29 +106,6 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
|
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "stage4" (deadline pacing) resolves only on iOS/tvOS. On macOS — whose present path is
|
|
||||||
/// entangled with the sync-off/DCP-panic saga — a synced-over "stage4" value maps back to
|
|
||||||
/// the platform default instead of engaging an unvalidated pacing.
|
|
||||||
func testPresenterChoiceGatesStage4ToVsyncLatchPlatforms() {
|
|
||||||
#if os(macOS)
|
|
||||||
XCTAssertNil(PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true))
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: "stage4", env: nil, allowStage1: true), .stage2)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage2)
|
|
||||||
#else
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true), .stage4)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage4)
|
|
||||||
// The env override wins over the persisted setting, both directions.
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: "stage4", env: "stage3", allowStage1: true), .stage3)
|
|
||||||
XCTAssertEqual(
|
|
||||||
PresenterChoice.resolve(setting: "stage2", env: "stage4", allowStage1: true), .stage4)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
|
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
|
||||||
/// builds); a leftover "stage1" value in a release build maps back to the platform default.
|
/// builds); a leftover "stage1" value in a release build maps back to the platform default.
|
||||||
func testPresenterChoiceGatesStage1() {
|
func testPresenterChoiceGatesStage1() {
|
||||||
@@ -309,65 +156,25 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
|
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
|
||||||
// Stage-4 means deadline regardless of codec or how it was chosen.
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.pacing(for: .stage4, explicit: nil, codec: .hevc), .deadline)
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Windowed present mechanism (the macOS DCP swapID-panic mitigation picker)
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// The safe-present setting: ON/unset → the validated transactional mitigation; an explicit
|
|
||||||
/// OFF → the fast async path (the user accepted the affected-setup panic risk). The
|
|
||||||
/// PUNKTFUNK_WINDOWED_PRESENT env lever overrides both ways, `surface` is env-only (the
|
|
||||||
/// prototype mechanism), and garbage/empty env values are "unset", not an override.
|
|
||||||
func testWindowedPresentModeResolution() {
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: nil, env: nil), .transaction,
|
|
||||||
"unset defaults to the panic mitigation")
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: true, env: nil), .transaction)
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: false, env: nil), .async,
|
|
||||||
"an explicit opt-out gets the fast async path")
|
|
||||||
// The dev env lever wins over the setting, both directions.
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: true, env: "async"), .async)
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: false, env: "transaction"),
|
|
||||||
.transaction)
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: true, env: "surface"), .surface,
|
|
||||||
"the surface prototype is reachable via env only")
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: false, env: "surface"), .surface)
|
|
||||||
// Garbage/empty env = unset.
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: nil, env: "garbage"), .transaction)
|
|
||||||
XCTAssertEqual(
|
|
||||||
SessionPresenter.windowedPresentMode(setting: false, env: ""), .async)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Glass-gate depth
|
// MARK: - Glass-gate depth
|
||||||
|
|
||||||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
/// The per-platform in-flight present budget: 2 on iOS/iPadOS (one flip scanning out + one
|
||||||
/// the 2026-07 iPad depth-2 experiment regressed display latency 14→22–28 ms (see
|
/// queued — the display-latency fix), 1 on tvOS (proven config), 1 pinned on macOS (glass
|
||||||
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass
|
/// there is the swapID-panic mitigation — strict serialization is its point, so the env
|
||||||
/// there is the swapID-panic mitigation — strict serialization is its point);
|
/// lever must not widen it). PUNKTFUNK_GATE_DEPTH A/Bs iOS/tvOS; out-of-range/garbage
|
||||||
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on iOS/tvOS.
|
/// values are ignored.
|
||||||
/// Out-of-range/garbage values are ignored.
|
|
||||||
func testGateDepthPlatformDefaultsAndEnvOverride() {
|
func testGateDepthPlatformDefaultsAndEnvOverride() {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
||||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
|
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
|
||||||
|
#elseif os(tvOS)
|
||||||
|
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
|
||||||
|
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device A/B lever")
|
||||||
#else
|
#else
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 2)
|
||||||
SessionPresenter.gateDepth(env: nil), 1,
|
XCTAssertEqual(SessionPresenter.gateDepth(env: "1"), 1, "the on-device A/B lever")
|
||||||
"any depth >1 is a standing queue — one refresh of display latency per slot")
|
|
||||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device ladder lever")
|
|
||||||
#endif
|
#endif
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
|
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||||
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
|
|
||||||
# firing Wake-on-LAN so the connect survives the host's resume)
|
|
||||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||||
#
|
#
|
||||||
@@ -63,17 +61,10 @@ if [ -z "${PF_HOST:-}" ]; then
|
|||||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
|
|
||||||
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
|
|
||||||
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
|
|
||||||
set -- --fullscreen
|
|
||||||
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
|
|
||||||
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
|
|
||||||
fi
|
|
||||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen
|
||||||
fi
|
fi
|
||||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@"
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen
|
||||||
|
|||||||
@@ -10,17 +10,9 @@ import {
|
|||||||
showModal,
|
showModal,
|
||||||
staticClasses,
|
staticClasses,
|
||||||
} from "@decky/ui";
|
} from "@decky/ui";
|
||||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
import { definePlugin, routerHook } from "@decky/api";
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import {
|
import { FaDownload, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaTv } from "react-icons/fa";
|
||||||
FaDownload,
|
|
||||||
FaLock,
|
|
||||||
FaLockOpen,
|
|
||||||
FaPlay,
|
|
||||||
FaPlus,
|
|
||||||
FaSyncAlt,
|
|
||||||
FaTv,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
import { PluginErrorBoundary } from "./boundary";
|
import { PluginErrorBoundary } from "./boundary";
|
||||||
import {
|
import {
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
@@ -39,19 +31,7 @@ import {
|
|||||||
import { streamPin } from "./library";
|
import { streamPin } from "./library";
|
||||||
import { PunktfunkRoute, ROUTE } from "./page";
|
import { PunktfunkRoute, ROUTE } from "./page";
|
||||||
import { PairModal } from "./pair";
|
import { PairModal } from "./pair";
|
||||||
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
|
import { ensureGamepadUiShortcut } from "./steam";
|
||||||
|
|
||||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
|
||||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
|
||||||
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
|
|
||||||
// that works even without a reload. Always ends in a toast so the tap has feedback.
|
|
||||||
async function recreatePunktfunkShortcut(): Promise<void> {
|
|
||||||
const appId = await recreateShortcuts();
|
|
||||||
toaster.toast({
|
|
||||||
title: "Punktfunk",
|
|
||||||
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||||
@@ -210,16 +190,6 @@ const QamPanel: FC = () => {
|
|||||||
{checking ? "Checking…" : "Check for updates"}
|
{checking ? "Checking…" : "Check for updates"}
|
||||||
</ButtonItem>
|
</ButtonItem>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
<PanelSectionRow>
|
|
||||||
<ButtonItem
|
|
||||||
layout="below"
|
|
||||||
description="Missing the Punktfunk entry in your library? This puts it back."
|
|
||||||
onClick={() => void recreatePunktfunkShortcut()}
|
|
||||||
>
|
|
||||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
|
||||||
Recreate library shortcut
|
|
||||||
</ButtonItem>
|
|
||||||
</PanelSectionRow>
|
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,29 +55,6 @@ declare const collectionStore:
|
|||||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
// SteamUI's appStore indexes every registered app/shortcut by appId; a remembered appId whose
|
|
||||||
// overview is gone was deleted out from under us (the user removed the library entry). We must
|
|
||||||
// verify this because the remembered appId lives in Steam's CEF localStorage — which survives a
|
|
||||||
// plugin UNINSTALL/REINSTALL — so a manually-deleted shortcut otherwise leaves a dangling appId
|
|
||||||
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
|
||||||
// entry never comes back.
|
|
||||||
declare const appStore:
|
|
||||||
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
|
||||||
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
|
||||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
|
||||||
function shortcutStillExists(appId: number): boolean {
|
|
||||||
try {
|
|
||||||
const get = appStore?.GetAppOverviewByAppID;
|
|
||||||
if (!get) return true; // no way to verify — preserve the reuse path
|
|
||||||
return get(appId) != null;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||||
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
||||||
function setShortcutHidden(appId: number, hidden: boolean): void {
|
function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||||
@@ -93,9 +70,7 @@ function setShortcutHidden(appId: number, hidden: boolean): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
||||||
// v3: CI zips through 0.17.1 shipped no assets/ at all, yet v2 was still recorded as applied
|
const ART_VERSION = 2;
|
||||||
// on those installs — the bump makes them re-apply once on the first build that has the files.
|
|
||||||
const ART_VERSION = 3;
|
|
||||||
function artKey(appId: number): string {
|
function artKey(appId: number): string {
|
||||||
return `punktfunk:shortcutArt:${appId}`;
|
return `punktfunk:shortcutArt:${appId}`;
|
||||||
}
|
}
|
||||||
@@ -104,7 +79,7 @@ function artKey(appId: number): string {
|
|||||||
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
||||||
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
||||||
*/
|
*/
|
||||||
async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
async function applyArtwork(appId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
||||||
return;
|
return;
|
||||||
@@ -116,29 +91,16 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
|||||||
[art.logo, 2],
|
[art.logo, 2],
|
||||||
[art.gridwide, 3],
|
[art.gridwide, 3],
|
||||||
];
|
];
|
||||||
let applied = false;
|
|
||||||
for (const [data, assetType] of assets) {
|
for (const [data, assetType] of assets) {
|
||||||
if (data) {
|
if (data) {
|
||||||
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
|
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
|
||||||
applied = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (art.icon_path) {
|
if (art.icon_path) {
|
||||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||||
applied = true;
|
|
||||||
}
|
}
|
||||||
// Only record "done" when something actually landed — a plugin build whose assets/ is
|
|
||||||
// missing/empty must keep retrying on later mounts instead of poisoning the marker.
|
|
||||||
if (applied) {
|
|
||||||
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A shortcut fresh out of AddShortcut may not be registered yet (the same race
|
|
||||||
// setShortcutHidden defers around) — one deferred second attempt, then leave it to
|
|
||||||
// the next mount.
|
|
||||||
if (!isRetry) {
|
|
||||||
setTimeout(() => void applyArtwork(appId, true), 2500);
|
|
||||||
}
|
|
||||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,9 +157,7 @@ async function ensureControllerConfig(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const r = await applyControllerConfig(SHORTCUT_NAME);
|
const r = await applyControllerConfig(SHORTCUT_NAME);
|
||||||
// `ok` alone isn't done: with zero account configset dirs (fresh Steam) the backend
|
if (r?.ok) {
|
||||||
// succeeds without pointing any account at the template — keep retrying until one lands.
|
|
||||||
if (r?.ok && (r.applied ?? []).some((a) => a.startsWith("configset:"))) {
|
|
||||||
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
||||||
} else {
|
} else {
|
||||||
console.warn("punktfunk: controller config not fully applied", r);
|
console.warn("punktfunk: controller config not fully applied", r);
|
||||||
@@ -222,10 +182,8 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }
|
|||||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||||
|
|
||||||
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
|
||||||
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
|
||||||
const remembered = recall(STORAGE_KEY_STREAM);
|
const remembered = recall(STORAGE_KEY_STREAM);
|
||||||
if (remembered != null && shortcutStillExists(remembered)) {
|
if (remembered != null) {
|
||||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||||
@@ -261,11 +219,8 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
|||||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||||
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
||||||
|
|
||||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
|
||||||
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
|
||||||
// library entry actually comes back instead of repointing a dead id.
|
|
||||||
let appId = recall(STORAGE_KEY_UI);
|
let appId = recall(STORAGE_KEY_UI);
|
||||||
if (appId != null && shortcutStillExists(appId)) {
|
if (appId != null) {
|
||||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
@@ -284,30 +239,6 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
|
||||||
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
|
||||||
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
|
||||||
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
|
||||||
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
|
||||||
* Returns the (new or existing) visible appId, or null on failure.
|
|
||||||
*/
|
|
||||||
export async function recreateShortcuts(): Promise<number | null> {
|
|
||||||
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
|
||||||
const id = recall(key);
|
|
||||||
if (id != null && !shortcutStillExists(id)) {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
|
||||||
return ensureGamepadUiShortcut();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||||
export async function launchGamepadUi(): Promise<void> {
|
export async function launchGamepadUi(): Promise<void> {
|
||||||
const appId = await ensureGamepadUiShortcut();
|
const appId = await ensureGamepadUiShortcut();
|
||||||
@@ -352,21 +283,13 @@ export async function launchStream(
|
|||||||
opts: LaunchOpts = {},
|
opts: LaunchOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||||
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
|
// so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame.
|
||||||
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
|
|
||||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||||
const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
const { appId, runner } = await ensureStreamShortcut();
|
||||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||||
const env = [`PF_HOST=${target}`];
|
const env = [`PF_HOST=${target}`];
|
||||||
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
|
|
||||||
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
|
|
||||||
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
|
|
||||||
// already-awake host the connect still lands in under a second, so this costs nothing.
|
|
||||||
if (woke.ok) {
|
|
||||||
env.push("PF_CONNECT_TIMEOUT=75");
|
|
||||||
}
|
|
||||||
if (opts.browse) {
|
if (opts.browse) {
|
||||||
env.push("PF_BROWSE=1");
|
env.push("PF_BROWSE=1");
|
||||||
if (opts.mgmt) {
|
if (opts.mgmt) {
|
||||||
@@ -380,9 +303,9 @@ export async function launchStream(
|
|||||||
env.push(`PF_LAUNCH=${opts.launchId}`);
|
env.push(`PF_LAUNCH=${opts.launchId}`);
|
||||||
}
|
}
|
||||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||||
// script rides behind it as an argument and reads PF_* from the environment. The wake was
|
// script rides behind it as an argument and reads PF_* from the environment.
|
||||||
// awaited above, so the magic packet is out before the connect attempt.
|
|
||||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||||
|
await waking; // ensure the magic packet is out before the connect attempt
|
||||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,6 @@ pub struct AppModel {
|
|||||||
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
|
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
|
||||||
/// run in the session binary, which has its own.
|
/// run in the session binary, which has its own.
|
||||||
pub gamepad: crate::gamepad::GamepadService,
|
pub gamepad: crate::gamepad::GamepadService,
|
||||||
/// Device lists for the settings pickers (GPUs via `punktfunk-session
|
|
||||||
/// --list-adapters` — the shell deliberately links no Vulkan itself — and audio
|
|
||||||
/// endpoints via the PipeWire registry), probed once at startup on a worker thread.
|
|
||||||
/// Empty until the probe lands — empty lists simply hide their pickers.
|
|
||||||
pub probes: Rc<RefCell<crate::ui_settings::DeviceProbes>>,
|
|
||||||
hosts: Controller<HostsPage>,
|
hosts: Controller<HostsPage>,
|
||||||
/// One session child at a time — connects while one runs are ignored.
|
/// One session child at a time — connects while one runs are ignored.
|
||||||
busy: bool,
|
busy: bool,
|
||||||
@@ -165,42 +160,6 @@ impl SimpleComponent for AppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let settings = Rc::new(RefCell::new(Settings::load()));
|
let settings = Rc::new(RefCell::new(Settings::load()));
|
||||||
// Device lists for the settings pickers: probe in the background, ready long
|
|
||||||
// before the dialog opens. A missing session binary or absent PipeWire just
|
|
||||||
// leaves the corresponding list empty (and its picker hidden).
|
|
||||||
let probes: Rc<RefCell<crate::ui_settings::DeviceProbes>> = Rc::default();
|
|
||||||
{
|
|
||||||
let (tx, rx) = async_channel::bounded::<crate::ui_settings::DeviceProbes>(1);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let adapters: Vec<String> =
|
|
||||||
std::process::Command::new(crate::spawn::session_binary())
|
|
||||||
.arg("--list-adapters")
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.filter(|o| o.status.success())
|
|
||||||
.map(|o| {
|
|
||||||
String::from_utf8_lossy(&o.stdout)
|
|
||||||
.lines()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|l| !l.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let (speakers, mics) = pf_client_core::audio::devices().unwrap_or_default();
|
|
||||||
let _ = tx.send_blocking(crate::ui_settings::DeviceProbes {
|
|
||||||
adapters,
|
|
||||||
speakers,
|
|
||||||
mics,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
let probes = probes.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
if let Ok(found) = rx.recv().await {
|
|
||||||
*probes.borrow_mut() = found;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Re-apply the persisted forwarded-controller pin (stable key; the service
|
// Re-apply the persisted forwarded-controller pin (stable key; the service
|
||||||
// matches it whenever such a pad connects).
|
// matches it whenever such a pad connects).
|
||||||
{
|
{
|
||||||
@@ -238,7 +197,6 @@ impl SimpleComponent for AppModel {
|
|||||||
settings,
|
settings,
|
||||||
identity,
|
identity,
|
||||||
gamepad: init.gamepad,
|
gamepad: init.gamepad,
|
||||||
probes,
|
|
||||||
hosts,
|
hosts,
|
||||||
busy: false,
|
busy: false,
|
||||||
wake_fallback: None,
|
wake_fallback: None,
|
||||||
@@ -349,15 +307,8 @@ impl SimpleComponent for AppModel {
|
|||||||
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
|
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
|
||||||
// box is already booting while the dial times out, arm the wake-wait fallback
|
// box is already booting while the dial times out, arm the wake-wait fallback
|
||||||
// for THIS request, and connect immediately.
|
// for THIS request, and connect immediately.
|
||||||
//
|
|
||||||
// Auto-wake OFF (the Settings toggle, for VPN hosts that look offline when
|
|
||||||
// they aren't): no packet and no wake-and-wait fallback — the dial either
|
|
||||||
// succeeds or fails with the normal error. The host-card menu's explicit
|
|
||||||
// "Wake host" is deliberately not gated.
|
|
||||||
if self.settings.borrow().auto_wake {
|
|
||||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
self.wake_fallback = Some(req.clone());
|
self.wake_fallback = Some(req.clone());
|
||||||
}
|
|
||||||
sender.input(AppMsg::Connect(req));
|
sender.input(AppMsg::Connect(req));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,7 +435,6 @@ impl SimpleComponent for AppModel {
|
|||||||
&self.window,
|
&self.window,
|
||||||
self.settings.clone(),
|
self.settings.clone(),
|
||||||
&self.gamepad,
|
&self.gamepad,
|
||||||
&self.probes.borrow(),
|
|
||||||
move || {
|
move || {
|
||||||
// The library toggle changes the saved cards' menu — re-render.
|
// The library toggle changes the saved cards' menu — re-render.
|
||||||
let _ = hosts.send(HostsMsg::Refresh);
|
let _ = hosts.send(HostsMsg::Refresh);
|
||||||
@@ -547,7 +497,6 @@ impl AppModel {
|
|||||||
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
||||||
0, // preferred_codec: no preference
|
0, // preferred_codec: no preference
|
||||||
None, // display_hdr: probe connect, nothing presents
|
None, // display_hdr: probe connect, nothing presents
|
||||||
0, // client_caps: probe connect, nothing renders a cursor
|
|
||||||
None, // launch: probe connect, no game
|
None, // launch: probe connect, no game
|
||||||
pin,
|
pin,
|
||||||
Some(identity),
|
Some(identity),
|
||||||
|
|||||||
@@ -352,7 +352,6 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
|||||||
paired: false,
|
paired: false,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: Vec::new(),
|
mac: Vec::new(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
match known.save() {
|
match known.save() {
|
||||||
@@ -535,34 +534,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
"settings" | "03-settings" => {
|
"settings" | "03-settings" => {
|
||||||
// Mock devices so the shot shows the probe-dependent pickers populated.
|
crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
|
||||||
let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice {
|
|
||||||
name: name.to_string(),
|
|
||||||
description: description.to_string(),
|
|
||||||
};
|
|
||||||
let probes = crate::ui_settings::DeviceProbes {
|
|
||||||
adapters: vec![
|
|
||||||
"NVIDIA GeForce RTX 4070".to_string(),
|
|
||||||
"AMD Radeon 780M".to_string(),
|
|
||||||
],
|
|
||||||
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
|
|
||||||
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
|
|
||||||
};
|
|
||||||
let dialog = crate::ui_settings::show(
|
|
||||||
&ctx.window,
|
|
||||||
ctx.settings.clone(),
|
|
||||||
&ctx.gamepad,
|
|
||||||
&probes,
|
|
||||||
|| {},
|
|
||||||
);
|
|
||||||
// Optional page for the capture (general/display/input/audio/controllers);
|
|
||||||
// the dialog opens on General otherwise.
|
|
||||||
if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") {
|
|
||||||
if !page.is_empty() {
|
|
||||||
use adw::prelude::PreferencesDialogExt as _;
|
|
||||||
dialog.set_visible_page_name(&page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
||||||
"pair" | "05-pair" => {
|
"pair" | "05-pair" => {
|
||||||
|
|||||||
+103
-454
@@ -1,9 +1,5 @@
|
|||||||
//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings
|
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
|
||||||
//! revamp): General / Display / Input / Audio / Controllers pages — Display owns
|
//! capture behavior. Written back to disk when the dialog closes.
|
||||||
//! everything about the picture — with per-field captions in each row's subtitle,
|
|
||||||
//! dynamic where the meaning depends on the selection (touch mode). Written back to
|
|
||||||
//! disk when the dialog closes. About stays in the primary menu (GNOME convention)
|
|
||||||
//! rather than as a page.
|
|
||||||
|
|
||||||
use crate::trust::Settings;
|
use crate::trust::Settings;
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
@@ -44,39 +40,14 @@ const GAMEPADS: &[&str] = &[
|
|||||||
"steamdeck",
|
"steamdeck",
|
||||||
];
|
];
|
||||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||||
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
|
/// Codec setting values (persisted) paired with their display labels below.
|
||||||
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
|
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||||
/// HEVC when either side can't do it.
|
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"];
|
|
||||||
const CODEC_LABELS: &[&str] = &[
|
|
||||||
"Automatic",
|
|
||||||
"HEVC (H.265)",
|
|
||||||
"H.264 (AVC)",
|
|
||||||
"AV1",
|
|
||||||
"PyroWave (wired LAN)",
|
|
||||||
];
|
|
||||||
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
||||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||||
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
|
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
|
||||||
/// The SELECTED touch mode explained — the caption swaps with the choice (the Apple
|
|
||||||
/// revamp's dynamic-caption idiom) instead of narrating all three modes at once.
|
|
||||||
/// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a
|
|
||||||
/// wrapped subtitle's natural width crushes the selected-value label into an ellipsis.
|
|
||||||
const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
|
||||||
"Drives the cursor like a laptop trackpad — tap to click",
|
|
||||||
"The cursor jumps to your finger — a tap clicks there",
|
|
||||||
"Real multi-touch reaches the host — for touch-native apps",
|
|
||||||
];
|
|
||||||
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
|
|
||||||
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
|
|
||||||
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
|
|
||||||
const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"];
|
|
||||||
const MOUSE_MODE_CAPTIONS: &[&str] = &[
|
|
||||||
"Pointer locks to the stream — relative motion, best for games",
|
|
||||||
"Pointer moves freely in and out — best for remote desktop work",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||||
const APP_LICENSE: &str = concat!(
|
const APP_LICENSE: &str = concat!(
|
||||||
@@ -295,92 +266,22 @@ impl ChoiceRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a row's caption after construction — the dynamic-caption hook (touch mode,
|
|
||||||
/// resolution, codec). Both ChoiceRow shapes carry their subtitle on [`adw::ActionRow`]
|
|
||||||
/// ([`adw::ComboRow`] derives from it), so one downcast covers desktop and gamescope mode.
|
|
||||||
fn set_row_subtitle(row: &adw::PreferencesRow, text: &str) {
|
|
||||||
if let Some(r) = row.downcast_ref::<adw::ActionRow>() {
|
|
||||||
r.set_subtitle(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SELECTED resolution choice explained (row index: 0 = Native, 1 = Match window,
|
|
||||||
/// 2.. = explicit sizes) — one line each, see the caption-width note on
|
|
||||||
/// [`TOUCH_MODE_CAPTIONS`].
|
|
||||||
fn resolution_caption(i: u32) -> &'static str {
|
|
||||||
match i {
|
|
||||||
0 => "The native mode of this monitor, resolved at connect",
|
|
||||||
1 => "Follows the stream window — resizes renegotiate the host output",
|
|
||||||
_ => "The host drives a virtual output at exactly this size",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SELECTED codec explained: the PyroWave entry is the one that needs its trade-off
|
|
||||||
/// spelled out; everything else shares the soft-preference line.
|
|
||||||
fn codec_caption(i: u32) -> &'static str {
|
|
||||||
if CODECS.get(i as usize) == Some(&"pyrowave") {
|
|
||||||
"Wavelet codec for wired LAN — minimal latency, lots of bandwidth"
|
|
||||||
} else {
|
|
||||||
"A preference — the host falls back if it can't encode it"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A settings category page for the dialog's view switcher.
|
|
||||||
fn page(title: &str, icon: &str) -> adw::PreferencesPage {
|
|
||||||
adw::PreferencesPage::builder()
|
|
||||||
// The name addresses the page programmatically (`set_visible_page_name` — the
|
|
||||||
// screenshot harness's page knob); the title is what the view switcher shows.
|
|
||||||
.name(title.to_lowercase())
|
|
||||||
.title(title)
|
|
||||||
.icon_name(icon)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Startup device probes for the pickers — filled by the app shell in the background
|
|
||||||
/// (GPUs via `punktfunk-session --list-adapters`, audio endpoints via the PipeWire
|
|
||||||
/// registry); any list may still be empty when the dialog opens, which simply hides
|
|
||||||
/// that picker.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct DeviceProbes {
|
|
||||||
pub adapters: Vec<String>,
|
|
||||||
pub speakers: Vec<pf_client_core::audio::AudioDevice>,
|
|
||||||
pub mics: Vec<pf_client_core::audio::AudioDevice>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A titled group of rows; `description` (may be empty) is the one form-level note —
|
|
||||||
/// per-field explanations belong in row subtitles, not here.
|
|
||||||
fn group(title: &str, description: &str) -> adw::PreferencesGroup {
|
|
||||||
let g = adw::PreferencesGroup::builder().title(title).build();
|
|
||||||
if !description.is_empty() {
|
|
||||||
g.set_description(Some(description));
|
|
||||||
}
|
|
||||||
g
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
||||||
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
|
/// there so the experimental library toggle takes effect without a nav round-trip).
|
||||||
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
|
|
||||||
/// presented dialog so the screenshot harness can select a page; callers ignore it.
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
parent: &impl IsA<gtk::Widget>,
|
parent: &impl IsA<gtk::Widget>,
|
||||||
settings: Rc<RefCell<Settings>>,
|
settings: Rc<RefCell<Settings>>,
|
||||||
gamepads: &crate::gamepad::GamepadService,
|
gamepads: &crate::gamepad::GamepadService,
|
||||||
probes: &DeviceProbes,
|
|
||||||
on_closed: impl Fn() + 'static,
|
on_closed: impl Fn() + 'static,
|
||||||
) -> adw::PreferencesDialog {
|
) {
|
||||||
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
||||||
// subpage onto it.
|
// subpage onto it.
|
||||||
let dialog = adw::PreferencesDialog::new();
|
let dialog = adw::PreferencesDialog::new();
|
||||||
dialog.set_title("Preferences");
|
dialog.set_title("Preferences");
|
||||||
dialog.set_search_enabled(true);
|
|
||||||
// Wide enough that the category switcher sits in the HEADER BAR (the tabbed look the
|
|
||||||
// Apple/Windows clients have): AdwPreferencesDialog moves it to a bottom bar below a
|
|
||||||
// breakpoint of 110pt × page count (≈ 733 px for our five pages). In a window that
|
|
||||||
// can't give the dialog this width it still collapses to the bottom bar on its own.
|
|
||||||
dialog.set_content_width(830);
|
|
||||||
let inline = gamescope_session();
|
let inline = gamescope_session();
|
||||||
|
let page = adw::PreferencesPage::new();
|
||||||
|
|
||||||
// ---- Display: Resolution ----
|
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||||
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||||
// `match_window` flag), then the explicit sizes.
|
// `match_window` flag), then the explicit sizes.
|
||||||
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
||||||
@@ -396,13 +297,10 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Resolution",
|
"Resolution",
|
||||||
resolution_caption(0),
|
"The host creates a virtual output at exactly this size — Match window follows \
|
||||||
|
the stream window, including mid-stream resizes",
|
||||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
{
|
|
||||||
let w = res_row.widget().clone();
|
|
||||||
res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i)));
|
|
||||||
}
|
|
||||||
let hz_names: Vec<String> = REFRESH
|
let hz_names: Vec<String> = REFRESH
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&r| {
|
.map(|&r| {
|
||||||
@@ -417,11 +315,9 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Refresh rate",
|
"Refresh rate",
|
||||||
"Native follows the monitor the window is on",
|
"",
|
||||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Display: Quality ----
|
|
||||||
let scale_names: Vec<String> = RENDER_SCALES
|
let scale_names: Vec<String> = RENDER_SCALES
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&s| render_scale_label(s))
|
.map(|&s| render_scale_label(s))
|
||||||
@@ -430,69 +326,13 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Render scale",
|
"Render scale",
|
||||||
"Above 1× supersamples for sharpness; below is lighter on the host",
|
"Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \
|
||||||
|
(< 1×) for a lighter host — this device resamples to the window",
|
||||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||||
bitrate_row.set_title("Bitrate");
|
bitrate_row.set_title("Bitrate");
|
||||||
bitrate_row
|
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||||
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
|
|
||||||
let codec_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Video codec",
|
|
||||||
codec_caption(0),
|
|
||||||
CODEC_LABELS,
|
|
||||||
);
|
|
||||||
{
|
|
||||||
let w = codec_row.widget().clone();
|
|
||||||
codec_row.connect_changed(move |i| set_row_subtitle(&w, codec_caption(i)));
|
|
||||||
}
|
|
||||||
let hdr_row = adw::SwitchRow::builder()
|
|
||||||
.title("10-bit HDR")
|
|
||||||
.subtitle(
|
|
||||||
"Advertise 10-bit HDR10 so the host upgrades HDR content — shown in HDR where \
|
|
||||||
the display supports it, tone-mapped otherwise",
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
let decoder_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Video decoder",
|
|
||||||
"Automatic picks the best hardware decode, then software",
|
|
||||||
&["Automatic", "Vulkan Video", "VAAPI", "Software"],
|
|
||||||
);
|
|
||||||
// GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick
|
|
||||||
// via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to
|
|
||||||
// pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry.
|
|
||||||
let saved_adapter = settings.borrow().adapter.clone();
|
|
||||||
let mut gpu_names = vec!["Automatic".to_string()];
|
|
||||||
let mut gpu_keys: Vec<String> = vec![String::new()];
|
|
||||||
for a in &probes.adapters {
|
|
||||||
gpu_names.push(a.clone());
|
|
||||||
gpu_keys.push(a.clone());
|
|
||||||
}
|
|
||||||
if !saved_adapter.is_empty() && !gpu_keys.contains(&saved_adapter) {
|
|
||||||
gpu_names.push(format!("{saved_adapter} (not detected)"));
|
|
||||||
gpu_keys.push(saved_adapter.clone());
|
|
||||||
}
|
|
||||||
let gpu_row = (gpu_keys.len() > 1).then(|| {
|
|
||||||
let row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"GPU",
|
|
||||||
"Decodes and presents the stream",
|
|
||||||
&gpu_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
|
||||||
);
|
|
||||||
let i = gpu_keys
|
|
||||||
.iter()
|
|
||||||
.position(|k| k == &saved_adapter)
|
|
||||||
.unwrap_or(0);
|
|
||||||
row.set_selected(i as u32);
|
|
||||||
row
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Display: Host output ----
|
|
||||||
let compositor_row = ChoiceRow::new(
|
let compositor_row = ChoiceRow::new(
|
||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
@@ -506,19 +346,19 @@ pub fn show(
|
|||||||
"gamescope",
|
"gamescope",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
let decoder_row = ChoiceRow::new(
|
||||||
// ---- General ----
|
&dialog,
|
||||||
let fullscreen_row = adw::SwitchRow::builder()
|
inline,
|
||||||
.title("Start streams in fullscreen")
|
"Video decoder",
|
||||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
|
||||||
.build();
|
Vulkan Video on NVIDIA), falling back to software",
|
||||||
let wake_row = adw::SwitchRow::builder()
|
&[
|
||||||
.title("Auto-wake on connect")
|
"Automatic (hardware → software)",
|
||||||
.subtitle(
|
"Vulkan Video",
|
||||||
"Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \
|
"VAAPI",
|
||||||
off if hosts behind a VPN look offline when they aren't",
|
"Software",
|
||||||
)
|
],
|
||||||
.build();
|
);
|
||||||
let stats_row = ChoiceRow::new(
|
let stats_row = ChoiceRow::new(
|
||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
@@ -526,115 +366,20 @@ pub fn show(
|
|||||||
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||||
&["Off", "Compact", "Normal", "Detailed"],
|
&["Off", "Compact", "Normal", "Detailed"],
|
||||||
);
|
);
|
||||||
let library_row = adw::SwitchRow::builder()
|
let fullscreen_row = adw::SwitchRow::builder()
|
||||||
.title("Show game library")
|
.title("Start streams in fullscreen")
|
||||||
.subtitle(
|
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||||
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
|
|
||||||
and launch one directly. No extra host setup",
|
|
||||||
)
|
|
||||||
.build();
|
.build();
|
||||||
|
stream.add(res_row.widget());
|
||||||
|
stream.add(hz_row.widget());
|
||||||
|
stream.add(scale_row.widget());
|
||||||
|
stream.add(&bitrate_row);
|
||||||
|
stream.add(compositor_row.widget());
|
||||||
|
stream.add(decoder_row.widget());
|
||||||
|
stream.add(&fullscreen_row);
|
||||||
|
stream.add(stats_row.widget());
|
||||||
|
|
||||||
// ---- Input ----
|
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||||
let touch_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Touch input",
|
|
||||||
TOUCH_MODE_CAPTIONS[0],
|
|
||||||
TOUCH_MODE_LABELS,
|
|
||||||
);
|
|
||||||
// Dynamic caption: describe the SELECTED mode, not all three at once.
|
|
||||||
{
|
|
||||||
let w = touch_row.widget().clone();
|
|
||||||
touch_row.connect_changed(move |i| {
|
|
||||||
let i = (i as usize).min(TOUCH_MODE_CAPTIONS.len() - 1);
|
|
||||||
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let mouse_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Mouse input",
|
|
||||||
MOUSE_MODE_CAPTIONS[0],
|
|
||||||
MOUSE_MODE_LABELS,
|
|
||||||
);
|
|
||||||
{
|
|
||||||
let w = mouse_row.widget().clone();
|
|
||||||
mouse_row.connect_changed(move |i| {
|
|
||||||
let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1);
|
|
||||||
set_row_subtitle(&w, MOUSE_MODE_CAPTIONS[i]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let inhibit_row = adw::SwitchRow::builder()
|
|
||||||
.title("Capture system shortcuts")
|
|
||||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
|
||||||
.build();
|
|
||||||
let invert_row = adw::SwitchRow::builder()
|
|
||||||
.title("Invert scroll direction")
|
|
||||||
.subtitle("Reverses the wheel and trackpad scroll direction sent to the host")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// ---- Audio ----
|
|
||||||
let surround_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Audio channels",
|
|
||||||
"Stereo or surround — the host downmixes if its output has fewer",
|
|
||||||
&["Stereo", "5.1 Surround", "7.1 Surround"],
|
|
||||||
);
|
|
||||||
let mic_row = adw::SwitchRow::builder()
|
|
||||||
.title("Stream microphone")
|
|
||||||
.subtitle("Sends your microphone to the host's virtual mic")
|
|
||||||
.build();
|
|
||||||
// Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the
|
|
||||||
// stored value is the node name. Hidden when the probe found nothing; a saved
|
|
||||||
// device that's gone keeps a revertable "(not detected)" entry, like the GPU row.
|
|
||||||
let dev_row = |saved: String,
|
|
||||||
devs: &[pf_client_core::audio::AudioDevice],
|
|
||||||
title: &str,
|
|
||||||
subtitle: &str| {
|
|
||||||
let mut names = vec!["System default".to_string()];
|
|
||||||
let mut keys = vec![String::new()];
|
|
||||||
for d in devs {
|
|
||||||
names.push(d.description.clone());
|
|
||||||
keys.push(d.name.clone());
|
|
||||||
}
|
|
||||||
if !saved.is_empty() && !keys.contains(&saved) {
|
|
||||||
names.push(format!("{saved} (not detected)"));
|
|
||||||
keys.push(saved.clone());
|
|
||||||
}
|
|
||||||
let row = (keys.len() > 1).then(|| {
|
|
||||||
let row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
&names.iter().map(String::as_str).collect::<Vec<_>>(),
|
|
||||||
);
|
|
||||||
row.set_selected(keys.iter().position(|k| k == &saved).unwrap_or(0) as u32);
|
|
||||||
row
|
|
||||||
});
|
|
||||||
(row, keys)
|
|
||||||
};
|
|
||||||
let (speaker_row, speaker_keys) = dev_row(
|
|
||||||
settings.borrow().speaker_device.clone(),
|
|
||||||
&probes.speakers,
|
|
||||||
"Speaker",
|
|
||||||
"Host audio plays here — System default follows the desktop",
|
|
||||||
);
|
|
||||||
let (micdev_row, micdev_keys) = dev_row(
|
|
||||||
settings.borrow().mic_device.clone(),
|
|
||||||
&probes.mics,
|
|
||||||
"Microphone",
|
|
||||||
"The input that feeds the host's virtual mic",
|
|
||||||
);
|
|
||||||
// The device pick only matters while the mic streams at all.
|
|
||||||
if let Some(r) = &micdev_row {
|
|
||||||
let w = r.widget().clone();
|
|
||||||
w.set_sensitive(mic_row.is_active());
|
|
||||||
mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Controllers ----
|
|
||||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
|
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
|
||||||
// (Steam's virtual pad skipped); pinning one restricts the session to that single
|
// (Steam's virtual pad skipped); pinning one restricts the session to that single
|
||||||
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
||||||
@@ -668,7 +413,7 @@ pub fn show(
|
|||||||
if pads.is_empty() {
|
if pads.is_empty() {
|
||||||
"No controllers detected"
|
"No controllers detected"
|
||||||
} else {
|
} else {
|
||||||
"Every pad is its own player — pick one to force single-player"
|
"All controllers are forwarded, each as its own player; pick one to force single-player"
|
||||||
},
|
},
|
||||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
@@ -698,7 +443,7 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Gamepad type",
|
"Gamepad type",
|
||||||
"The virtual pad on the host — Automatic matches your controller",
|
"The virtual pad the host creates — Automatic matches the physical pad",
|
||||||
&[
|
&[
|
||||||
"Automatic",
|
"Automatic",
|
||||||
"Xbox 360",
|
"Xbox 360",
|
||||||
@@ -708,8 +453,66 @@ pub fn show(
|
|||||||
"Steam Deck",
|
"Steam Deck",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
let touch_row = ChoiceRow::new(
|
||||||
|
&dialog,
|
||||||
|
inline,
|
||||||
|
"Touch input",
|
||||||
|
"How the touchscreen drives the host — Trackpad nudges a cursor (tap to click); \
|
||||||
|
Direct pointer jumps to your finger; Touch passthrough sends real touches",
|
||||||
|
TOUCH_MODE_LABELS,
|
||||||
|
);
|
||||||
|
let inhibit_row = adw::SwitchRow::builder()
|
||||||
|
.title("Capture system shortcuts")
|
||||||
|
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||||
|
.build();
|
||||||
|
input.add(forward_row.widget());
|
||||||
|
input.add(pad_row.widget());
|
||||||
|
input.add(touch_row.widget());
|
||||||
|
input.add(&inhibit_row);
|
||||||
|
|
||||||
// ---- Seed from the current settings ----
|
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||||||
|
let surround_row = ChoiceRow::new(
|
||||||
|
&dialog,
|
||||||
|
inline,
|
||||||
|
"Audio channels",
|
||||||
|
"Request stereo or surround (the host downmixes if its output has fewer)",
|
||||||
|
&["Stereo", "5.1 Surround", "7.1 Surround"],
|
||||||
|
);
|
||||||
|
audio.add(surround_row.widget());
|
||||||
|
let codec_row = ChoiceRow::new(
|
||||||
|
&dialog,
|
||||||
|
inline,
|
||||||
|
"Video codec",
|
||||||
|
"Preferred codec — the host falls back if it can't encode this one",
|
||||||
|
CODEC_LABELS,
|
||||||
|
);
|
||||||
|
stream.add(codec_row.widget());
|
||||||
|
let mic_row = adw::SwitchRow::builder()
|
||||||
|
.title("Stream microphone")
|
||||||
|
.subtitle("Send the default input device to the host's virtual microphone")
|
||||||
|
.build();
|
||||||
|
audio.add(&mic_row);
|
||||||
|
|
||||||
|
// Experimental — mirrors the Apple client's Experimental section (wording included).
|
||||||
|
let experimental = adw::PreferencesGroup::builder()
|
||||||
|
.title("Experimental")
|
||||||
|
.build();
|
||||||
|
let library_row = adw::SwitchRow::builder()
|
||||||
|
.title("Show game library")
|
||||||
|
.subtitle(
|
||||||
|
"Adds a “Browse library…” action to each saved host that lists its games \
|
||||||
|
(Steam + custom) via the host's management API — works once you've paired",
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
experimental.add(&library_row);
|
||||||
|
|
||||||
|
// About (with the license/third-party Legal pages) lives in the primary menu now.
|
||||||
|
page.add(&stream);
|
||||||
|
page.add(&input);
|
||||||
|
page.add(&audio);
|
||||||
|
page.add(&experimental);
|
||||||
|
|
||||||
|
// Seed from the current settings.
|
||||||
{
|
{
|
||||||
let s = settings.borrow();
|
let s = settings.borrow();
|
||||||
let res_i = if s.match_window {
|
let res_i = if s.match_window {
|
||||||
@@ -722,7 +525,6 @@ pub fn show(
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
};
|
};
|
||||||
res_row.set_selected(res_i as u32);
|
res_row.set_selected(res_i as u32);
|
||||||
set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32));
|
|
||||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||||
hz_row.set_selected(hz_i as u32);
|
hz_row.set_selected(hz_i as u32);
|
||||||
let scale_i = RENDER_SCALES
|
let scale_i = RENDER_SCALES
|
||||||
@@ -738,14 +540,6 @@ pub fn show(
|
|||||||
.position(|&t| t == s.touch_mode)
|
.position(|&t| t == s.touch_mode)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
touch_row.set_selected(touch_i as u32);
|
touch_row.set_selected(touch_i as u32);
|
||||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
|
||||||
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
|
|
||||||
let mouse_i = MOUSE_MODES
|
|
||||||
.iter()
|
|
||||||
.position(|&m| m == s.mouse_mode)
|
|
||||||
.unwrap_or(0);
|
|
||||||
mouse_row.set_selected(mouse_i as u32);
|
|
||||||
set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]);
|
|
||||||
let comp_i = COMPOSITORS
|
let comp_i = COMPOSITORS
|
||||||
.iter()
|
.iter()
|
||||||
.position(|&c| c == s.compositor)
|
.position(|&c| c == s.compositor)
|
||||||
@@ -759,11 +553,8 @@ pub fn show(
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
stats_row.set_selected(stats_i as u32);
|
stats_row.set_selected(stats_i as u32);
|
||||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||||
wake_row.set_active(s.auto_wake);
|
|
||||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||||
invert_row.set_active(s.invert_scroll);
|
|
||||||
mic_row.set_active(s.mic_enabled);
|
mic_row.set_active(s.mic_enabled);
|
||||||
hdr_row.set_active(s.hdr_enabled);
|
|
||||||
library_row.set_active(s.library_enabled);
|
library_row.set_active(s.library_enabled);
|
||||||
surround_row.set_selected(match s.audio_channels {
|
surround_row.set_selected(match s.audio_channels {
|
||||||
6 => 1,
|
6 => 1,
|
||||||
@@ -772,105 +563,9 @@ pub fn show(
|
|||||||
});
|
});
|
||||||
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
|
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
|
||||||
codec_row.set_selected(codec_i as u32);
|
codec_row.set_selected(codec_i as u32);
|
||||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Assemble the category pages (the Apple revamp's map) ----
|
dialog.add(&page);
|
||||||
let general = page("General", "preferences-system-symbolic");
|
|
||||||
let session_group = group("Session", "");
|
|
||||||
session_group.add(&fullscreen_row);
|
|
||||||
session_group.add(&wake_row);
|
|
||||||
let stats_group = group("Statistics", "");
|
|
||||||
stats_group.add(stats_row.widget());
|
|
||||||
let library_group = group("Library", "");
|
|
||||||
library_group.add(&library_row);
|
|
||||||
general.add(&session_group);
|
|
||||||
general.add(&stats_group);
|
|
||||||
general.add(&library_group);
|
|
||||||
|
|
||||||
let display = page("Display", "video-display-symbolic");
|
|
||||||
let resolution_group = group("Resolution", "");
|
|
||||||
resolution_group.add(res_row.widget());
|
|
||||||
resolution_group.add(hz_row.widget());
|
|
||||||
let quality_group = group("Quality", "");
|
|
||||||
quality_group.add(scale_row.widget());
|
|
||||||
quality_group.add(&bitrate_row);
|
|
||||||
quality_group.add(codec_row.widget());
|
|
||||||
quality_group.add(&hdr_row);
|
|
||||||
quality_group.add(decoder_row.widget());
|
|
||||||
if let Some(r) = &gpu_row {
|
|
||||||
quality_group.add(r.widget());
|
|
||||||
}
|
|
||||||
// The one form-level note (deliberately not repeated on every row).
|
|
||||||
let output_group = group(
|
|
||||||
"Host output",
|
|
||||||
"Display changes apply from the next session.",
|
|
||||||
);
|
|
||||||
output_group.add(compositor_row.widget());
|
|
||||||
display.add(&resolution_group);
|
|
||||||
display.add(&quality_group);
|
|
||||||
display.add(&output_group);
|
|
||||||
|
|
||||||
let input = page("Input", "input-keyboard-symbolic");
|
|
||||||
let touch_group = group("Touch", "");
|
|
||||||
touch_group.add(touch_row.widget());
|
|
||||||
// Group titles are Pango markup — the ampersand must be an entity.
|
|
||||||
let kbm_group = group("Keyboard & mouse", "");
|
|
||||||
kbm_group.add(mouse_row.widget());
|
|
||||||
kbm_group.add(&inhibit_row);
|
|
||||||
kbm_group.add(&invert_row);
|
|
||||||
input.add(&touch_group);
|
|
||||||
input.add(&kbm_group);
|
|
||||||
|
|
||||||
let audio = page("Audio", "audio-volume-high-symbolic");
|
|
||||||
let audio_group = group("", "Applies from the next session.");
|
|
||||||
audio_group.add(surround_row.widget());
|
|
||||||
if let Some(r) = &speaker_row {
|
|
||||||
audio_group.add(r.widget());
|
|
||||||
}
|
|
||||||
audio_group.add(&mic_row);
|
|
||||||
if let Some(r) = &micdev_row {
|
|
||||||
audio_group.add(r.widget());
|
|
||||||
}
|
|
||||||
audio.add(&audio_group);
|
|
||||||
|
|
||||||
let controllers = page("Controllers", "input-gaming-symbolic");
|
|
||||||
let controllers_group = group("", "");
|
|
||||||
// The detected-pad list (mirrors the Apple Controllers section): informational rows
|
|
||||||
// above the pickers, from the same snapshot that feeds the forwarding picker.
|
|
||||||
if pads.is_empty() {
|
|
||||||
let none = adw::ActionRow::builder()
|
|
||||||
.title("No controllers detected")
|
|
||||||
.css_classes(["dim-label"])
|
|
||||||
.build();
|
|
||||||
controllers_group.add(&none);
|
|
||||||
} else {
|
|
||||||
for p in &pads {
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(&p.name)
|
|
||||||
.use_markup(false)
|
|
||||||
.build();
|
|
||||||
if p.steam_virtual {
|
|
||||||
row.set_subtitle(
|
|
||||||
"Steam Input's virtual pad — Automatic skips it while a real pad is connected",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
row.set_subtitle(p.kind_label());
|
|
||||||
}
|
|
||||||
row.add_prefix(>k::Image::from_icon_name("input-gaming-symbolic"));
|
|
||||||
controllers_group.add(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
controllers_group.add(forward_row.widget());
|
|
||||||
controllers_group.add(pad_row.widget());
|
|
||||||
controllers.add(&controllers_group);
|
|
||||||
|
|
||||||
dialog.add(&general);
|
|
||||||
dialog.add(&display);
|
|
||||||
dialog.add(&input);
|
|
||||||
dialog.add(&audio);
|
|
||||||
dialog.add(&controllers);
|
|
||||||
|
|
||||||
dialog.connect_closed(move |_| {
|
dialog.connect_closed(move |_| {
|
||||||
let mut s = settings.borrow_mut();
|
let mut s = settings.borrow_mut();
|
||||||
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||||
@@ -885,42 +580,19 @@ pub fn show(
|
|||||||
s.render_scale =
|
s.render_scale =
|
||||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||||
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
|
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||||
// session, hand-edited or written by another client): it displays as "Automatic", and
|
|
||||||
// writing that back would silently erase it just by opening + closing the dialog.
|
|
||||||
// Persist the row only when the user picked a non-Auto entry or the stored value was
|
|
||||||
// a listed one to begin with.
|
|
||||||
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
|
|
||||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
|
||||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
|
||||||
}
|
|
||||||
s.touch_mode =
|
s.touch_mode =
|
||||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||||
s.mouse_mode =
|
|
||||||
MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
|
|
||||||
s.forward_pad = chosen_pin.borrow().clone();
|
s.forward_pad = chosen_pin.borrow().clone();
|
||||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||||
.to_string();
|
.to_string();
|
||||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||||
if let Some(r) = &gpu_row {
|
|
||||||
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
|
|
||||||
}
|
|
||||||
if let Some(r) = &speaker_row {
|
|
||||||
s.speaker_device =
|
|
||||||
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
|
|
||||||
}
|
|
||||||
if let Some(r) = &micdev_row {
|
|
||||||
s.mic_device = micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
|
|
||||||
}
|
|
||||||
s.set_stats_verbosity(
|
s.set_stats_verbosity(
|
||||||
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||||
);
|
);
|
||||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||||
s.auto_wake = wake_row.is_active();
|
|
||||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||||
s.invert_scroll = invert_row.is_active();
|
|
||||||
s.mic_enabled = mic_row.is_active();
|
s.mic_enabled = mic_row.is_active();
|
||||||
s.hdr_enabled = hdr_row.is_active();
|
|
||||||
s.audio_channels = match surround_row.selected() {
|
s.audio_channels = match surround_row.selected() {
|
||||||
1 => 6,
|
1 => 6,
|
||||||
2 => 8,
|
2 => 8,
|
||||||
@@ -933,7 +605,6 @@ pub fn show(
|
|||||||
on_closed();
|
on_closed();
|
||||||
});
|
});
|
||||||
dialog.present(Some(parent));
|
dialog.present(Some(parent));
|
||||||
dialog
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1007,17 +678,6 @@ mod tests {
|
|||||||
assert_eq!(fired.get(), 1);
|
assert_eq!(fired.get(), 1);
|
||||||
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
|
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
|
||||||
|
|
||||||
// The dynamic-caption hook drives the same subtitle both row shapes expose.
|
|
||||||
set_row_subtitle(row.widget(), "swapped");
|
|
||||||
assert_eq!(
|
|
||||||
row.widget()
|
|
||||||
.downcast_ref::<adw::ActionRow>()
|
|
||||||
.unwrap()
|
|
||||||
.subtitle()
|
|
||||||
.as_deref(),
|
|
||||||
Some("swapped")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-activating shows the check on the new selection (fresh subpage each time).
|
// Re-activating shows the check on the new selection (fresh subpage each time).
|
||||||
row.widget()
|
row.widget()
|
||||||
.downcast_ref::<adw::ActionRow>()
|
.downcast_ref::<adw::ActionRow>()
|
||||||
@@ -1038,16 +698,5 @@ mod tests {
|
|||||||
combo.set_selected(0);
|
combo.set_selected(0);
|
||||||
assert_eq!(combo.selected(), 0);
|
assert_eq!(combo.selected(), 0);
|
||||||
assert_eq!(combo_fired.get(), 0);
|
assert_eq!(combo_fired.get(), 0);
|
||||||
// ComboRow derives from ActionRow, so the caption hook reaches it too.
|
|
||||||
set_row_subtitle(combo.widget(), "combo caption");
|
|
||||||
assert_eq!(
|
|
||||||
combo
|
|
||||||
.widget()
|
|
||||||
.downcast_ref::<adw::ActionRow>()
|
|
||||||
.unwrap()
|
|
||||||
.subtitle()
|
|
||||||
.as_deref(),
|
|
||||||
Some("combo caption")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-29
@@ -458,11 +458,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
),
|
),
|
||||||
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
||||||
}
|
}
|
||||||
let (mut send, recv) = conn.open_bi().await.context("open control stream")?;
|
let (mut send, mut recv) = conn.open_bi().await.context("open control stream")?;
|
||||||
// Frame every read on the control stream through the resumable reader, exactly as the client
|
|
||||||
// pump does: `clock_sync` bounds each read with a timeout, and a frame straddling two wakeups
|
|
||||||
// would otherwise leave the stream permanently misaligned for the rest of the run.
|
|
||||||
let mut recv = io::MsgReader::new(recv);
|
|
||||||
|
|
||||||
io::write_msg(
|
io::write_msg(
|
||||||
&mut send,
|
&mut send,
|
||||||
@@ -487,24 +483,14 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
||||||
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
||||||
// qualifies for `--speed-test` bursts; without the bit the host declines them.
|
// qualifies for `--speed-test` bursts; without the bit the host declines them.
|
||||||
// STREAMED_AU: the same shared reassembler accepts sentinel-headed streamed
|
|
||||||
// blocks, and the probe is exactly the tool that measures the overlap win.
|
|
||||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
|
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
|
||||||
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ
|
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ;
|
||||||
| punktfunk_core::quic::VIDEO_CAP_STREAMED_AU;
|
|
||||||
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||||
}
|
}
|
||||||
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
|
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
|
||||||
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
||||||
}
|
}
|
||||||
// PUNKTFUNK_CLIENT_CHACHA20=1 advertises VIDEO_CAP_CHACHA20 — drives the
|
|
||||||
// host's ChaCha20-Poly1305 session-cipher resolution (the soft-AES armv7
|
|
||||||
// negotiation, design/chacha20-session-cipher.md §7) without a webOS build;
|
|
||||||
// the negotiated cipher is reported in the welcome log line below.
|
|
||||||
if std::env::var_os("PUNKTFUNK_CLIENT_CHACHA20").is_some() {
|
|
||||||
caps |= punktfunk_core::quic::VIDEO_CAP_CHACHA20;
|
|
||||||
}
|
|
||||||
caps
|
caps
|
||||||
},
|
},
|
||||||
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the
|
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the
|
||||||
@@ -523,15 +509,12 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
// writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding
|
// writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding
|
||||||
// path can be validated headlessly (check the host's monitor caps / ADD log line).
|
// path can be validated headlessly (check the host's monitor caps / ADD log line).
|
||||||
display_hdr: punktfunk_core::client::display_hdr_env_override(),
|
display_hdr: punktfunk_core::client::display_hdr_env_override(),
|
||||||
// No CLIENT_CAP_CURSOR: this headless tool renders nothing — advertising it would
|
|
||||||
// just strip the pointer from the dumped bitstream.
|
|
||||||
client_caps: 0,
|
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let welcome =
|
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)
|
||||||
Welcome::decode(&recv.read_msg().await?).map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
.map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mode = ?welcome.mode,
|
mode = ?welcome.mode,
|
||||||
fec = ?welcome.fec,
|
fec = ?welcome.fec,
|
||||||
@@ -545,11 +528,6 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||||
chroma_format_idc = welcome.chroma_format,
|
chroma_format_idc = welcome.chroma_format,
|
||||||
codec = codec_ext(welcome.codec),
|
codec = codec_ext(welcome.codec),
|
||||||
cipher = if welcome.cipher == punktfunk_core::quic::CIPHER_CHACHA20_POLY1305 {
|
|
||||||
"chacha20-poly1305"
|
|
||||||
} else {
|
|
||||||
"aes-128-gcm"
|
|
||||||
},
|
|
||||||
"session offer"
|
"session offer"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -651,7 +629,10 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("Reconfigure write failed");
|
tracing::error!("Reconfigure write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match rr.read_msg().await.map(|b| Reconfigured::decode(&b)) {
|
match io::read_msg(&mut rr)
|
||||||
|
.await
|
||||||
|
.map(|b| Reconfigured::decode(&b))
|
||||||
|
{
|
||||||
Ok(Ok(ack)) if ack.accepted => {
|
Ok(Ok(ack)) if ack.accepted => {
|
||||||
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
||||||
}
|
}
|
||||||
@@ -704,7 +685,10 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("SetBitrate write failed");
|
tracing::error!("SetBitrate write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match rr.read_msg().await.map(|b| BitrateChanged::decode(&b)) {
|
match io::read_msg(&mut rr)
|
||||||
|
.await
|
||||||
|
.map(|b| BitrateChanged::decode(&b))
|
||||||
|
{
|
||||||
Ok(Ok(ack)) => tracing::info!(
|
Ok(Ok(ack)) => tracing::info!(
|
||||||
applied_kbps = ack.bitrate_kbps,
|
applied_kbps = ack.bitrate_kbps,
|
||||||
"BITRATE CHANGE acked by host"
|
"BITRATE CHANGE acked by host"
|
||||||
@@ -766,7 +750,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("ProbeRequest write failed");
|
tracing::error!("ProbeRequest write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let res = match sr.read_msg().await.map(|b| ProbeResult::decode(&b)) {
|
let res = match io::read_msg(&mut sr).await.map(|b| ProbeResult::decode(&b)) {
|
||||||
Ok(Ok(r)) => r,
|
Ok(Ok(r)) => r,
|
||||||
other => {
|
other => {
|
||||||
tracing::error!(?other, "bad ProbeResult");
|
tracing::error!(?other, "bad ProbeResult");
|
||||||
|
|||||||
@@ -27,13 +27,9 @@ ui = ["dep:pf-console-ui", "dep:serde_json"]
|
|||||||
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
|
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
|
||||||
# binary.
|
# binary.
|
||||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||||
# `default-features = false` on both: THIS crate's `pyrowave` feature (above) is the single
|
pf-presenter = { path = "../../crates/pf-presenter" }
|
||||||
# switch that turns the wavelet codec on, and it enables it explicitly on each. Inheriting their
|
|
||||||
# defaults instead would make `--no-default-features` a lie — the Windows ARM64 leg builds that
|
|
||||||
# way precisely to skip the vendored PyroWave C++, which has no ARM64 SIMD path.
|
|
||||||
pf-presenter = { path = "../../crates/pf-presenter", default-features = false }
|
|
||||||
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
|
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
|
||||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||||
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
||||||
serde_json = { version = "1", optional = true }
|
serde_json = { version = "1", optional = true }
|
||||||
|
|||||||
@@ -158,8 +158,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
v => v,
|
v => v,
|
||||||
},
|
},
|
||||||
touch_mode: settings_at_start.touch_mode(),
|
touch_mode: settings_at_start.touch_mode(),
|
||||||
mouse_mode: settings_at_start.mouse_mode(),
|
|
||||||
invert_scroll: settings_at_start.invert_scroll,
|
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||||
let fp_hex = trust::hex(&fingerprint);
|
let fp_hex = trust::hex(&fingerprint);
|
||||||
@@ -454,7 +452,6 @@ impl ServiceState {
|
|||||||
paired: false,
|
paired: false,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: Vec::new(),
|
mac: Vec::new(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Err(e) = known.save() {
|
if let Err(e) = known.save() {
|
||||||
|
|||||||
@@ -103,14 +103,6 @@ mod session_main {
|
|||||||
force_software: Arc<AtomicBool>,
|
force_software: Arc<AtomicBool>,
|
||||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||||
) -> SessionParams {
|
) -> SessionParams {
|
||||||
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3), resolved
|
|
||||||
// here rather than passed in so every caller — a direct connect and the console's
|
|
||||||
// own launches — honors the same stored decision. `addr` is moved into the struct
|
|
||||||
// below, so read it first.
|
|
||||||
let clipboard = trust::KnownHosts::load()
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync);
|
|
||||||
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
||||||
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
||||||
// process. Applied per params-build (idempotent; browse re-launches included) so
|
// process. Applied per params-build (idempotent; browse re-launches included) so
|
||||||
@@ -172,13 +164,7 @@ mod session_main {
|
|||||||
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||||
// pump) pins one manually.
|
// pump) pins one manually.
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
|
|
||||||
// channel); capture-mode sessions keep the composited cursor, so only advertise
|
|
||||||
// when the session STARTS in desktop mode. The host gates further (Linux portal
|
|
||||||
// compositors only).
|
|
||||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
|
||||||
mic_enabled: settings.mic_enabled,
|
mic_enabled: settings.mic_enabled,
|
||||||
clipboard,
|
|
||||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||||
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||||
@@ -273,66 +259,19 @@ mod session_main {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
|
|
||||||
// line, discrete first) for the desktop shells' GPU picker, then exit.
|
|
||||||
if arg_flag("--list-adapters") {
|
|
||||||
return match pf_presenter::vk::list_adapters() {
|
|
||||||
Ok(names) => {
|
|
||||||
for n in names {
|
|
||||||
println!("{n}");
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("list-adapters: {e:#}");
|
|
||||||
EXIT_PRESENTER_FAILED
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
|
|
||||||
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
|
|
||||||
// same enumeration the GTK shell probes.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if arg_flag("--list-audio") {
|
|
||||||
return match pf_client_core::audio::devices() {
|
|
||||||
Ok((sinks, sources)) => {
|
|
||||||
for d in sinks {
|
|
||||||
println!("sink\t{}\t{}", d.name, d.description);
|
|
||||||
}
|
|
||||||
for d in sources {
|
|
||||||
println!("source\t{}\t{}", d.name, d.description);
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("list-audio: {e:#}");
|
|
||||||
EXIT_PRESENTER_FAILED
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
enable_radv_video_decode();
|
enable_radv_video_decode();
|
||||||
|
|
||||||
// The Settings device picks → env, unless the user already forced one by hand:
|
// The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing
|
||||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
// name) → the presenter's device selection, unless the user already forced one.
|
||||||
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
// Before any Vulkan call, like the RADV knob (covers --connect and --browse).
|
||||||
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() {
|
||||||
// the RADV knob (covers --connect and --browse).
|
let adapter = trust::Settings::load().adapter;
|
||||||
{
|
if !adapter.is_empty() {
|
||||||
let s = trust::Settings::load();
|
std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter);
|
||||||
for (var, value) in [
|
|
||||||
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
|
|
||||||
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
|
|
||||||
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
|
|
||||||
] {
|
|
||||||
if std::env::var_os(var).is_none() && !value.is_empty() {
|
|
||||||
std::env::set_var(var, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,8 +373,6 @@ mod session_main {
|
|||||||
v => v,
|
v => v,
|
||||||
},
|
},
|
||||||
touch_mode: settings.touch_mode(),
|
touch_mode: settings.touch_mode(),
|
||||||
mouse_mode: settings.mouse_mode(),
|
|
||||||
invert_scroll: settings.invert_scroll,
|
|
||||||
json_status: true,
|
json_status: true,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
// This host's card carries the accent bar in the desktop client now.
|
// This host's card carries the accent bar in the desktop client now.
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ repository.workspace = true
|
|||||||
name = "punktfunk-client"
|
name = "punktfunk-client"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
# The couch/HTPC Start-menu entry. Its own executable because an MSIX <Application> cannot
|
|
||||||
# pass arguments to a full-trust exe — see the binary's own docs.
|
|
||||||
[[bin]]
|
|
||||||
name = "punktfunk-console"
|
|
||||||
path = "src/bin/punktfunk-console.rs"
|
|
||||||
|
|
||||||
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
|
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
|
||||||
# other native clients live in clients/linux and clients/apple); on other
|
# other native clients live in clients/linux and clients/apple); on other
|
||||||
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
|
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
|
||||||
@@ -29,17 +23,7 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
|||||||
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
|
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
|
||||||
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
|
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
|
||||||
# data model (fetch + art pipeline) behind the library page.
|
# data model (fetch + art pipeline) behind the library page.
|
||||||
#
|
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||||
# `default-features = false` drops pf-client-core's default `pyrowave`, which would otherwise
|
|
||||||
# build the vendored PyroWave C++ INTO THE SHELL — dead weight here (the shell never decodes;
|
|
||||||
# it only offers "pyrowave" as a codec preference string the session binary acts on) and fatal
|
|
||||||
# on ARM64, where Granite's math falls back to x86 SSE intrinsics and stops at
|
|
||||||
# `simd.hpp: #error "Implement me."`. This does NOT drop PyroWave from the Windows client:
|
|
||||||
# decode lives in the spawned punktfunk-session binary, whose own default enables the feature,
|
|
||||||
# and cargo's feature unification turns it back on for the shared pf-client-core whenever that
|
|
||||||
# binary is in the same build (x64). On the ARM64 leg both are built --no-default-features, so
|
|
||||||
# nothing enables it and the C++ is never compiled.
|
|
||||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
|
||||||
|
|
||||||
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
||||||
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
||||||
|
|||||||
@@ -57,28 +57,6 @@
|
|||||||
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
||||||
</uap:VisualElements>
|
</uap:VisualElements>
|
||||||
</Application>
|
</Application>
|
||||||
<!--
|
|
||||||
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
|
|
||||||
desktop shell is the wrong first screen. Its own executable (punktfunk-console.exe)
|
|
||||||
because an MSIX Application entry cannot pass arguments to a full-trust exe; it hands
|
|
||||||
straight off to the session binary's controller-driven browse mode (host list,
|
|
||||||
pairing, settings, library) fullscreen.
|
|
||||||
|
|
||||||
NOTE: never write a double hyphen in this file. XML forbids it inside a comment, and
|
|
||||||
makepri rejects the whole manifest ("Appx manifest not found or is invalid") — which
|
|
||||||
is exactly how the console flag spelled out here broke the v0.15.0 MSIX build.
|
|
||||||
-->
|
|
||||||
<Application Id="PunktfunkConsole" Executable="punktfunk-console.exe"
|
|
||||||
EntryPoint="Windows.FullTrustApplication">
|
|
||||||
<uap:VisualElements
|
|
||||||
DisplayName="Punktfunk Console"
|
|
||||||
Description="Controller-driven couch interface for TVs and HTPCs"
|
|
||||||
BackgroundColor="transparent"
|
|
||||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
|
||||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
|
||||||
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
|
||||||
</uap:VisualElements>
|
|
||||||
</Application>
|
|
||||||
</Applications>
|
</Applications>
|
||||||
|
|
||||||
<Capabilities>
|
<Capabilities>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $layout 'Assets') | Out-Nul
|
|||||||
# session client the shell spawns for every stream (sibling resolution — see clients/windows/
|
# session client the shell spawns for every stream (sibling resolution — see clients/windows/
|
||||||
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
|
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
|
||||||
# adds no DLLs of its own.
|
# adds no DLLs of its own.
|
||||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||||
foreach ($f in $required) {
|
foreach ($f in $required) {
|
||||||
$src = Join-Path $TargetDir $f
|
$src = Join-Path $TargetDir $f
|
||||||
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
|
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ pub(crate) fn initiate_waking(
|
|||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
set_status: &AsyncSetState<String>,
|
||||||
) {
|
) {
|
||||||
if ctx.settings.lock().unwrap().auto_wake {
|
|
||||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||||
}
|
|
||||||
initiate_opts(ctx, target, set_screen, set_status, true)
|
initiate_opts(ctx, target, set_screen, set_status, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +272,6 @@ fn connect_spawn(
|
|||||||
paired: persist_paired,
|
paired: persist_paired,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: target.mac.clone(),
|
mac: target.mac.clone(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
@@ -294,13 +291,9 @@ fn connect_spawn(
|
|||||||
*shared.target.lock().unwrap() = target.clone();
|
*shared.target.lock().unwrap() = target.clone();
|
||||||
ss.call(Screen::Pair);
|
ss.call(Screen::Pair);
|
||||||
}
|
}
|
||||||
Some((_, false))
|
Some((_, false)) if wake_on_fail => {
|
||||||
if wake_on_fail && ctx2.settings.lock().unwrap().auto_wake =>
|
|
||||||
{
|
|
||||||
// The dial-first attempt to a non-advertising host failed — it
|
// The dial-first attempt to a non-advertising host failed — it
|
||||||
// may genuinely be asleep. NOW wake and wait. Skipped entirely
|
// may genuinely be asleep. NOW wake and wait.
|
||||||
// when auto-wake is off: the wait is only worth showing if we
|
|
||||||
// are actually sending magic packets to end it.
|
|
||||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
||||||
}
|
}
|
||||||
Some((msg, false)) => {
|
Some((msg, false)) => {
|
||||||
@@ -328,12 +321,9 @@ fn connect_spawn(
|
|||||||
/// PAIRED host in the session window. The shell yields exactly like a stream — hidden on
|
/// PAIRED host in the session window. The shell yields exactly like a stream — hidden on
|
||||||
/// the library window's `ready`, restored when the child exits (launched titles stream
|
/// the library window's `ready`, restored when the child exits (launched titles stream
|
||||||
/// in that same window, so the whole couch round-trip happens without the shell).
|
/// in that same window, so the whole couch round-trip happens without the shell).
|
||||||
/// `target = None` opens the console's own host view (discovery, pairing, settings) — the
|
|
||||||
/// couch entry point that isn't tied to one host; `Some` opens straight into that host's
|
|
||||||
/// library.
|
|
||||||
pub(crate) fn open_console(
|
pub(crate) fn open_console(
|
||||||
ctx: &Arc<AppCtx>,
|
ctx: &Arc<AppCtx>,
|
||||||
target: Option<Target>,
|
target: Target,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
set_status: &AsyncSetState<String>,
|
||||||
) {
|
) {
|
||||||
@@ -341,21 +331,15 @@ pub(crate) fn open_console(
|
|||||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||||
ctx.shared.stats_line.lock().unwrap().clear();
|
ctx.shared.stats_line.lock().unwrap().clear();
|
||||||
ctx.shared.browse.store(true, Ordering::SeqCst);
|
ctx.shared.browse.store(true, Ordering::SeqCst);
|
||||||
if let Some(t) = target.clone() {
|
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
*ctx.shared.target.lock().unwrap() = t;
|
|
||||||
}
|
|
||||||
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
||||||
set_status.call(String::new());
|
set_status.call(String::new());
|
||||||
set_screen.call(Screen::Connecting);
|
set_screen.call(Screen::Connecting);
|
||||||
|
|
||||||
let shared = ctx.shared.clone();
|
let shared = ctx.shared.clone();
|
||||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||||
let addr_port = target.as_ref().map(|t| (t.addr.clone(), t.port));
|
let spawned =
|
||||||
let spawned = crate::spawn::spawn_browse(
|
crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| {
|
||||||
addr_port.as_ref().map(|(a, p)| (a.as_str(), *p)),
|
|
||||||
fullscreen,
|
|
||||||
child,
|
|
||||||
move |event| {
|
|
||||||
use crate::spawn::SpawnEvent;
|
use crate::spawn::SpawnEvent;
|
||||||
match event {
|
match event {
|
||||||
SpawnEvent::Ready => {
|
SpawnEvent::Ready => {
|
||||||
@@ -373,8 +357,7 @@ pub(crate) fn open_console(
|
|||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if let Err(e) = spawned {
|
if let Err(e) = spawned {
|
||||||
set_status.call(e);
|
set_status.call(e);
|
||||||
set_screen.call(Screen::Hosts);
|
set_screen.call(Screen::Hosts);
|
||||||
@@ -484,7 +467,6 @@ fn wake_and_connect(
|
|||||||
paired: false,
|
paired: false,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: target.mac.clone(),
|
mac: target.mac.clone(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-184
@@ -1,5 +1,5 @@
|
|||||||
//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect
|
//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect
|
||||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / edit /
|
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
||||||
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
||||||
|
|
||||||
use super::connect::{initiate, initiate_waking, open_console};
|
use super::connect::{initiate, initiate_waking, open_console};
|
||||||
@@ -14,12 +14,10 @@ use windows_reactor::*;
|
|||||||
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
||||||
const MENU_CONNECT: &str = "Connect";
|
const MENU_CONNECT: &str = "Connect";
|
||||||
const MENU_LIBRARY: &str = "Browse library\u{2026}";
|
const MENU_LIBRARY: &str = "Browse library\u{2026}";
|
||||||
|
const MENU_CONSOLE: &str = "Open console UI";
|
||||||
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
||||||
const MENU_WAKE: &str = "Wake host";
|
const MENU_WAKE: &str = "Wake host";
|
||||||
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
|
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||||
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
|
|
||||||
/// that matter.
|
|
||||||
const MENU_EDIT: &str = "Edit\u{2026}";
|
|
||||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||||
|
|
||||||
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
||||||
@@ -189,114 +187,43 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The in-tile host editor (a ContentDialog can't hold text fields): every per-host
|
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
|
||||||
/// property in one place, mirroring the Apple client's add/edit sheet — name, address,
|
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
|
||||||
/// port, Wake-on-LAN MAC, and whether this machine shares its clipboard with the host.
|
/// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
|
||||||
/// Replaced a menu-item-per-property, which buried the useful entries in noise.
|
/// field is uncontrolled, its live value kept in `live` (read at Save). Driving a *controlled* box
|
||||||
///
|
/// from an always-deferred `AsyncSetState` round-trip fights the caret on fast typing and can drop
|
||||||
/// Drafts live in refs owned by the page and are read at Save time; the root `edit` state
|
/// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
|
||||||
/// carries only the target's fingerprint + initial name, so typing doesn't round-trip
|
/// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
|
||||||
/// through a re-render.
|
fn rename_editor(
|
||||||
#[allow(clippy::too_many_arguments)]
|
initial: &str,
|
||||||
fn edit_editor(
|
fp: String,
|
||||||
fp: &str,
|
live: HookRef<String>,
|
||||||
initial_name: &str,
|
set_rename: AsyncSetState<Option<(String, String)>>,
|
||||||
name_draft: HookRef<String>,
|
|
||||||
addr_draft: HookRef<String>,
|
|
||||||
port_draft: HookRef<String>,
|
|
||||||
mac_draft: HookRef<String>,
|
|
||||||
clip_draft: HookRef<bool>,
|
|
||||||
set_edit: AsyncSetState<Option<(String, String)>>,
|
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let commit = {
|
let commit = {
|
||||||
let (fp, se) = (fp.to_string(), set_edit.clone());
|
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
|
||||||
let (name_draft, addr_draft, port_draft, mac_draft, clip_draft) = (
|
|
||||||
name_draft.clone(),
|
|
||||||
addr_draft.clone(),
|
|
||||||
port_draft.clone(),
|
|
||||||
mac_draft.clone(),
|
|
||||||
clip_draft.clone(),
|
|
||||||
);
|
|
||||||
move || {
|
move || {
|
||||||
|
let draft = live.borrow();
|
||||||
|
let name = draft.trim();
|
||||||
|
if !name.is_empty() {
|
||||||
let mut known = KnownHosts::load();
|
let mut known = KnownHosts::load();
|
||||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||||
// Each field falls back to what was stored: a cleared box means "leave it",
|
h.name = name.to_string();
|
||||||
// never "erase it" — except the MAC, which is legitimately clearable.
|
|
||||||
let name = name_draft.borrow().trim().to_string();
|
|
||||||
if !name.is_empty() {
|
|
||||||
h.name = name;
|
|
||||||
}
|
|
||||||
let addr = addr_draft.borrow().trim().to_string();
|
|
||||||
if !addr.is_empty() {
|
|
||||||
h.addr = addr;
|
|
||||||
}
|
|
||||||
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
|
|
||||||
if p != 0 {
|
|
||||||
h.port = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mac = mac_draft.borrow().trim().to_string();
|
|
||||||
h.mac = if mac.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
mac.split(&[',', ' '][..])
|
|
||||||
.filter(|m| !m.trim().is_empty())
|
|
||||||
.map(|m| m.trim().to_string())
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
h.clipboard_sync = *clip_draft.borrow();
|
|
||||||
}
|
}
|
||||||
let _ = known.save();
|
let _ = known.save();
|
||||||
se.call(None);
|
}
|
||||||
|
sr.call(None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
|
let on_changed = {
|
||||||
vstack((
|
let live = live.clone();
|
||||||
text_block(label)
|
move |s: String| live.set(s)
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left),
|
|
||||||
text_box(&value)
|
|
||||||
.placeholder_text(placeholder)
|
|
||||||
.on_text_changed(move |t: String| draft.set(t)),
|
|
||||||
))
|
|
||||||
.spacing(2.0)
|
|
||||||
};
|
};
|
||||||
let (name0, addr0, port0, mac0, clip0) = (
|
|
||||||
name_draft.borrow().clone(),
|
|
||||||
addr_draft.borrow().clone(),
|
|
||||||
port_draft.borrow().clone(),
|
|
||||||
mac_draft.borrow().clone(),
|
|
||||||
*clip_draft.borrow(),
|
|
||||||
);
|
|
||||||
let _ = initial_name;
|
|
||||||
card(
|
card(
|
||||||
vstack((
|
vstack((
|
||||||
field("Name", name0, "e.g. Living Room", name_draft),
|
text_box(initial)
|
||||||
field("Address", addr0, "IP or hostname", addr_draft),
|
.placeholder_text("Host name")
|
||||||
field("Port", port0, "9777", port_draft),
|
.on_text_changed(on_changed),
|
||||||
field(
|
|
||||||
"MAC (Wake-on-LAN)",
|
|
||||||
mac0,
|
|
||||||
"auto-filled when known",
|
|
||||||
mac_draft,
|
|
||||||
),
|
|
||||||
vstack((
|
|
||||||
ToggleSwitch::new(clip0)
|
|
||||||
.header("Share clipboard with this host")
|
|
||||||
.on_content("On")
|
|
||||||
.off_content("Off")
|
|
||||||
.on_toggled(move |v: bool| clip_draft.set(v)),
|
|
||||||
text_block(
|
|
||||||
"Copy on one machine, paste on the other. Off for every host until you \
|
|
||||||
turn it on here; the host must allow it too.",
|
|
||||||
)
|
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.wrap()
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left),
|
|
||||||
))
|
|
||||||
.spacing(4.0),
|
|
||||||
hstack((
|
hstack((
|
||||||
button("Save")
|
button("Save")
|
||||||
.accent()
|
.accent()
|
||||||
@@ -304,7 +231,7 @@ fn edit_editor(
|
|||||||
.on_click(commit),
|
.on_click(commit),
|
||||||
button("Cancel")
|
button("Cancel")
|
||||||
.subtle()
|
.subtle()
|
||||||
.on_click(move || set_edit.call(None)),
|
.on_click(move || set_rename.call(None)),
|
||||||
))
|
))
|
||||||
.spacing(4.0),
|
.spacing(4.0),
|
||||||
))
|
))
|
||||||
@@ -337,41 +264,16 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
let rename = props.rename.clone();
|
let rename = props.rename.clone();
|
||||||
let set_forget = &props.set_forget;
|
let set_forget = &props.set_forget;
|
||||||
let set_rename = &props.set_rename;
|
let set_rename = &props.set_rename;
|
||||||
// The live edit drafts, read at Save time (see `edit_editor`). Root `rename` carries only
|
// The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
|
||||||
// the target's fingerprint + initial name, so typing never round-trips through a
|
// INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
|
||||||
// re-render. Every draft is re-seeded from the STORED host whenever the edit target
|
// TARGET changes (start, cancel, or a switch to another host).
|
||||||
// changes (open, cancel, or switching to another host).
|
let rename_draft = cx.use_ref(String::new());
|
||||||
let name_draft = cx.use_ref(String::new());
|
let rename_seed = cx.use_ref(Option::<String>::None);
|
||||||
let addr_draft = cx.use_ref(String::new());
|
|
||||||
let port_draft = cx.use_ref(String::new());
|
|
||||||
let mac_draft = cx.use_ref(String::new());
|
|
||||||
let clip_draft = cx.use_ref(false);
|
|
||||||
let edit_seed = cx.use_ref(Option::<String>::None);
|
|
||||||
{
|
{
|
||||||
let active = rename.as_ref().map(|(fp, _)| fp.clone());
|
let active = rename.as_ref().map(|(fp, _)| fp.clone());
|
||||||
if *edit_seed.borrow() != active {
|
if *rename_seed.borrow() != active {
|
||||||
let stored = active.as_ref().and_then(|fp| {
|
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
|
||||||
KnownHosts::load()
|
rename_seed.set(active);
|
||||||
.hosts
|
|
||||||
.into_iter()
|
|
||||||
.find(|h| &h.fp_hex == fp)
|
|
||||||
});
|
|
||||||
name_draft.set(stored.as_ref().map(|h| h.name.clone()).unwrap_or_default());
|
|
||||||
addr_draft.set(stored.as_ref().map(|h| h.addr.clone()).unwrap_or_default());
|
|
||||||
port_draft.set(
|
|
||||||
stored
|
|
||||||
.as_ref()
|
|
||||||
.map(|h| h.port.to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
mac_draft.set(
|
|
||||||
stored
|
|
||||||
.as_ref()
|
|
||||||
.map(|h| h.mac.join(", "))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
clip_draft.set(stored.as_ref().is_some_and(|h| h.clipboard_sync));
|
|
||||||
edit_seed.set(active);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let hover = Hover {
|
let hover = Hover {
|
||||||
@@ -412,51 +314,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
.spacing(2.0)
|
.spacing(2.0)
|
||||||
.grid_column(0)
|
.grid_column(0)
|
||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
hstack({
|
hstack((
|
||||||
let mut actions: Vec<Element> = vec![header_btn("Add host", Symbol::Add)
|
header_btn("Add host", Symbol::Add).accent().on_click({
|
||||||
.accent()
|
|
||||||
.on_click({
|
|
||||||
let sa = set_show_add.clone();
|
let sa = set_show_add.clone();
|
||||||
move || sa.call(true)
|
move || sa.call(true)
|
||||||
})
|
}),
|
||||||
.into()];
|
header_btn("Shortcuts", Symbol::Keyboard).on_click({
|
||||||
// The couch UI's front door, beside the other page actions. Absent on ARM64,
|
|
||||||
// where the session binary ships without its Skia console.
|
|
||||||
if CONSOLE_UI_AVAILABLE {
|
|
||||||
actions.push(
|
|
||||||
header_btn("Console UI", Symbol::Play)
|
|
||||||
.tooltip(
|
|
||||||
"The controller-driven couch interface \u{2014} host list, \
|
|
||||||
pairing and libraries, launching streams in the same window.",
|
|
||||||
)
|
|
||||||
.on_click({
|
|
||||||
let (c, ss, st) =
|
|
||||||
(ctx.clone(), set_screen.clone(), set_status.clone());
|
|
||||||
// No target: the console opens its OWN host view rather than
|
|
||||||
// one host's library — the couch counterpart of this page.
|
|
||||||
move || open_console(&c, None, &ss, &st)
|
|
||||||
})
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
actions.push(
|
|
||||||
header_btn("Shortcuts", Symbol::Keyboard)
|
|
||||||
.on_click({
|
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
move || ss.call(Screen::Help)
|
move || ss.call(Screen::Help)
|
||||||
})
|
}),
|
||||||
.into(),
|
header_btn("Settings", Symbol::Setting).on_click({
|
||||||
);
|
|
||||||
actions.push(
|
|
||||||
header_btn("Settings", Symbol::Setting)
|
|
||||||
.on_click({
|
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
move || ss.call(Screen::Settings)
|
move || ss.call(Screen::Settings)
|
||||||
})
|
}),
|
||||||
.into(),
|
))
|
||||||
);
|
|
||||||
actions
|
|
||||||
})
|
|
||||||
.spacing(8.0)
|
.spacing(8.0)
|
||||||
.grid_column(1)
|
.grid_column(1)
|
||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
@@ -476,23 +347,84 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A controller is connected and a paired host is REACHABLE (advertising or probed —
|
||||||
|
// an offline host would just open the console onto an error scene): offer the couch
|
||||||
|
// experience — the console (gamepad) UI on the most recently used such host.
|
||||||
|
if CONSOLE_UI_AVAILABLE && props.pads > 0 {
|
||||||
|
let reachable = |k: &&crate::trust::KnownHost| {
|
||||||
|
hosts
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||||
|
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false)
|
||||||
|
};
|
||||||
|
if let Some(k) = known
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.filter(|h| h.paired)
|
||||||
|
.filter(reachable)
|
||||||
|
.max_by_key(|h| h.last_used.unwrap_or(0))
|
||||||
|
{
|
||||||
|
let target = Target {
|
||||||
|
name: k.name.clone(),
|
||||||
|
addr: k.addr.clone(),
|
||||||
|
port: k.port,
|
||||||
|
fp_hex: Some(k.fp_hex.clone()),
|
||||||
|
pair_optional: false,
|
||||||
|
mac: k.mac.clone(),
|
||||||
|
};
|
||||||
|
let svc = props.svc.clone();
|
||||||
|
body.push(
|
||||||
|
card(
|
||||||
|
grid((
|
||||||
|
vstack((
|
||||||
|
text_block("Controller detected").font_size(14.0).semibold(),
|
||||||
|
text_block(format!(
|
||||||
|
"Browse {}\u{2019}s game library with the gamepad \u{2014} \
|
||||||
|
launches stream in the same window.",
|
||||||
|
k.name
|
||||||
|
))
|
||||||
|
.font_size(12.0)
|
||||||
|
.wrap()
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(2.0)
|
||||||
|
.grid_column(0)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
button("Open console UI")
|
||||||
|
.accent()
|
||||||
|
.icon(Symbol::Play)
|
||||||
|
.on_click(move || {
|
||||||
|
open_console(
|
||||||
|
&svc.ctx,
|
||||||
|
target.clone(),
|
||||||
|
&svc.set_screen,
|
||||||
|
&svc.set_status,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.grid_column(1)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.margin(edges(12.0, 0.0, 0.0, 0.0)),
|
||||||
|
))
|
||||||
|
.columns([GridLength::Star(1.0), GridLength::Auto]),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
|
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
|
||||||
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
||||||
if !known.hosts.is_empty() {
|
if !known.hosts.is_empty() {
|
||||||
body.push(section("SAVED HOSTS"));
|
body.push(section("SAVED HOSTS"));
|
||||||
let mut tiles: Vec<Element> = Vec::new();
|
let mut tiles: Vec<Element> = Vec::new();
|
||||||
for k in &known.hosts {
|
for k in &known.hosts {
|
||||||
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
|
||||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||||
let (fp, initial) = rename.clone().unwrap();
|
let (fp, initial) = rename.clone().unwrap();
|
||||||
tiles.push(edit_editor(
|
tiles.push(rename_editor(
|
||||||
&fp,
|
|
||||||
&initial,
|
&initial,
|
||||||
name_draft.clone(),
|
fp,
|
||||||
addr_draft.clone(),
|
rename_draft.clone(),
|
||||||
port_draft.clone(),
|
|
||||||
mac_draft.clone(),
|
|
||||||
clip_draft.clone(),
|
|
||||||
set_rename.clone(),
|
set_rename.clone(),
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
@@ -539,12 +471,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
if library_enabled && k.paired {
|
if library_enabled && k.paired {
|
||||||
items.push(menu_item(MENU_LIBRARY));
|
items.push(menu_item(MENU_LIBRARY));
|
||||||
}
|
}
|
||||||
|
if CONSOLE_UI_AVAILABLE && k.paired {
|
||||||
|
items.push(menu_item(MENU_CONSOLE));
|
||||||
|
}
|
||||||
items.push(menu_item(MENU_SPEED));
|
items.push(menu_item(MENU_SPEED));
|
||||||
// Offer an explicit wake only when the host is offline and we have a MAC.
|
// Offer an explicit wake only when the host is offline and we have a MAC.
|
||||||
if can_wake {
|
if can_wake {
|
||||||
items.push(menu_item(MENU_WAKE));
|
items.push(menu_item(MENU_WAKE));
|
||||||
}
|
}
|
||||||
items.push(menu_item(MENU_EDIT));
|
items.push(menu_item(MENU_RENAME));
|
||||||
items.push(menu_separator());
|
items.push(menu_separator());
|
||||||
items.push(menu_item(MENU_FORGET));
|
items.push(menu_item(MENU_FORGET));
|
||||||
items
|
items
|
||||||
@@ -558,6 +493,9 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
super::library::start_fetch(&svc.ctx, &svc.set_library);
|
super::library::start_fetch(&svc.ctx, &svc.set_library);
|
||||||
svc.set_screen.call(Screen::Library);
|
svc.set_screen.call(Screen::Library);
|
||||||
}
|
}
|
||||||
|
MENU_CONSOLE => {
|
||||||
|
open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||||
|
}
|
||||||
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
||||||
MENU_SPEED => {
|
MENU_SPEED => {
|
||||||
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
@@ -569,7 +507,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
svc.set_speed.call(SpeedState::Running);
|
svc.set_speed.call(SpeedState::Running);
|
||||||
svc.set_screen.call(Screen::SpeedTest);
|
svc.set_screen.call(Screen::SpeedTest);
|
||||||
}
|
}
|
||||||
MENU_EDIT => sr.call(Some((fp.clone(), name.clone()))),
|
MENU_RENAME => sr.call(Some((fp.clone(), name.clone()))),
|
||||||
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
|
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -241,8 +241,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
// reactor backend, so only a root `AsyncSetState` reliably re-renders the page.
|
// reactor backend, so only a root `AsyncSetState` reliably re-renders the page.
|
||||||
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
||||||
// Which Settings section the NavigationView shows (persists across visits this run).
|
// Which Settings section the NavigationView shows (persists across visits this run).
|
||||||
// Opens on General — the first sidebar item, matching the Apple client's landing category.
|
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
||||||
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
|
|
||||||
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
||||||
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
||||||
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
paired: true,
|
paired: true,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: target3.mac.clone(),
|
mac: target3.mac.clone(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||||
|
|||||||
+120
-333
@@ -1,15 +1,5 @@
|
|||||||
//! The settings screen. Every control writes straight back to the persisted [`Settings`]
|
//! The settings screen. Every control writes straight back to the persisted [`Settings`]
|
||||||
//! (there is no Apply step), via the small [`setting_combo`]/[`setting_toggle`] builders.
|
//! (there is no Apply step), via the small [`setting_combo`]/[`setting_toggle`] builders.
|
||||||
//!
|
|
||||||
//! **Structure mirrors the Apple client's 2026-07 settings revamp** (its
|
|
||||||
//! `SettingsCategory` + `SettingsView+Sections.swift`), so the two desktop clients read the
|
|
||||||
//! same way: General = session/app behavior, Display = everything about the picture,
|
|
||||||
//! Input = touch/keyboard/mouse, Audio, Controllers, About. Each field carries its
|
|
||||||
//! explanation DIRECTLY under it ([`described`]) rather than only on hover — the same move
|
|
||||||
//! Apple made, for the same reason (guidance nobody hovers for is guidance nobody reads).
|
|
||||||
//! Wording is shared verbatim wherever the setting means the same thing on both platforms;
|
|
||||||
//! where the BEHAVIOR differs the text is deliberately Windows-specific (the forwarded-
|
|
||||||
//! controller picker especially: Apple forwards one pad, this client forwards them all).
|
|
||||||
|
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{AppCtx, Screen};
|
use super::{AppCtx, Screen};
|
||||||
@@ -48,8 +38,7 @@ fn render_scale_label(scale: f64) -> String {
|
|||||||
// Automatic — which is exactly how the session's decoder chain reads that value.
|
// Automatic — which is exactly how the session's decoder chain reads that value.
|
||||||
const DECODERS: &[(&str, &str)] = &[
|
const DECODERS: &[(&str, &str)] = &[
|
||||||
("auto", "Automatic (GPU, fall back to CPU)"),
|
("auto", "Automatic (GPU, fall back to CPU)"),
|
||||||
("vulkan", "Hardware (Vulkan Video)"),
|
("vulkan", "Hardware (GPU / Vulkan Video)"),
|
||||||
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
|
|
||||||
("software", "Software (CPU)"),
|
("software", "Software (CPU)"),
|
||||||
];
|
];
|
||||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||||
@@ -90,13 +79,6 @@ const TOUCH_MODES: &[(&str, &str)] = &[
|
|||||||
("pointer", "Direct pointer"),
|
("pointer", "Direct pointer"),
|
||||||
("touch", "Touch passthrough"),
|
("touch", "Touch passthrough"),
|
||||||
];
|
];
|
||||||
/// Physical-mouse presets: `(stored value, display label)` — capture (pointer lock,
|
|
||||||
/// relative, for games) vs desktop (uncaptured absolute pointer, for remote desktop
|
|
||||||
/// work). Ctrl+Alt+Shift+M flips the model live in-stream.
|
|
||||||
const MOUSE_MODES: &[(&str, &str)] = &[
|
|
||||||
("capture", "Capture (games)"),
|
|
||||||
("desktop", "Desktop (absolute)"),
|
|
||||||
];
|
|
||||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||||
const COMPOSITORS: &[(&str, &str)] = &[
|
const COMPOSITORS: &[(&str, &str)] = &[
|
||||||
@@ -155,63 +137,11 @@ fn setting_toggle(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One field: the control with its explanation directly underneath (Apple's `described`).
|
/// A settings card: just the controls. No heading (the section title is the NavigationView
|
||||||
///
|
/// header) and no description paragraph — per-control guidance is a `.tooltip(...)` on the
|
||||||
/// The caption goes BELOW the control on purpose. An earlier revision put guidance only in
|
/// control itself (a paragraph in the card reads as the first control's label).
|
||||||
/// hover tooltips because a paragraph *above* a control reads as that control's label — true,
|
fn settings_card(controls: Vec<Element>) -> Element {
|
||||||
/// but a caption under it reads as a caption, which is how every Windows Settings page and
|
card(vstack(controls).spacing(10.0)).into()
|
||||||
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
|
|
||||||
/// full-width caption runs into the control column and the whole cell reads as one block.
|
|
||||||
fn described(control: impl Into<Element>, caption: &str) -> Element {
|
|
||||||
vstack((
|
|
||||||
control.into(),
|
|
||||||
text_block(caption)
|
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.wrap()
|
|
||||||
.max_width(420.0)
|
|
||||||
// Stretch (the TextBlock default) CENTRES a MaxWidth-capped block in the leftover
|
|
||||||
// width — the caption must be pinned left or it drifts away from its control.
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left),
|
|
||||||
))
|
|
||||||
.spacing(5.0)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A settings sub-section heading. Deliberately NOT the shared [`section`] helper: that one
|
|
||||||
/// carries a 2px left inset (fine over the hosts/licenses lists it was written for), which
|
|
||||||
/// here left every heading hanging one nudge right of the card edge below it. Flush left, so
|
|
||||||
/// heading and card share one line.
|
|
||||||
fn group_heading(label: &str) -> Element {
|
|
||||||
text_block(label)
|
|
||||||
.font_size(12.0)
|
|
||||||
.semibold()
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left)
|
|
||||||
.margin(edges(0.0, 14.0, 0.0, 2.0))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One settings group: an optional sub-section label, a card of fields, and an optional
|
|
||||||
/// form-level note under it (Apple's Section header/footer). Groups stack down the page.
|
|
||||||
fn group(header: Option<&str>, fields: Vec<Element>, footer: Option<&str>) -> Vec<Element> {
|
|
||||||
let mut out = Vec::with_capacity(3);
|
|
||||||
if let Some(h) = header {
|
|
||||||
out.push(group_heading(h));
|
|
||||||
}
|
|
||||||
out.push(card(vstack(fields).spacing(14.0)).into());
|
|
||||||
if let Some(f) = footer {
|
|
||||||
out.push(
|
|
||||||
text_block(f)
|
|
||||||
.font_size(12.0)
|
|
||||||
.foreground(ThemeRef::SecondaryText)
|
|
||||||
.wrap()
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left)
|
|
||||||
.margin(edges(0.0, 6.0, 0.0, 0.0))
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
|
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
|
||||||
@@ -256,7 +186,12 @@ pub(crate) fn settings_page(
|
|||||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
||||||
s.match_window = i == 1;
|
s.match_window = i == 1;
|
||||||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
|
||||||
|
resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \
|
||||||
|
follows the stream window, including mid-stream resizes.",
|
||||||
|
);
|
||||||
let (hz_names, hz_i) = {
|
let (hz_names, hz_i) = {
|
||||||
let names: Vec<String> = REFRESH
|
let names: Vec<String> = REFRESH
|
||||||
.iter()
|
.iter()
|
||||||
@@ -273,7 +208,8 @@ pub(crate) fn settings_page(
|
|||||||
};
|
};
|
||||||
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
|
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
|
||||||
s.refresh_hz = REFRESH[i];
|
s.refresh_hz = REFRESH[i];
|
||||||
});
|
})
|
||||||
|
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
|
||||||
let (scale_names, scale_i) = {
|
let (scale_names, scale_i) = {
|
||||||
let names: Vec<String> = RENDER_SCALES
|
let names: Vec<String> = RENDER_SCALES
|
||||||
.iter()
|
.iter()
|
||||||
@@ -287,26 +223,36 @@ pub(crate) fn settings_page(
|
|||||||
};
|
};
|
||||||
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
|
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
|
||||||
s.render_scale = RENDER_SCALES[i];
|
s.render_scale = RENDER_SCALES[i];
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Supersample for sharpness (above 1\u{00D7}, more bandwidth and decode) or render below \
|
||||||
|
native (below 1\u{00D7}) for a lighter host \u{2014} this device resamples to the window.",
|
||||||
|
);
|
||||||
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||||
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||||
s.compositor = COMPOSITORS[i].0.to_string();
|
s.compositor = COMPOSITORS[i].0.to_string();
|
||||||
});
|
})
|
||||||
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
|
.tooltip(
|
||||||
s.auto_wake = on
|
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
|
||||||
});
|
choice is unavailable.",
|
||||||
|
);
|
||||||
let fullscreen_toggle = setting_toggle(
|
let fullscreen_toggle = setting_toggle(
|
||||||
ctx,
|
ctx,
|
||||||
"Start streams fullscreen",
|
"Start streams fullscreen",
|
||||||
s.fullscreen_on_stream,
|
s.fullscreen_on_stream,
|
||||||
|s, on| s.fullscreen_on_stream = on,
|
|s, on| s.fullscreen_on_stream = on,
|
||||||
);
|
)
|
||||||
|
.tooltip("The stream window opens fullscreen; F11 or Alt+Enter switches back live.");
|
||||||
|
|
||||||
// --- Video -----------------------------------------------------------------------------
|
// --- Video -----------------------------------------------------------------------------
|
||||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
||||||
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
|
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
|
||||||
s.decoder = DECODERS[i].0.to_string();
|
s.decoder = DECODERS[i].0.to_string();
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Hardware decode (Vulkan Video) is far lighter than software \u{2014} keep it on \
|
||||||
|
Automatic unless debugging.",
|
||||||
|
);
|
||||||
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
||||||
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
||||||
let gpus = crate::gpu::adapter_names();
|
let gpus = crate::gpu::adapter_names();
|
||||||
@@ -325,11 +271,18 @@ pub(crate) fn settings_page(
|
|||||||
gpus[i - 1].clone()
|
gpus[i - 1].clone()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Which adapter decodes and presents the stream. Applies to the next stream; \
|
||||||
|
Automatic uses the GPU driving this window's display.",
|
||||||
|
)
|
||||||
});
|
});
|
||||||
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
||||||
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
||||||
s.codec = CODECS[i].0.to_string();
|
s.codec = CODECS[i].0.to_string();
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"A soft preference \u{2014} the host falls back to the best codec both sides support. PyroWave is the low-latency wavelet codec for a WIRED link: it trades bitrate (hundreds of Mb/s) for near-zero decode time, so it needs gigabit Ethernet.",
|
||||||
|
);
|
||||||
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
||||||
// round-trips exactly.
|
// round-trips exactly.
|
||||||
let bitrate_box = {
|
let bitrate_box = {
|
||||||
@@ -342,10 +295,18 @@ pub(crate) fn settings_page(
|
|||||||
s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32;
|
s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32;
|
||||||
s.save();
|
s.save();
|
||||||
})
|
})
|
||||||
|
.tooltip(
|
||||||
|
"0 lets the host decide. Run a per-host speed test from the host list for a \
|
||||||
|
recommendation.",
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
|
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
|
||||||
s.hdr_enabled = on
|
s.hdr_enabled = on
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Advertise 10-bit HDR10 so the host upgrades HDR content. Needs a display in HDR mode; \
|
||||||
|
SDR content is unaffected.",
|
||||||
|
);
|
||||||
|
|
||||||
// --- Input -----------------------------------------------------------------------------
|
// --- Input -----------------------------------------------------------------------------
|
||||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||||
@@ -390,48 +351,60 @@ pub(crate) fn settings_page(
|
|||||||
s.forward_pad = key.unwrap_or_default();
|
s.forward_pad = key.unwrap_or_default();
|
||||||
s.save();
|
s.save();
|
||||||
})
|
})
|
||||||
|
.tooltip(
|
||||||
|
"Every connected controller is forwarded, each as its own player. Pick one \
|
||||||
|
to force single-player \u{2014} only it reaches the host.",
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||||
});
|
});
|
||||||
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
|
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||||
s.gamepad = GAMEPADS[i].0.to_string();
|
s.gamepad = GAMEPADS[i].0.to_string();
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"The virtual pad the host creates. \u{201C}Automatic\u{201D} matches your physical \
|
||||||
|
controller.",
|
||||||
|
);
|
||||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||||
});
|
})
|
||||||
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
|
.tooltip(
|
||||||
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
|
"How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
|
||||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
pointer jumps to your finger, Touch passthrough sends real touches.",
|
||||||
});
|
);
|
||||||
let invert_scroll_toggle =
|
|
||||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
|
||||||
s.invert_scroll = on
|
|
||||||
});
|
|
||||||
let shortcuts_toggle = setting_toggle(
|
let shortcuts_toggle = setting_toggle(
|
||||||
ctx,
|
ctx,
|
||||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||||
s.inhibit_shortcuts,
|
s.inhibit_shortcuts,
|
||||||
|s, on| s.inhibit_shortcuts = on,
|
|s, on| s.inhibit_shortcuts = on,
|
||||||
);
|
)
|
||||||
|
.tooltip("Off: Alt+Tab, Win & co. act on this machine while the stream input is captured.");
|
||||||
|
|
||||||
// --- Audio -----------------------------------------------------------------------------
|
// --- Audio -----------------------------------------------------------------------------
|
||||||
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
|
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
|
||||||
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
|
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
|
||||||
s.audio_channels = AUDIO_CHANNELS[i].0;
|
s.audio_channels = AUDIO_CHANNELS[i].0;
|
||||||
});
|
})
|
||||||
|
.tooltip("The host downmixes if its output has fewer channels.");
|
||||||
let mic_toggle = setting_toggle(
|
let mic_toggle = setting_toggle(
|
||||||
ctx,
|
ctx,
|
||||||
"Stream microphone to the host",
|
"Stream microphone to the host",
|
||||||
s.mic_enabled,
|
s.mic_enabled,
|
||||||
|s, on| s.mic_enabled = on,
|
|s, on| s.mic_enabled = on,
|
||||||
);
|
)
|
||||||
|
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
||||||
|
|
||||||
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||||
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||||
});
|
})
|
||||||
|
.tooltip(
|
||||||
|
"How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
|
||||||
|
in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
|
||||||
|
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
|
||||||
|
);
|
||||||
|
|
||||||
let licenses_button = {
|
let licenses_button = {
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
@@ -442,6 +415,10 @@ pub(crate) fn settings_page(
|
|||||||
"Show game library (experimental)",
|
"Show game library (experimental)",
|
||||||
s.library_enabled,
|
s.library_enabled,
|
||||||
|s, on| s.library_enabled = on,
|
|s, on| s.library_enabled = on,
|
||||||
|
)
|
||||||
|
.tooltip(
|
||||||
|
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \
|
||||||
|
launches in the stream. Mirrors the Apple client's toggle.",
|
||||||
);
|
);
|
||||||
// App identity + version at the top of the About card (the WinUI Settings convention; the About
|
// App identity + version at the top of the About card (the WinUI Settings convention; the About
|
||||||
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
||||||
@@ -454,234 +431,70 @@ pub(crate) fn settings_page(
|
|||||||
))
|
))
|
||||||
.spacing(2.0);
|
.spacing(2.0);
|
||||||
|
|
||||||
// The selected section's content, grouped exactly like the Apple client's categories
|
// The selected section's content — per-control guidance lives on hover tooltips, so the
|
||||||
// (SettingsCategory + SettingsView+Sections.swift). Each field's explanation sits under
|
// card is just the controls.
|
||||||
// it; the only form-level notes are the "applies from the next session" footers, matching
|
let (title, card): (&str, Element) = match section {
|
||||||
// Apple's decision to keep exactly one of those per affected category.
|
"video" => (
|
||||||
let (title, groups): (&str, Vec<Element>) = match section {
|
"Video",
|
||||||
"display" => {
|
settings_card({
|
||||||
let mut out = group(
|
let mut controls: Vec<Element> = vec![decoder_combo.into()];
|
||||||
Some("Resolution"),
|
|
||||||
vec![
|
|
||||||
described(
|
|
||||||
res_combo,
|
|
||||||
"The host drives a real virtual output at exactly this size \u{2014} true \
|
|
||||||
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
|
|
||||||
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
|
|
||||||
(1:1) through every resize.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
hz_combo,
|
|
||||||
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
|
||||||
connect.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
out.extend(group(
|
|
||||||
Some("Quality"),
|
|
||||||
vec![
|
|
||||||
described(
|
|
||||||
scale_combo,
|
|
||||||
"Above native supersamples for sharpness; below renders lighter on the \
|
|
||||||
host and the link. This device resamples the result to the window.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
bitrate_box,
|
|
||||||
"0 lets the host decide (its default, clamped to what it supports). A \
|
|
||||||
host card\u{2019}s context menu has a network speed test.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
codec_combo,
|
|
||||||
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
|
|
||||||
PyroWave is the low-latency wavelet codec for a WIRED link: it trades \
|
|
||||||
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
|
|
||||||
gigabit Ethernet.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
hdr_toggle,
|
|
||||||
"HDR10, when the host has HDR content and this display supports it. \
|
|
||||||
HEVC only; otherwise the stream stays SDR.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
out.extend(group(
|
|
||||||
Some("Decoding"),
|
|
||||||
{
|
|
||||||
let mut fields = vec![described(
|
|
||||||
decoder_combo,
|
|
||||||
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
|
|
||||||
11 on Intel, Vulkan Video on NVIDIA and AMD \u{2014} and falls back to \
|
|
||||||
the CPU. Change it only when debugging.",
|
|
||||||
)];
|
|
||||||
if let Some(c) = gpu_combo {
|
if let Some(c) = gpu_combo {
|
||||||
fields.push(described(
|
controls.push(c.into());
|
||||||
c,
|
|
||||||
"Which adapter decodes and presents the stream. Automatic uses the \
|
|
||||||
GPU driving this window\u{2019}s display.",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
fields
|
controls.extend([
|
||||||
},
|
codec_combo.into(),
|
||||||
None,
|
bitrate_box.into(),
|
||||||
));
|
hdr_toggle.into(),
|
||||||
out.extend(group(
|
hud_combo.into(),
|
||||||
Some("Host output"),
|
]);
|
||||||
vec![described(
|
controls
|
||||||
comp_combo,
|
}),
|
||||||
"The backend the host uses for its virtual output (Linux hosts only). A \
|
|
||||||
specific choice falls back to auto-detection when that backend \
|
|
||||||
isn\u{2019}t available.",
|
|
||||||
)],
|
|
||||||
// The one form-level note, exactly as on Apple.
|
|
||||||
Some("Display changes apply from the next session."),
|
|
||||||
));
|
|
||||||
("Display", out)
|
|
||||||
}
|
|
||||||
"input" => {
|
|
||||||
let mut out = group(
|
|
||||||
Some("Touch & pointer"),
|
|
||||||
vec![described(
|
|
||||||
touch_combo,
|
|
||||||
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
|
|
||||||
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
|
|
||||||
you touch, Touch passthrough sends real multi-touch through.",
|
|
||||||
)],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
out.extend(group(
|
|
||||||
Some("Keyboard & mouse"),
|
|
||||||
vec![
|
|
||||||
described(
|
|
||||||
mouse_combo,
|
|
||||||
"Capture locks the pointer to the stream and sends relative motion — \
|
|
||||||
best for games. Desktop leaves the pointer free to enter and leave \
|
|
||||||
the stream and sends absolute positions — best for remote desktop \
|
|
||||||
work. Ctrl+Alt+Shift+M switches live.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
shortcuts_toggle,
|
|
||||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
|
||||||
has input captured. Off, they act on this machine instead.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
invert_scroll_toggle,
|
|
||||||
"Reverses the wheel and trackpad scroll direction sent to the host.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
("Input", out)
|
|
||||||
}
|
|
||||||
"controllers" => (
|
|
||||||
"Controllers",
|
|
||||||
group(
|
|
||||||
None,
|
|
||||||
vec![
|
|
||||||
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
|
|
||||||
// forwards every controller as its own player. Same picker, different rule.
|
|
||||||
described(
|
|
||||||
forward_combo,
|
|
||||||
"Every connected controller is forwarded, each as its own player. Pick \
|
|
||||||
one to force single-player \u{2014} only it reaches the host.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
pad_combo,
|
|
||||||
"The virtual pad created on the host. Automatic matches your controller \
|
|
||||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
|
||||||
motion.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
Some("Applies from the next session."),
|
|
||||||
),
|
),
|
||||||
|
"input" => (
|
||||||
|
"Input",
|
||||||
|
settings_card(vec![
|
||||||
|
forward_combo.into(),
|
||||||
|
pad_combo.into(),
|
||||||
|
touch_combo.into(),
|
||||||
|
shortcuts_toggle.into(),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
"audio" => (
|
"audio" => (
|
||||||
"Audio",
|
"Audio",
|
||||||
group(
|
settings_card(vec![channels_combo.into(), mic_toggle.into()]),
|
||||||
None,
|
|
||||||
vec![
|
|
||||||
described(
|
|
||||||
channels_combo,
|
|
||||||
"The speaker layout requested from the host. It downmixes if its own \
|
|
||||||
output has fewer channels.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
mic_toggle,
|
|
||||||
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
Some("Applies from the next session."),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
"about" => (
|
"about" => (
|
||||||
"About",
|
"About",
|
||||||
group(
|
settings_card(vec![
|
||||||
None,
|
about_identity.into(),
|
||||||
vec![about_identity.into(), licenses_button.into()],
|
library_toggle.into(),
|
||||||
None,
|
licenses_button.into(),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
|
_ => (
|
||||||
|
"Display",
|
||||||
|
settings_card(vec![
|
||||||
|
res_combo.into(),
|
||||||
|
hz_combo.into(),
|
||||||
|
scale_combo.into(),
|
||||||
|
fullscreen_toggle.into(),
|
||||||
|
comp_combo.into(),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
// "general" and anything unrecognized.
|
|
||||||
_ => {
|
|
||||||
let mut out = group(
|
|
||||||
Some("Session"),
|
|
||||||
vec![
|
|
||||||
described(
|
|
||||||
fullscreen_toggle,
|
|
||||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
|
||||||
live.",
|
|
||||||
),
|
|
||||||
described(
|
|
||||||
auto_wake_toggle,
|
|
||||||
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
|
|
||||||
waits for it to boot. Turn off if hosts behind a VPN look offline when \
|
|
||||||
they aren\u{2019}t.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
out.extend(group(
|
|
||||||
Some("Statistics"),
|
|
||||||
vec![described(
|
|
||||||
hud_combo,
|
|
||||||
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
|
|
||||||
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
|
|
||||||
tiers any time.",
|
|
||||||
)],
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
out.extend(group(
|
|
||||||
Some("Library"),
|
|
||||||
vec![described(
|
|
||||||
library_toggle,
|
|
||||||
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
|
|
||||||
their Steam and custom games and launch one directly. No extra host setup.",
|
|
||||||
)],
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
("General", out)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The stock WinUI sidebar (Windows-Settings pattern): pane on the left, the section's card
|
// The stock WinUI sidebar (Windows-Settings pattern): pane on the left, the section's card
|
||||||
// as content, the NavigationView's own back arrow returning to the host list. Auto display
|
// as content, the NavigationView's own back arrow returning to the host list. Auto display
|
||||||
// mode collapses the pane on a narrow window, exactly like Windows Settings.
|
// mode collapses the pane on a narrow window, exactly like Windows Settings.
|
||||||
// Category order mirrors the Apple client's sidebar exactly.
|
|
||||||
let items = vec![
|
let items = vec![
|
||||||
NavViewItem::new("General")
|
|
||||||
.tag("general")
|
|
||||||
.icon(Symbol::Setting),
|
|
||||||
NavViewItem::new("Display")
|
NavViewItem::new("Display")
|
||||||
.tag("display")
|
.tag("display")
|
||||||
.icon(Symbol::FullScreen),
|
.icon(Symbol::FullScreen),
|
||||||
|
NavViewItem::new("Video").tag("video").icon(Symbol::Video),
|
||||||
NavViewItem::new("Input")
|
NavViewItem::new("Input")
|
||||||
.tag("input")
|
.tag("input")
|
||||||
.icon(Symbol::Keyboard),
|
.icon(Symbol::Keyboard),
|
||||||
NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume),
|
NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume),
|
||||||
NavViewItem::new("Controllers")
|
|
||||||
.tag("controllers")
|
|
||||||
.icon(Symbol::Play),
|
|
||||||
NavViewItem::new("About").tag("about").icon(Symbol::Help),
|
NavViewItem::new("About").tag("about").icon(Symbol::Help),
|
||||||
];
|
];
|
||||||
// The card is KEYED by section so switching panes REMOUNTS it instead of diffing one
|
// The card is KEYED by section so switching panes REMOUNTS it instead of diffing one
|
||||||
@@ -692,38 +505,12 @@ pub(crate) fn settings_page(
|
|||||||
//
|
//
|
||||||
// The content column (not the NavigationView — the sidebar must stay put) carries the
|
// The content column (not the NavigationView — the sidebar must stay put) carries the
|
||||||
// section-switch entrance: fade + slide-up from the root-driven tween.
|
// section-switch entrance: fade + slide-up from the root-driven tween.
|
||||||
// No max-width cap here (unlike the other pages): the NavigationView already spends the
|
let content = page_wide(vec![card.with_key(section)])
|
||||||
// left third on its pane, so a 640-wide column left the cards as a narrow ribbon.
|
|
||||||
// The category title is rendered HERE, not via NavigationView's Header: that header's
|
|
||||||
// left inset belongs to WinUI's own template (a string prop is all we can set), so it
|
|
||||||
// sat noticeably right of the cards under it. In the content column it shares the cards'
|
|
||||||
// left edge by construction.
|
|
||||||
let titled: Vec<Element> = std::iter::once(
|
|
||||||
text_block(title)
|
|
||||||
.font_size(28.0)
|
|
||||||
.semibold()
|
|
||||||
.horizontal_alignment(HorizontalAlignment::Left)
|
|
||||||
.margin(edges(0.0, 0.0, 0.0, 6.0))
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
.chain(groups)
|
|
||||||
.collect();
|
|
||||||
// The keyed column MUST sit inside a panel's child list, not directly under the
|
|
||||||
// scroll_view: `ScrollView::children()` is `Children::PositionalSingle`, which
|
|
||||||
// reconciles its one child POSITIONALLY and ignores keys outright. Keyed straight onto
|
|
||||||
// the scroll_view's child, the section switch silently diffs one section's controls into
|
|
||||||
// another's — which re-sets each reused ComboBox's items (clearing WinUI's selection)
|
|
||||||
// but skips `selected_index` whenever the two sections' values compare equal, so the
|
|
||||||
// combos render blank until touched. A panel (vstack) takes the keyed path, so the key
|
|
||||||
// remounts the whole column and every prop is applied fresh.
|
|
||||||
let content = scroll_view(
|
|
||||||
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
|
|
||||||
.margin(edges(24.0, 20.0, 28.0, 40.0)),
|
|
||||||
)
|
|
||||||
.opacity(progress)
|
.opacity(progress)
|
||||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||||
NavigationView::new(items, content)
|
NavigationView::new(items, content)
|
||||||
.pane_title("Settings")
|
.pane_title("Settings")
|
||||||
|
.header(title)
|
||||||
.selected_tag(section)
|
.selected_tag(section)
|
||||||
.on_selection_changed({
|
.on_selection_changed({
|
||||||
let ss = set_section.clone();
|
let ss = set_section.clone();
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
//! `punktfunk-console.exe` — the couch/HTPC entry point.
|
|
||||||
//!
|
|
||||||
//! Exists because an MSIX `<Application>` cannot pass ARGUMENTS to a full-trust executable:
|
|
||||||
//! a second Start-menu tile therefore cannot simply be "punktfunk-client.exe --console", it
|
|
||||||
//! needs its own executable. This is that executable, and it is deliberately nothing but a
|
|
||||||
//! hand-off — it starts the session binary's `--browse` mode (the complete controller-driven
|
|
||||||
//! client: host list, discovery, PIN pairing, settings, Wake-on-LAN, library) fullscreen and
|
|
||||||
//! mirrors its exit code, so whatever supervises this process sees the real result.
|
|
||||||
//!
|
|
||||||
//! `--windowed` keeps it in a window; everything else is the session binary's own business.
|
|
||||||
|
|
||||||
// No console window: this is launched from a Start-menu tile / shortcut, and a flashing
|
|
||||||
// console behind the couch UI looks like a crash.
|
|
||||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn main() {
|
|
||||||
// The session binary ships beside us in the package; fall back to PATH for a dev run.
|
|
||||||
let session = std::env::current_exe()
|
|
||||||
.ok()
|
|
||||||
.map(|e| e.with_file_name("punktfunk-session.exe"))
|
|
||||||
.filter(|p| p.exists())
|
|
||||||
.unwrap_or_else(|| "punktfunk-session".into());
|
|
||||||
|
|
||||||
let mut cmd = std::process::Command::new(session);
|
|
||||||
cmd.arg("--browse");
|
|
||||||
if !std::env::args().any(|a| a == "--windowed") {
|
|
||||||
cmd.arg("--fullscreen");
|
|
||||||
}
|
|
||||||
match cmd.status() {
|
|
||||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
|
||||||
Err(_) => std::process::exit(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The workspace builds on Linux/macOS too; there is nothing to launch there.
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn main() {}
|
|
||||||
@@ -76,27 +76,6 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `--console`: go straight to the gamepad/couch UI, skipping the WinUI shell entirely —
|
|
||||||
// the HTPC entry point (a Start-menu tile, a Steam shortcut, a startup item). The session
|
|
||||||
// binary's bare `--browse` IS a complete standalone client: host list, discovery, PIN
|
|
||||||
// pairing, settings and Wake-on-LAN, all controller-driven. We just exec it and mirror
|
|
||||||
// its exit code, so anything supervising this process sees the real result.
|
|
||||||
if flag("--console") {
|
|
||||||
let mut cmd = std::process::Command::new(spawn::session_binary());
|
|
||||||
cmd.arg("--browse");
|
|
||||||
// A couch UI is fullscreen unless explicitly told otherwise.
|
|
||||||
if !flag("--windowed") {
|
|
||||||
cmd.arg("--fullscreen");
|
|
||||||
}
|
|
||||||
match cmd.status() {
|
|
||||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("could not start the console UI: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
|
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
|
||||||
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
|
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
|
||||||
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).
|
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ pub fn run_speed_probe(
|
|||||||
decodable_codecs(),
|
decodable_codecs(),
|
||||||
0, // preferred_codec: no preference
|
0, // preferred_codec: no preference
|
||||||
None, // display_hdr: probe connect, nothing presents
|
None, // display_hdr: probe connect, nothing presents
|
||||||
0, // client_caps: probe connect, nothing renders a cursor
|
|
||||||
None, // launch: no game
|
None, // launch: no game
|
||||||
pin,
|
pin,
|
||||||
Some(identity),
|
Some(identity),
|
||||||
|
|||||||
@@ -126,26 +126,21 @@ pub(crate) fn spawn_session(
|
|||||||
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
|
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
|
||||||
/// window presents, `error` on a failed start, EOF on quit.
|
/// window presents, `error` on a failed start, EOF on quit.
|
||||||
pub(crate) fn spawn_browse(
|
pub(crate) fn spawn_browse(
|
||||||
target: Option<(&str, u16)>,
|
addr: &str,
|
||||||
|
port: u16,
|
||||||
fullscreen: bool,
|
fullscreen: bool,
|
||||||
slot: SessionChild,
|
slot: SessionChild,
|
||||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut cmd = Command::new(session_binary());
|
let mut cmd = Command::new(session_binary());
|
||||||
cmd.arg("--browse");
|
cmd.arg("--browse")
|
||||||
// A target opens straight into that host's library; bare `--browse` opens the console's
|
.arg(format!("{addr}:{port}"))
|
||||||
// OWN host view (discovery, pairing, settings, Wake-on-LAN) — the couch equivalent of
|
.arg("--json-status");
|
||||||
// the shell's hosts page.
|
|
||||||
if let Some((addr, port)) = target {
|
|
||||||
cmd.arg(format!("{addr}:{port}"));
|
|
||||||
}
|
|
||||||
cmd.arg("--json-status");
|
|
||||||
if fullscreen {
|
if fullscreen {
|
||||||
cmd.arg("--fullscreen");
|
cmd.arg("--fullscreen");
|
||||||
}
|
}
|
||||||
add_window_pos(&mut cmd);
|
add_window_pos(&mut cmd);
|
||||||
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}"));
|
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||||
spawn_with(cmd, &label, slot, on_event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ pipewire = "0.9"
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
|
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
|
||||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||||
# XFixes cursor source for gamescope (remote-desktop-sweep Phase C): gamescope paints no
|
|
||||||
# `SPA_META_Cursor`, so the pointer never reaches the PipeWire node. We read the shape/hotspot/
|
|
||||||
# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot.
|
|
||||||
# `RustConnection` is the pure-Rust default (no libxcb link → no new C dependency on the host); the
|
|
||||||
# `xfixes` feature (auto-pulls `render` + `shape`) is what exposes GetCursorImage/SelectCursorInput.
|
|
||||||
x11rb = { version = "0.13", default-features = false, features = ["xfixes"] }
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
|
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
|
||||||
@@ -49,9 +43,7 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_Graphics_Direct3D_Fxc",
|
"Win32_Graphics_Direct3D_Fxc",
|
||||||
"Win32_Graphics_Dxgi",
|
"Win32_Graphics_Dxgi",
|
||||||
"Win32_Graphics_Dxgi_Common",
|
"Win32_Graphics_Dxgi_Common",
|
||||||
"Win32_Graphics_Gdi",
|
|
||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
"Win32_System_StationsAndDesktops",
|
|
||||||
"Win32_System_Memory",
|
"Win32_System_Memory",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
"Win32_UI_HiDpi",
|
"Win32_UI_HiDpi",
|
||||||
|
|||||||
@@ -24,16 +24,6 @@ use pf_frame::DmabufFrame;
|
|||||||
pub trait Capturer: Send {
|
pub trait Capturer: Send {
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||||
|
|
||||||
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
|
|
||||||
/// backend's default. The pipeline retry loop shortens its FIRST attempt's wait: a PipeWire
|
|
||||||
/// stream connected while gamescope re-inits its headless takeover can negotiate a format,
|
|
||||||
/// reach `Streaming`, and still never receive a buffer — a fresh connect then delivers within
|
|
||||||
/// ~0.5 s, so waiting out the full default budget on a doomed stream just delays the retry
|
|
||||||
/// that fixes it. Backends without an internal wait budget ignore it (the default delegates).
|
|
||||||
fn next_frame_within(&mut self, _budget: std::time::Duration) -> Result<CapturedFrame> {
|
|
||||||
self.next_frame()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
||||||
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
||||||
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
||||||
@@ -69,34 +59,6 @@ pub trait Capturer: Send {
|
|||||||
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
|
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
|
||||||
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
|
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
|
||||||
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
|
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
|
||||||
/// The capture source's LIVE cursor state, when it arrives out-of-band from the frames
|
|
||||||
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
|
|
||||||
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
|
|
||||||
/// produce NO new frame, so the frame-attached overlay would go stale on a static desktop.
|
|
||||||
/// Default `None`: the Linux portal path attaches its cursor to frames instead.
|
|
||||||
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// LIVE cursor-render flip for a cursor-forward session (design/remote-desktop-sweep.md §8):
|
|
||||||
/// `on = true` — the client draws the pointer, keep it OUT of the video; `on = false` —
|
|
||||||
/// the capture mouse model, the pointer must be IN the video again. The Windows IDD
|
|
||||||
/// capturer implements the composite side ITSELF (slot-copy + alpha-blended quad from the
|
|
||||||
/// GDI poller) — a declared IddCx hardware cursor is irrevocable, so DWM can never be
|
|
||||||
/// handed the job back. Called every encode tick (implementations cache; steady state is
|
|
||||||
/// one compare). Default no-op: the Linux portal never bakes the pointer into frames —
|
|
||||||
/// the encode loop blends its overlay instead.
|
|
||||||
fn set_cursor_forward(&mut self, _on: bool) {}
|
|
||||||
|
|
||||||
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
|
|
||||||
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
|
|
||||||
/// portal capturer gamescope's nested Xwayland `(DISPLAY, XAUTHORITY)` targets (it may run
|
|
||||||
/// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11
|
|
||||||
/// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that
|
|
||||||
/// same slot. Called once, after the capturer is built, only for gamescope sessions. Default
|
|
||||||
/// no-op: every non-gamescope capturer already has a cursor source.
|
|
||||||
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {}
|
|
||||||
|
|
||||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -282,19 +244,8 @@ pub struct ZeroCopyPolicy {
|
|||||||
/// The resolved backend produces GPU-resident frames (everything but the software encoder) —
|
/// The resolved backend produces GPU-resident frames (everything but the software encoder) —
|
||||||
/// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`).
|
/// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`).
|
||||||
pub backend_is_gpu: bool,
|
pub backend_is_gpu: bool,
|
||||||
/// THIS session encodes PyroWave: the frames' consumer is the wavelet encoder's own Vulkan
|
|
||||||
/// device, which imports raw dmabufs on ANY vendor — so the capturer takes the raw-dmabuf
|
|
||||||
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
|
|
||||||
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
|
|
||||||
pub pyrowave_session: bool,
|
|
||||||
/// THIS session's encoder can ingest a producer-native NV12 capture (the Linux raw Vulkan
|
|
||||||
/// Video backend on an H265/AV1 session — resolved by the host facade via
|
|
||||||
/// `pf_encode::linux_native_nv12_ok`). Gates whether the negotiation PREFERS gamescope's
|
|
||||||
/// producer-side NV12 pod: libav VAAPI (H264's backend) would misread the two-plane buffer,
|
|
||||||
/// so H264/GameStream/PyroWave sessions must never see NV12 frames.
|
|
||||||
pub native_nv12_session: bool,
|
|
||||||
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
||||||
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
|
/// resolved when the encoder pref is `pyrowave` (the passthrough advertises them so Mutter+NVIDIA,
|
||||||
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
||||||
pub pyrowave_modifiers: Vec<u64>,
|
pub pyrowave_modifiers: Vec<u64>,
|
||||||
}
|
}
|
||||||
@@ -395,26 +346,6 @@ pub type FrameChannelSender = std::sync::Arc<
|
|||||||
dyn Fn(&pf_driver_proto::control::SetFrameChannelRequest) -> Result<()> + Send + Sync,
|
dyn Fn(&pf_driver_proto::control::SetFrameChannelRequest) -> Result<()> + Send + Sync,
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/// Delivery closure for the v5 hardware-cursor channel (`IOCTL_SET_CURSOR_CHANNEL`) — same
|
|
||||||
/// facade contract as [`FrameChannelSender`]. `Some` also OPTS THE SESSION IN: the capturer
|
|
||||||
/// creates + delivers the cursor section only when the host hands it a sender (the negotiated
|
|
||||||
/// cursor-forward sessions), and the driver only declares the hardware cursor once that
|
|
||||||
/// delivery lands — so a plain session keeps DWM's composited pointer untouched.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub type CursorChannelSender = std::sync::Arc<
|
|
||||||
dyn Fn(&pf_driver_proto::control::SetCursorChannelRequest) -> Result<()> + Send + Sync,
|
|
||||||
>;
|
|
||||||
|
|
||||||
/// The mid-stream cursor-render flip (`IOCTL_SET_CURSOR_FORWARD`, proto v6) as a host-facade
|
|
||||||
/// closure — same contract as [`CursorChannelSender`]. `bool` = declare the IddCx hardware
|
|
||||||
/// cursor (`true`) or stand it down (`false`; the host facade additionally forces the same-mode
|
|
||||||
/// re-commit that actualises the OS's software-cursor default). The capturer drives this from
|
|
||||||
/// its secure-desktop watch: UAC/Winlogon render only through the software-cursor path, so a
|
|
||||||
/// path pinned to the hardware cursor never presents them (the 0.18.0 secure-desktop
|
|
||||||
/// regression).
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub type CursorForwardSender = std::sync::Arc<dyn Fn(bool) -> Result<()> + Send + Sync>;
|
|
||||||
|
|
||||||
// One-time PipeWire library init, shared by the video (portal) and audio capture threads.
|
// One-time PipeWire library init, shared by the video (portal) and audio capture threads.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod pwinit;
|
pub mod pwinit;
|
||||||
@@ -472,7 +403,6 @@ pub fn open_virtual_output(
|
|||||||
allow_zerocopy: bool,
|
allow_zerocopy: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
policy: ZeroCopyPolicy,
|
policy: ZeroCopyPolicy,
|
||||||
expect_exact_dims: bool,
|
|
||||||
) -> Result<Box<dyn Capturer>> {
|
) -> Result<Box<dyn Capturer>> {
|
||||||
linux::PortalCapturer::from_virtual_output(
|
linux::PortalCapturer::from_virtual_output(
|
||||||
remote_fd,
|
remote_fd,
|
||||||
@@ -482,7 +412,6 @@ pub fn open_virtual_output(
|
|||||||
allow_zerocopy,
|
allow_zerocopy,
|
||||||
want_444,
|
want_444,
|
||||||
policy,
|
policy,
|
||||||
expect_exact_dims,
|
|
||||||
)
|
)
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
@@ -500,8 +429,6 @@ pub fn open_idd_push(
|
|||||||
pyrowave: bool,
|
pyrowave: bool,
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
sender: FrameChannelSender,
|
sender: FrameChannelSender,
|
||||||
cursor_sender: Option<CursorChannelSender>,
|
|
||||||
cursor_forward: Option<CursorForwardSender>,
|
|
||||||
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
||||||
idd_push::IddPushCapturer::open(
|
idd_push::IddPushCapturer::open(
|
||||||
target,
|
target,
|
||||||
@@ -511,8 +438,6 @@ pub fn open_idd_push(
|
|||||||
pyrowave,
|
pyrowave,
|
||||||
keepalive,
|
keepalive,
|
||||||
sender,
|
sender,
|
||||||
cursor_sender,
|
|
||||||
cursor_forward,
|
|
||||||
)
|
)
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-443
@@ -22,10 +22,6 @@
|
|||||||
|
|
||||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
|
||||||
// gamescope cursor source (remote-desktop-sweep Phase C) — feeds `cursor_live` from XFixes when
|
|
||||||
// the PipeWire node carries no `SPA_META_Cursor` (gamescope's does not).
|
|
||||||
mod xfixes_cursor;
|
|
||||||
use std::os::fd::OwnedFd;
|
use std::os::fd::OwnedFd;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, TryRecvError};
|
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, TryRecvError};
|
||||||
@@ -61,12 +57,6 @@ pub struct PortalCapturer {
|
|||||||
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
|
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
|
||||||
/// is `Streaming`.
|
/// is `Streaming`.
|
||||||
stall_since: Option<std::time::Instant>,
|
stall_since: Option<std::time::Instant>,
|
||||||
/// The LIVE cursor overlay, published by the PipeWire thread from every buffer's
|
|
||||||
/// `SPA_META_Cursor` — including the cursor-only "corrupted" buffers that never become
|
|
||||||
/// frames. [`Capturer::cursor`] serves it so the encode loop's forwarder tracks pointer-only
|
|
||||||
/// motion on a static desktop; the frame-attached overlay alone goes stale between damage
|
|
||||||
/// frames (the same gap the Windows IddCx channel fills, and why the tick prefers LIVE).
|
|
||||||
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
|
||||||
/// True when this capture runs the VAAPI dmabuf passthrough (a LINEAR-dmabuf-only offer). If
|
/// True when this capture runs the VAAPI dmabuf passthrough (a LINEAR-dmabuf-only offer). If
|
||||||
/// that offer never negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches
|
/// that offer never negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches
|
||||||
/// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline
|
/// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline
|
||||||
@@ -92,12 +82,6 @@ pub struct PortalCapturer {
|
|||||||
/// is, releasing the compositor-side output via the keepalive's own `Drop`. `None` for the
|
/// is, releasing the compositor-side output via the keepalive's own `Drop`. `None` for the
|
||||||
/// portal source (its session ends with the portal thread's zbus connection).
|
/// portal source (its session ends with the portal thread's zbus connection).
|
||||||
_keepalive: Option<Box<dyn Send>>,
|
_keepalive: Option<Box<dyn Send>>,
|
||||||
/// The gamescope XFixes cursor reader (remote-desktop-sweep Phase C), when this capturer
|
|
||||||
/// serves a gamescope node. `Some` after
|
|
||||||
/// [`attach_gamescope_cursor`](Capturer::attach_gamescope_cursor); its `Drop` stops the reader
|
|
||||||
/// thread, so it lives exactly as long as the capturer. `None` on the portal path (its cursor
|
|
||||||
/// comes from `SPA_META_Cursor`).
|
|
||||||
_gs_cursor: Option<xfixes_cursor::XFixesCursorSource>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PortalCapturer {
|
impl PortalCapturer {
|
||||||
@@ -130,17 +114,10 @@ impl PortalCapturer {
|
|||||||
"ScreenCast portal session started; connecting PipeWire"
|
"ScreenCast portal session started; connecting PipeWire"
|
||||||
);
|
);
|
||||||
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
||||||
Ok(spawn_pipewire(
|
Ok(
|
||||||
Some(fd),
|
spawn_pipewire(Some(fd), node_id, None, true, false, want_hdr, policy)?
|
||||||
node_id,
|
.into_capturer(node_id, None),
|
||||||
None,
|
)
|
||||||
true,
|
|
||||||
false,
|
|
||||||
want_hdr,
|
|
||||||
policy,
|
|
||||||
false,
|
|
||||||
)?
|
|
||||||
.into_capturer(node_id, None))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade
|
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade
|
||||||
@@ -160,13 +137,11 @@ impl PortalCapturer {
|
|||||||
allow_zerocopy: bool,
|
allow_zerocopy: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
policy: ZeroCopyPolicy,
|
policy: ZeroCopyPolicy,
|
||||||
expect_exact_dims: bool,
|
|
||||||
) -> Result<PortalCapturer> {
|
) -> Result<PortalCapturer> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
node_id,
|
node_id,
|
||||||
allow_zerocopy,
|
allow_zerocopy,
|
||||||
want_444,
|
want_444,
|
||||||
expect_exact_dims,
|
|
||||||
"connecting PipeWire to virtual output"
|
"connecting PipeWire to virtual output"
|
||||||
);
|
);
|
||||||
// Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit
|
// Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit
|
||||||
@@ -179,7 +154,6 @@ impl PortalCapturer {
|
|||||||
want_444,
|
want_444,
|
||||||
false,
|
false,
|
||||||
policy,
|
policy,
|
||||||
expect_exact_dims,
|
|
||||||
)?
|
)?
|
||||||
.into_capturer(node_id, Some(keepalive)))
|
.into_capturer(node_id, Some(keepalive)))
|
||||||
}
|
}
|
||||||
@@ -202,8 +176,6 @@ struct PwHandles {
|
|||||||
hdr_offer: bool,
|
hdr_offer: bool,
|
||||||
/// See [`PortalCapturer::hdr_negotiated`].
|
/// See [`PortalCapturer::hdr_negotiated`].
|
||||||
hdr_negotiated: Arc<AtomicBool>,
|
hdr_negotiated: Arc<AtomicBool>,
|
||||||
/// See [`PortalCapturer::cursor_live`].
|
|
||||||
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
|
||||||
quit: ::pipewire::channel::Sender<()>,
|
quit: ::pipewire::channel::Sender<()>,
|
||||||
join: thread::JoinHandle<()>,
|
join: thread::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
@@ -223,12 +195,10 @@ impl PwHandles {
|
|||||||
vaapi_dmabuf: self.vaapi_dmabuf,
|
vaapi_dmabuf: self.vaapi_dmabuf,
|
||||||
hdr_offer: self.hdr_offer,
|
hdr_offer: self.hdr_offer,
|
||||||
hdr_negotiated: self.hdr_negotiated,
|
hdr_negotiated: self.hdr_negotiated,
|
||||||
cursor_live: self.cursor_live,
|
|
||||||
node_id,
|
node_id,
|
||||||
quit: Some(self.quit),
|
quit: Some(self.quit),
|
||||||
join: Some(self.join),
|
join: Some(self.join),
|
||||||
_keepalive: keepalive,
|
_keepalive: keepalive,
|
||||||
_gs_cursor: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +206,6 @@ impl PwHandles {
|
|||||||
/// Spawn the PipeWire consumer thread for `node_id` (fd `Some` = portal remote, `None` =
|
/// Spawn the PipeWire consumer thread for `node_id` (fd `Some` = portal remote, `None` =
|
||||||
/// default daemon) and return its [`PwHandles`]. `preferred` seeds the format negotiation's
|
/// default daemon) and return its [`PwHandles`]. `preferred` seeds the format negotiation's
|
||||||
/// default size/framerate — for Mutter virtual monitors this is what actually sizes the monitor.
|
/// default size/framerate — for Mutter virtual monitors this is what actually sizes the monitor.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn spawn_pipewire(
|
fn spawn_pipewire(
|
||||||
fd: Option<OwnedFd>,
|
fd: Option<OwnedFd>,
|
||||||
node_id: u32,
|
node_id: u32,
|
||||||
@@ -255,12 +224,6 @@ fn spawn_pipewire(
|
|||||||
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
||||||
// capture→encode edge (plan §W6).
|
// capture→encode edge (plan §W6).
|
||||||
policy: ZeroCopyPolicy,
|
policy: ZeroCopyPolicy,
|
||||||
// The producer's FIRST negotiation is for a sacrificial mode and a renegotiation to
|
|
||||||
// `preferred`'s dims is guaranteed to follow (KWin virtual outputs — see kwin.rs `create`):
|
|
||||||
// skip whole buffers until the negotiated size matches, so the pipeline never builds against
|
|
||||||
// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation,
|
|
||||||
// gamescope fixates its own — gating those would starve legitimate first frames).
|
|
||||||
expect_exact_dims: bool,
|
|
||||||
) -> Result<PwHandles> {
|
) -> Result<PwHandles> {
|
||||||
// Frames flow from the pipewire thread over a small bounded channel.
|
// Frames flow from the pipewire thread over a small bounded channel.
|
||||||
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
|
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
|
||||||
@@ -274,8 +237,6 @@ fn spawn_pipewire(
|
|||||||
let broken_cb = broken.clone();
|
let broken_cb = broken.clone();
|
||||||
let hdr_negotiated = Arc::new(AtomicBool::new(false));
|
let hdr_negotiated = Arc::new(AtomicBool::new(false));
|
||||||
let hdr_negotiated_cb = hdr_negotiated.clone();
|
let hdr_negotiated_cb = hdr_negotiated.clone();
|
||||||
let cursor_live = Arc::new(std::sync::Mutex::new(None::<pf_frame::CursorOverlay>));
|
|
||||||
let cursor_live_cb = cursor_live.clone();
|
|
||||||
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
|
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
|
||||||
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
||||||
// inner `mod pipewire` shadows the crate name at this scope.
|
// inner `mod pipewire` shadows the crate name at this scope.
|
||||||
@@ -294,11 +255,9 @@ fn spawn_pipewire(
|
|||||||
want_hdr
|
want_hdr
|
||||||
};
|
};
|
||||||
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
|
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
|
||||||
// backend or a PyroWave session the EGL→CUDA importer is never built) — kept on the capturer
|
// backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s
|
||||||
// so `next_frame`'s negotiation-timeout branch knows a failed negotiation was the raw-dmabuf
|
// negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer.
|
||||||
// passthrough offer.
|
let vaapi_dmabuf = zerocopy && !force_shm && policy.backend_is_vaapi;
|
||||||
let vaapi_dmabuf =
|
|
||||||
zerocopy && !force_shm && (policy.backend_is_vaapi || policy.pyrowave_session);
|
|
||||||
let join = thread::Builder::new()
|
let join = thread::Builder::new()
|
||||||
.name("punktfunk-pipewire".into())
|
.name("punktfunk-pipewire".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -311,14 +270,12 @@ fn spawn_pipewire(
|
|||||||
streaming_cb,
|
streaming_cb,
|
||||||
broken_cb,
|
broken_cb,
|
||||||
hdr_negotiated_cb,
|
hdr_negotiated_cb,
|
||||||
cursor_live_cb,
|
|
||||||
zerocopy,
|
zerocopy,
|
||||||
want_444,
|
want_444,
|
||||||
want_hdr,
|
want_hdr,
|
||||||
preferred,
|
preferred,
|
||||||
quit_rx,
|
quit_rx,
|
||||||
policy,
|
policy,
|
||||||
expect_exact_dims,
|
|
||||||
) {
|
) {
|
||||||
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
|
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
|
||||||
}
|
}
|
||||||
@@ -333,7 +290,6 @@ fn spawn_pipewire(
|
|||||||
vaapi_dmabuf,
|
vaapi_dmabuf,
|
||||||
hdr_offer: want_hdr,
|
hdr_offer: want_hdr,
|
||||||
hdr_negotiated,
|
hdr_negotiated,
|
||||||
cursor_live,
|
|
||||||
quit: quit_tx,
|
quit: quit_tx,
|
||||||
join,
|
join,
|
||||||
})
|
})
|
||||||
@@ -341,29 +297,29 @@ fn spawn_pipewire(
|
|||||||
|
|
||||||
impl Capturer for PortalCapturer {
|
impl Capturer for PortalCapturer {
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||||
self.frame_within(Duration::from_secs(10))
|
// First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in
|
||||||
|
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s
|
||||||
|
// instead of sitting out the full first-frame budget.
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
if self.broken.load(Ordering::Relaxed) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
||||||
|
failed repeatedly — rebuilding capture",
|
||||||
|
self.node_id
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(f) = self.pending.take() {
|
||||||
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
|
||||||
// The PipeWire thread's live cursor slot (fed by every buffer's meta, frames or not) —
|
}
|
||||||
// lets the forwarder track pointer-only motion on a static desktop. See `cursor_live`.
|
let slice = Duration::from_millis(500)
|
||||||
// On a gamescope node the meta never arrives; the XFixes source (attached below) fills
|
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
||||||
// the same slot instead.
|
match self.frames.recv_timeout(slice) {
|
||||||
self.cursor_live.lock().ok().and_then(|slot| slot.clone())
|
Ok(frame) => return Ok(frame),
|
||||||
|
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
||||||
|
Err(e) => return self.next_frame_timed_out(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn attach_gamescope_cursor(&mut self, targets: Vec<(String, Option<String>)>) {
|
|
||||||
// gamescope paints no `SPA_META_Cursor`, so `cursor_live` would stay empty. Spawn the
|
|
||||||
// XFixes reader to publish gamescope's pointer into that SAME slot — `cursor()` above then
|
|
||||||
// serves it and the encode loop composites it, exactly like the portal path. It connects
|
|
||||||
// to every nested Xwayland and follows the focused one's pointer. A failure (no Xwayland /
|
|
||||||
// no XFixes) logs and leaves the slot empty = today's cursorless gamescope.
|
|
||||||
self._gs_cursor =
|
|
||||||
xfixes_cursor::XFixesCursorSource::spawn(targets, Arc::clone(&self.cursor_live));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
|
||||||
self.frame_within(budget)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supports_arrival_wait(&self) -> bool {
|
fn supports_arrival_wait(&self) -> bool {
|
||||||
@@ -459,41 +415,9 @@ impl Capturer for PortalCapturer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PortalCapturer {
|
impl PortalCapturer {
|
||||||
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
|
/// The [`Capturer::next_frame`] budget expired (or the thread ended) — turn it into the
|
||||||
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
|
/// diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||||
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
|
fn next_frame_timed_out(&self, err: RecvTimeoutError) -> Result<CapturedFrame> {
|
||||||
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
|
|
||||||
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
|
||||||
let deadline = std::time::Instant::now() + budget;
|
|
||||||
loop {
|
|
||||||
if self.broken.load(Ordering::Relaxed) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
|
||||||
failed repeatedly — rebuilding capture",
|
|
||||||
self.node_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(f) = self.pending.take() {
|
|
||||||
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
|
|
||||||
}
|
|
||||||
let slice = Duration::from_millis(500)
|
|
||||||
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
|
||||||
match self.frames.recv_timeout(slice) {
|
|
||||||
Ok(frame) => return Ok(frame),
|
|
||||||
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
|
||||||
Err(e) => return self.next_frame_timed_out(e, budget),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
|
|
||||||
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
|
||||||
fn next_frame_timed_out(
|
|
||||||
&self,
|
|
||||||
err: RecvTimeoutError,
|
|
||||||
budget: Duration,
|
|
||||||
) -> Result<CapturedFrame> {
|
|
||||||
let within = budget.as_secs_f32();
|
|
||||||
match err {
|
match err {
|
||||||
RecvTimeoutError::Timeout => {
|
RecvTimeoutError::Timeout => {
|
||||||
// Split the two black-screen root causes apart so the operator gets a cause, not
|
// Split the two black-screen root causes apart so the operator gets a cause, not
|
||||||
@@ -501,10 +425,9 @@ impl PortalCapturer {
|
|||||||
// not (no acceptable format / node never emitted a param)?
|
// not (no acceptable format / node never emitted a param)?
|
||||||
if self.negotiated.load(Ordering::Relaxed) {
|
if self.negotiated.load(Ordering::Relaxed) {
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within {within}s (node {}): format negotiated but no \
|
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \
|
||||||
buffers arrived — the compositor produced no frames (virtual output \
|
arrived — the compositor produced no frames (virtual output idle/unmapped, \
|
||||||
idle/unmapped, capture never started, or a stream bound during a \
|
or capture never started)",
|
||||||
compositor (re)start that will never deliver — a reconnect fixes that)",
|
|
||||||
self.node_id
|
self.node_id
|
||||||
))
|
))
|
||||||
} else if self.hdr_offer {
|
} else if self.hdr_offer {
|
||||||
@@ -515,10 +438,10 @@ impl PortalCapturer {
|
|||||||
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
||||||
super::note_hdr_capture_failed();
|
super::note_hdr_capture_failed();
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||||
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
|
the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored monitor in \
|
||||||
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
|
HDR mode on GNOME 50+? Downgrading this host to SDR capture; reconnect \
|
||||||
reconnect to stream SDR",
|
to stream SDR",
|
||||||
self.node_id
|
self.node_id
|
||||||
))
|
))
|
||||||
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
||||||
@@ -527,15 +450,14 @@ impl PortalCapturer {
|
|||||||
// retries on the CPU offer instead of failing this same negotiation forever.
|
// retries on the CPU offer instead of failing this same negotiation forever.
|
||||||
pf_zerocopy::note_vaapi_dmabuf_failed();
|
pf_zerocopy::note_vaapi_dmabuf_failed();
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||||
accepted the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this \
|
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
|
||||||
host to the CPU capture path; the pipeline rebuild will renegotiate \
|
CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
|
||||||
without dmabuf",
|
|
||||||
self.node_id
|
self.node_id
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within {within}s (node {}): format negotiation never \
|
"no PipeWire frame within 10s (node {}): format negotiation never \
|
||||||
completed — the compositor offered no format this consumer accepts \
|
completed — the compositor offered no format this consumer accepts \
|
||||||
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
||||||
self.node_id
|
self.node_id
|
||||||
@@ -900,7 +822,6 @@ mod pipewire {
|
|||||||
VideoFormat::RGBA => PixelFormat::Rgba,
|
VideoFormat::RGBA => PixelFormat::Rgba,
|
||||||
VideoFormat::RGB => PixelFormat::Rgb,
|
VideoFormat::RGB => PixelFormat::Rgb,
|
||||||
VideoFormat::BGR => PixelFormat::Bgr,
|
VideoFormat::BGR => PixelFormat::Bgr,
|
||||||
VideoFormat::NV12 => PixelFormat::Nv12,
|
|
||||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
|
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
|
||||||
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
|
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
|
||||||
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
|
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
|
||||||
@@ -928,21 +849,13 @@ mod pipewire {
|
|||||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
||||||
/// so the GPU encoder re-uploads its cursor texture only on change.
|
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||||
serial: u64,
|
serial: u64,
|
||||||
/// The compositor-reported hotspot — carried on the overlay for the cursor-forward
|
|
||||||
/// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it).
|
|
||||||
hot_x: i32,
|
|
||||||
hot_y: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CursorState {
|
impl CursorState {
|
||||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
/// A shareable overlay for the GPU encode paths (blended at encode time), or `None` when
|
||||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
/// there is nothing to draw. Cheap: clones an `Arc` + a few scalars.
|
||||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
|
||||||
/// client's relative-mode hint (M3) — which is a different fact from "no cursor yet".
|
|
||||||
/// The encode loop strips invisible overlays before any blend path sees the frame.
|
|
||||||
/// Cheap: clones an `Arc` + a few scalars.
|
|
||||||
fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
|
fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
|
||||||
if self.rgba.is_empty() {
|
if !self.visible || self.rgba.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(pf_frame::CursorOverlay {
|
Some(pf_frame::CursorOverlay {
|
||||||
@@ -952,9 +865,6 @@ mod pipewire {
|
|||||||
h: self.bh,
|
h: self.bh,
|
||||||
rgba: self.rgba.clone(),
|
rgba: self.rgba.clone(),
|
||||||
serial: self.serial,
|
serial: self.serial,
|
||||||
hot_x: self.hot_x.max(0) as u32,
|
|
||||||
hot_y: self.hot_y.max(0) as u32,
|
|
||||||
visible: self.visible,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1005,21 +915,6 @@ mod pipewire {
|
|||||||
dbg_log_n: u64,
|
dbg_log_n: u64,
|
||||||
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
|
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
|
||||||
cursor: CursorState,
|
cursor: CursorState,
|
||||||
/// LIVE overlay slot shared with [`super::PortalCapturer::cursor_live`] — refreshed after
|
|
||||||
/// every `update_cursor_meta`, including from cursor-only buffers that never become frames.
|
|
||||||
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
|
||||||
/// `Some((w, h))` while the producer's negotiated size is a sacrificial birth mode and a
|
|
||||||
/// renegotiation to these dims is guaranteed (KWin virtual outputs — kwin.rs `create`):
|
|
||||||
/// `.process` skips whole buffers until the negotiated size matches, then clears this
|
|
||||||
/// (self-disarming — later legitimate resizes are unaffected). `None` = no gating.
|
|
||||||
expect_dims: Option<(u32, u32)>,
|
|
||||||
/// Buffers skipped by the `expect_dims` gate (rate-limits its log).
|
|
||||||
gate_skips: u64,
|
|
||||||
/// When the gate first held a buffer — after [`GATE_DEADLINE`] with no renegotiation the
|
|
||||||
/// gate disarms and accepts what the producer serves (degraded dims beat a session wedged
|
|
||||||
/// into the first-frame-timeout retry loop; the promised renegotiation normally lands
|
|
||||||
/// within a frame or two).
|
|
||||||
gate_since: Option<std::time::Instant>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
|
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
|
||||||
@@ -1092,10 +987,10 @@ mod pipewire {
|
|||||||
.into_inner())
|
.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
|
/// Build a BGRx dmabuf `EnumFormat` pod advertising the EGL-importable `modifiers` as a
|
||||||
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
|
/// mandatory enum Choice; the compositor fixates to one of them that it can allocate, which
|
||||||
|
/// we read back in `param_changed`.
|
||||||
fn build_dmabuf_format(
|
fn build_dmabuf_format(
|
||||||
format: VideoFormat,
|
|
||||||
modifiers: &[u64],
|
modifiers: &[u64],
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
@@ -1106,7 +1001,7 @@ mod pipewire {
|
|||||||
pw::spa::param::ParamType::EnumFormat,
|
pw::spa::param::ParamType::EnumFormat,
|
||||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, VideoFormat::BGRx),
|
||||||
pw::spa::pod::property!(
|
pw::spa::pod::property!(
|
||||||
FormatProperties::VideoSize,
|
FormatProperties::VideoSize,
|
||||||
Choice,
|
Choice,
|
||||||
@@ -1135,22 +1030,6 @@ mod pipewire {
|
|||||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if format == VideoFormat::NV12 {
|
|
||||||
obj.properties.push(pw::spa::pod::Property {
|
|
||||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
|
|
||||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
|
||||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
|
||||||
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
obj.properties.push(pw::spa::pod::Property {
|
|
||||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
|
|
||||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
|
||||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
|
||||||
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
obj.properties.push(pw::spa::pod::Property {
|
obj.properties.push(pw::spa::pod::Property {
|
||||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||||
@@ -1398,17 +1277,10 @@ mod pipewire {
|
|||||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||||
pw::spa::utils::Choice(
|
pw::spa::utils::Choice(
|
||||||
pw::spa::utils::ChoiceFlags::empty(),
|
pw::spa::utils::ChoiceFlags::empty(),
|
||||||
// The max must cover the producer's offer or the Meta param silently
|
|
||||||
// fails to negotiate and NO buffer ever carries the meta region:
|
|
||||||
// Mutter offers a FIXED `SPA_POD_Int(CURSOR_META_SIZE(384, 384))`
|
|
||||||
// (meta-screen-cast-stream-src.c, GNOME 50) — a 256² max made the
|
|
||||||
// intersection empty, which cost the whole Linux cursor channel
|
|
||||||
// on-glass. 1024² is headroom, not an allocation: the negotiated
|
|
||||||
// region follows the producer's value.
|
|
||||||
pw::spa::utils::ChoiceEnum::Range {
|
pw::spa::utils::ChoiceEnum::Range {
|
||||||
default: meta_size(64, 64),
|
default: meta_size(64, 64),
|
||||||
min: meta_size(1, 1),
|
min: meta_size(1, 1),
|
||||||
max: meta_size(1024, 1024),
|
max: meta_size(256, 256),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
@@ -1436,25 +1308,21 @@ mod pipewire {
|
|||||||
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
||||||
fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
||||||
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
||||||
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
|
// `spa_buffer_find_meta_data` scans its metadata array for a `SPA_META_Cursor` of at least
|
||||||
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
|
// `size_of::<spa_meta_cursor>()` bytes and returns a pointer into that buffer's metadata
|
||||||
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
|
// (or null), valid until requeue. The size argument matches the struct the result is cast to.
|
||||||
// are ALL producer-written, and without a bound against the actual region they drive
|
let cur = unsafe {
|
||||||
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
|
spa::sys::spa_buffer_find_meta_data(
|
||||||
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
spa_buf,
|
||||||
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
spa::sys::SPA_META_Cursor,
|
||||||
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
std::mem::size_of::<spa::sys::spa_meta_cursor>(),
|
||||||
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
|
) as *const spa::sys::spa_meta_cursor
|
||||||
if meta.is_null() {
|
};
|
||||||
|
if cur.is_null() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
|
// SAFETY: `cur` is non-null and points to a `spa_meta_cursor` of at least its own size
|
||||||
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
|
// inside the held buffer (guaranteed by the size arg above), so every field read is in bounds.
|
||||||
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cur = data as *const spa::sys::spa_meta_cursor;
|
|
||||||
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
|
|
||||||
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||||
(
|
(
|
||||||
(*cur).id,
|
(*cur).id,
|
||||||
@@ -1466,35 +1334,24 @@ mod pipewire {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
// Compositor reports no visible pointer (e.g. a game grabbed/hid it).
|
||||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
cursor.visible = false;
|
||||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
|
||||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
|
||||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
|
||||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cursor.visible = true;
|
cursor.visible = true;
|
||||||
cursor.x = pos_x - hot_x;
|
cursor.x = pos_x - hot_x;
|
||||||
cursor.y = pos_y - hot_y;
|
cursor.y = pos_y - hot_y;
|
||||||
cursor.hot_x = hot_x;
|
|
||||||
cursor.hot_y = hot_y;
|
|
||||||
if bmp_off == 0 {
|
if bmp_off == 0 {
|
||||||
// Position-only update — keep the cached bitmap.
|
// Position-only update — keep the cached bitmap.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let bmp_off = bmp_off as usize;
|
// SAFETY: `bitmap_offset` is a byte offset from `cur` to a `spa_meta_bitmap`, which the
|
||||||
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
|
// producer placed inside the same meta region it sized for this cursor (>= the size we
|
||||||
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
|
// requested). The resulting pointer is in bounds and aligned for `spa_meta_bitmap`.
|
||||||
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
|
let bmp =
|
||||||
Some(end) if end <= region_size => {}
|
unsafe { (cur as *const u8).add(bmp_off as usize) as *const spa::sys::spa_meta_bitmap };
|
||||||
_ => return,
|
// SAFETY: `bmp` is the in-bounds, aligned `spa_meta_bitmap` pointer computed just above; the
|
||||||
}
|
// producer fully initialized this header, so reading its scalar fields is sound.
|
||||||
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
|
|
||||||
// so the header is fully in bounds; the producer places it aligned as before.
|
|
||||||
let bmp = unsafe { data.add(bmp_off) as *const spa::sys::spa_meta_bitmap };
|
|
||||||
// SAFETY: `bmp` is the in-bounds `spa_meta_bitmap` header validated just above; reading its
|
|
||||||
// scalar fields is sound.
|
|
||||||
let (vfmt, bw, bh, stride, pix_off) = unsafe {
|
let (vfmt, bw, bh, stride, pix_off) = unsafe {
|
||||||
(
|
(
|
||||||
(*bmp).format,
|
(*bmp).format,
|
||||||
@@ -1504,34 +1361,16 @@ mod pipewire {
|
|||||||
(*bmp).offset as usize,
|
(*bmp).offset as usize,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
// Ignore empty or implausibly large bitmaps (the meta-size request covers <= 1024×1024;
|
// Ignore empty or implausibly large bitmaps (we requested <= 256×256).
|
||||||
// real cursors are ≤96px — the cursor channel downscales >120px for the wire anyway).
|
if bw == 0 || bh == 0 || bw > 256 || bh > 256 {
|
||||||
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let row = bw as usize * 4;
|
let row = bw as usize * 4;
|
||||||
let stride = if stride < row { row } else { stride };
|
let stride = if stride < row { row } else { stride };
|
||||||
// `span` is the exact byte extent the strided loop reads: `stride·(bh-1) + row`. Compute it
|
let span = stride * (bh as usize - 1) + row;
|
||||||
// with checked arithmetic (a producer stride near `i32::MAX` would otherwise overflow) and
|
// SAFETY: the bitmap pixels live at `bmp + pix_off` for `span` bytes, within the
|
||||||
// require the whole pixel block `[bmp_off + pix_off, +span)` to lie inside the region before
|
// producer-sized meta region. `span` is the exact extent the strided copy below reads.
|
||||||
// fabricating the slice — this is the check whose absence made the read go out of bounds.
|
let src = unsafe { std::slice::from_raw_parts((bmp as *const u8).add(pix_off), span) };
|
||||||
let span = match stride
|
|
||||||
.checked_mul(bh as usize - 1)
|
|
||||||
.and_then(|v| v.checked_add(row))
|
|
||||||
{
|
|
||||||
Some(s) => s,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
match bmp_off
|
|
||||||
.checked_add(pix_off)
|
|
||||||
.and_then(|v| v.checked_add(span))
|
|
||||||
{
|
|
||||||
Some(end) if end <= region_size => {}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
// SAFETY: `bmp_off + pix_off + span <= region_size` (checked directly above), so the slice
|
|
||||||
// is fully within the producer's meta region; `span` is exactly the strided loop's extent.
|
|
||||||
let src = unsafe { std::slice::from_raw_parts(data.add(bmp_off + pix_off), span) };
|
|
||||||
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
||||||
for y in 0..bh as usize {
|
for y in 0..bh as usize {
|
||||||
for x in 0..bw as usize {
|
for x in 0..bw as usize {
|
||||||
@@ -1737,8 +1576,8 @@ mod pipewire {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can
|
// VAAPI zero-copy passthrough: hand the raw dmabuf straight to the encoder, which imports
|
||||||
// be consumed by the Vulkan Video encoder without another color conversion.
|
// it into a VA surface and does RGB→NV12 on the GPU video engine. No CUDA importer here.
|
||||||
if ud.vaapi_passthrough {
|
if ud.vaapi_passthrough {
|
||||||
if let Some(fmt) = ud.format {
|
if let Some(fmt) = ud.format {
|
||||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||||
@@ -1746,41 +1585,9 @@ mod pipewire {
|
|||||||
let chunk = datas[0].chunk();
|
let chunk = datas[0].chunk();
|
||||||
let offset = chunk.offset();
|
let offset = chunk.offset();
|
||||||
let stride = chunk.stride().max(0) as u32;
|
let stride = chunk.stride().max(0) as u32;
|
||||||
// Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer
|
|
||||||
// object; plane 1's chunk carries the REAL UV offset/stride (compositors
|
|
||||||
// may align the Y plane before UV). Pass it through instead of assuming
|
|
||||||
// contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is
|
|
||||||
// by inode, not fd number; a genuinely two-BO frame cannot travel through
|
|
||||||
// the single-fd import — drop it with a diagnosis instead of streaming
|
|
||||||
// garbage chroma.
|
|
||||||
let plane1 =
|
|
||||||
if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 {
|
|
||||||
// SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are
|
|
||||||
// owned by the live PipeWire buffer for this callback, and `fstat`
|
|
||||||
// only writes the out-param structs, whose fields are read only after
|
|
||||||
// the `== 0` success checks.
|
|
||||||
let same_bo = unsafe {
|
|
||||||
let mut s0: libc::stat = std::mem::zeroed();
|
|
||||||
let mut s1: libc::stat = std::mem::zeroed();
|
|
||||||
libc::fstat(datas[0].fd() as i32, &mut s0) == 0
|
|
||||||
&& libc::fstat(datas[1].fd() as i32, &mut s1) == 0
|
|
||||||
&& (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino)
|
|
||||||
};
|
|
||||||
if !same_bo {
|
|
||||||
warn_once(
|
|
||||||
"NV12 planes live in different buffer objects — frames \
|
|
||||||
dropped (single-fd import only)",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let c1 = datas[1].chunk();
|
|
||||||
Some((c1.offset(), c1.stride().max(0) as u32))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||||
// imports it. Content stability across the brief import/encode window relies
|
// imports it. (Content stability across the brief map+CSC window relies on
|
||||||
// on the compositor's buffer-pool depth, like any zero-copy capture.
|
// the compositor's buffer-pool depth, like any zero-copy capture.)
|
||||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||||
@@ -1807,10 +1614,9 @@ mod pipewire {
|
|||||||
modifier: ud.modifier,
|
modifier: ud.modifier,
|
||||||
offset,
|
offset,
|
||||||
stride,
|
stride,
|
||||||
plane1,
|
|
||||||
}),
|
}),
|
||||||
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
|
// Cursor-as-metadata: the encoder blends this into its owned VA
|
||||||
// embeds its pointer in the produced pixels, so native NV12 has none.
|
// surface (raw dmabuf never touched).
|
||||||
cursor: ud.cursor.overlay(),
|
cursor: ud.cursor.overlay(),
|
||||||
});
|
});
|
||||||
static ONCE: std::sync::atomic::AtomicBool =
|
static ONCE: std::sync::atomic::AtomicBool =
|
||||||
@@ -1821,12 +1627,7 @@ mod pipewire {
|
|||||||
h,
|
h,
|
||||||
modifier = ud.modifier,
|
modifier = ud.modifier,
|
||||||
fourcc = format_args!("{:#010x}", fourcc),
|
fourcc = format_args!("{:#010x}", fourcc),
|
||||||
source = if fmt == PixelFormat::Nv12 {
|
"zero-copy: handing the raw dmabuf to the encoder (GPU import + CSC)"
|
||||||
"producer-native NV12"
|
|
||||||
} else {
|
|
||||||
"packed RGB (encoder GPU CSC)"
|
|
||||||
},
|
|
||||||
"zero-copy: handing the raw DMA-BUF to the encoder"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -2106,9 +1907,6 @@ mod pipewire {
|
|||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
broken: Arc<AtomicBool>,
|
broken: Arc<AtomicBool>,
|
||||||
hdr_negotiated: Arc<AtomicBool>,
|
hdr_negotiated: Arc<AtomicBool>,
|
||||||
// LIVE cursor publisher (see `PortalCapturer::cursor_live`): refreshed from every
|
|
||||||
// dequeued buffer's cursor meta, frames or not.
|
|
||||||
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
|
||||||
zerocopy: bool,
|
zerocopy: bool,
|
||||||
// 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert.
|
// 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert.
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
@@ -2120,9 +1918,6 @@ mod pipewire {
|
|||||||
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
||||||
// capture→encode edge (plan §W6).
|
// capture→encode edge (plan §W6).
|
||||||
policy: ZeroCopyPolicy,
|
policy: ZeroCopyPolicy,
|
||||||
// See `spawn_pipewire`: the first negotiation is for a sacrificial mode; hold frames
|
|
||||||
// until the producer renegotiates to `preferred`'s dims.
|
|
||||||
expect_exact_dims: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
crate::pwinit::ensure_init();
|
crate::pwinit::ensure_init();
|
||||||
|
|
||||||
@@ -2150,18 +1945,16 @@ mod pipewire {
|
|||||||
// Build the GPU importer up front — normally the ISOLATED worker process
|
// Build the GPU importer up front — normally the ISOLATED worker process
|
||||||
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
|
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
|
||||||
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
|
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
|
||||||
// (we simply won't request dmabuf below). Skipped entirely when the frames go to the
|
// (we simply won't request dmabuf below). Skipped entirely when the encode backend is
|
||||||
// raw-dmabuf passthrough — the encode backend is VAAPI, or the SESSION encodes PyroWave
|
// VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there
|
||||||
// (its Vulkan device imports raw dmabufs on any vendor): building the importer there
|
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
|
||||||
// would waste a CUDA probe — or worse, succeed and produce CUDA payloads only NVENC can
|
// succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
|
||||||
// consume. Also skipped once repeated worker deaths latched the import off (a wedged GPU
|
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
|
||||||
// stack must not crash-loop).
|
|
||||||
let backend_is_vaapi = policy.backend_is_vaapi;
|
let backend_is_vaapi = policy.backend_is_vaapi;
|
||||||
let raw_passthrough = backend_is_vaapi || policy.pyrowave_session;
|
|
||||||
// HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8,
|
// HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8,
|
||||||
// which would silently crush the 10-bit depth. The HDR consumers are the CPU mmap path
|
// which would silently crush the 10-bit depth. The HDR consumers are the CPU mmap path
|
||||||
// (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough.
|
// (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough.
|
||||||
let mut importer = if zerocopy && !raw_passthrough && !want_hdr {
|
let mut importer = if zerocopy && !backend_is_vaapi && !want_hdr {
|
||||||
if pf_zerocopy::gpu_import_disabled() {
|
if pf_zerocopy::gpu_import_disabled() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
||||||
@@ -2187,33 +1980,9 @@ mod pipewire {
|
|||||||
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
|
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
|
||||||
// race).
|
// race).
|
||||||
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
||||||
// Raw-dmabuf zero-copy passthrough: zero-copy on, no EGL→CUDA importer, and the frames'
|
// VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and
|
||||||
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a
|
// the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs).
|
||||||
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && backend_is_vaapi;
|
||||||
// dmabuf straight to the encoder.
|
|
||||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
|
|
||||||
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
|
|
||||||
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
|
|
||||||
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
|
|
||||||
// CSC at all). `native_nv12_session` restricts this to sessions whose encoder can ingest
|
|
||||||
// it (Linux vulkan-encode H265/AV1 — never H264/libav-VAAPI, GameStream-resolve, or
|
|
||||||
// PyroWave, whose Vulkan compute CSC ingests packed RGB only). Raw passthrough is
|
|
||||||
// required because the CUDA importer expects packed RGB, and 4:4:4/HDR must not be
|
|
||||||
// silently subsampled/downconverted. Non-NV12 compositors (KWin/GNOME) simply match the
|
|
||||||
// packed-RGB fallback pod.
|
|
||||||
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() != Ok("0")
|
|
||||||
&& policy.native_nv12_session
|
|
||||||
&& backend_is_vaapi
|
|
||||||
&& vaapi_passthrough
|
|
||||||
&& !policy.pyrowave_session
|
|
||||||
&& !want_444
|
|
||||||
&& !want_hdr;
|
|
||||||
if prefer_native_nv12 {
|
|
||||||
tracing::info!(
|
|
||||||
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
|
|
||||||
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
||||||
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
||||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||||
@@ -2229,9 +1998,8 @@ mod pipewire {
|
|||||||
// advertisement with every modifier its device samples from, so compositors that
|
// advertisement with every modifier its device samples from, so compositors that
|
||||||
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
|
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
|
||||||
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
|
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
|
||||||
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is
|
// the host's `pyrowave` feature is on AND the encoder pref is `pyrowave` — so capture never
|
||||||
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
|
// calls back into `encode` and needs no feature gate of its own (the emptiness check gates it).
|
||||||
// own (the emptiness check gates it).
|
|
||||||
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
||||||
for &m in &policy.pyrowave_modifiers {
|
for &m in &policy.pyrowave_modifiers {
|
||||||
if !modifiers.contains(&m) {
|
if !modifiers.contains(&m) {
|
||||||
@@ -2251,13 +2019,11 @@ mod pipewire {
|
|||||||
);
|
);
|
||||||
} else if zerocopy && !want_dmabuf {
|
} else if zerocopy && !want_dmabuf {
|
||||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
} else if vaapi_passthrough {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
native_nv12_preferred = prefer_native_nv12,
|
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
|
||||||
"zero-copy: advertising LINEAR DMA-BUF for encoder import (native NV12 first \
|
|
||||||
when enabled, packed RGB fallback)"
|
|
||||||
);
|
);
|
||||||
} else if want_dmabuf && !vaapi_passthrough {
|
} else if want_dmabuf {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
count = modifiers.len(),
|
count = modifiers.len(),
|
||||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||||
@@ -2307,14 +2073,6 @@ mod pipewire {
|
|||||||
linear_nv12_failed: false,
|
linear_nv12_failed: false,
|
||||||
dbg_log_n: 0,
|
dbg_log_n: 0,
|
||||||
cursor: CursorState::default(),
|
cursor: CursorState::default(),
|
||||||
cursor_live,
|
|
||||||
expect_dims: if expect_exact_dims {
|
|
||||||
preferred.map(|(w, h, _)| (w, h))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
gate_skips: 0,
|
|
||||||
gate_since: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = pw::stream::StreamBox::new(
|
let stream = pw::stream::StreamBox::new(
|
||||||
@@ -2399,91 +2157,36 @@ mod pipewire {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.process(|stream, ud| {
|
.process(|stream, ud| {
|
||||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its
|
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
|
||||||
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue
|
// panic crossing that FFI boundary would abort the whole host. Contain it.
|
||||||
// the older ones, keep only the newest. This dequeue/requeue runs OUTSIDE the
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
// `catch_unwind` below — they are non-panicking C FFI pointer ops, and `newest` is
|
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
|
||||||
// requeued exactly once AFTER the panic-containing region. Previously the whole thing was
|
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
|
||||||
// inside the catch, so a caught panic (in `update_cursor_meta`/`consume_frame`) stranded
|
// queued buffers, requeue the older ones, keep only the newest.
|
||||||
// `newest` forever, permanently shrinking the stream's fixed pool until capture wedged.
|
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
|
||||||
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on the
|
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
|
||||||
// loop thread; `dequeue_raw_buffer` returns a stream-owned `*mut pw_buffer` or null
|
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
|
||||||
// (null-checked), single-threaded so no concurrent access.
|
// null-checked before any use. The loop is single-threaded, so no concurrent access.
|
||||||
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
||||||
if newest.is_null() {
|
if newest.is_null() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut drained = 1u32;
|
let mut drained = 1u32;
|
||||||
loop {
|
loop {
|
||||||
// SAFETY: same stream/loop-thread contract; returns the next stream-owned buffer or null.
|
// SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
|
||||||
|
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
|
||||||
let next = unsafe { stream.dequeue_raw_buffer() };
|
let next = unsafe { stream.dequeue_raw_buffer() };
|
||||||
if next.is_null() {
|
if next.is_null() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// SAFETY: `newest` was dequeued from this stream and not yet requeued; we immediately
|
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
|
||||||
// overwrite it, so the requeued pointer is never touched again.
|
// stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
|
||||||
|
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
|
||||||
|
// touched again (no use-after-requeue). Loop thread, single-threaded.
|
||||||
unsafe { stream.queue_raw_buffer(newest) };
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
newest = next;
|
newest = next;
|
||||||
drained += 1;
|
drained += 1;
|
||||||
}
|
}
|
||||||
// Sacrificial-mode gate (kwin.rs `create`): until the producer renegotiates to the
|
|
||||||
// expected dims, every buffer — frame AND cursor meta, whose positions are in the
|
|
||||||
// doomed mode's space — belongs to the birth mode; consuming one would build the
|
|
||||||
// pipeline at the wrong size. Self-disarms on the first matching negotiation, or
|
|
||||||
// after `GATE_DEADLINE` without one — degraded dims beat wedging the session into
|
|
||||||
// the first-frame-timeout retry loop when the promised renegotiation never comes.
|
|
||||||
if let Some((ew, eh)) = ud.expect_dims {
|
|
||||||
/// The renegotiation normally lands within a frame or two of recording; well
|
|
||||||
/// past that, the producer is not going to deliver it (the on-glass case: the
|
|
||||||
/// real mode never actually applied) — stop starving the pipeline.
|
|
||||||
const GATE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(3);
|
|
||||||
let sz = ud.info.size();
|
|
||||||
if sz.width == ew && sz.height == eh {
|
|
||||||
tracing::info!(
|
|
||||||
skipped = ud.gate_skips,
|
|
||||||
width = ew,
|
|
||||||
height = eh,
|
|
||||||
"producer renegotiated to the expected mode — frames flow"
|
|
||||||
);
|
|
||||||
ud.expect_dims = None;
|
|
||||||
} else if ud
|
|
||||||
.gate_since
|
|
||||||
.get_or_insert_with(std::time::Instant::now)
|
|
||||||
.elapsed()
|
|
||||||
> GATE_DEADLINE
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
|
||||||
negotiated_w = sz.width,
|
|
||||||
negotiated_h = sz.height,
|
|
||||||
expected_w = ew,
|
|
||||||
expected_h = eh,
|
|
||||||
skipped = ud.gate_skips,
|
|
||||||
"producer never renegotiated to the expected mode — accepting its \
|
|
||||||
dims (session runs degraded rather than wedged)"
|
|
||||||
);
|
|
||||||
ud.expect_dims = None;
|
|
||||||
} else {
|
|
||||||
ud.gate_skips += 1;
|
|
||||||
if ud.gate_skips == 1 || ud.gate_skips.is_power_of_two() {
|
|
||||||
tracing::info!(
|
|
||||||
negotiated_w = sz.width,
|
|
||||||
negotiated_h = sz.height,
|
|
||||||
expected_w = ew,
|
|
||||||
expected_h = eh,
|
|
||||||
n = ud.gate_skips,
|
|
||||||
"holding frames until the producer renegotiates to the expected mode"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// SAFETY: `newest` was dequeued from this stream and not yet requeued;
|
|
||||||
// requeued exactly once here, then never touched (mirrors the null path).
|
|
||||||
unsafe { stream.queue_raw_buffer(newest) };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// PipeWire dispatches from a C trampoline with no catch_unwind; a panic crossing that FFI
|
|
||||||
// boundary would abort the whole host. Contain the inspect/consume work — the only Rust
|
|
||||||
// code here that can panic — and requeue `newest` unconditionally after it.
|
|
||||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
||||||
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
|
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
|
||||||
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
|
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
|
||||||
// load through a valid pointer — no mutation or aliasing.
|
// load through a valid pointer — no mutation or aliasing.
|
||||||
@@ -2493,19 +2196,6 @@ mod pipewire {
|
|||||||
// pointer-only movements as metadata-only "corrupted" buffers we drop for their
|
// pointer-only movements as metadata-only "corrupted" buffers we drop for their
|
||||||
// frame, but their cursor meta is fresh and must still move our overlay.
|
// frame, but their cursor meta is fresh and must still move our overlay.
|
||||||
update_cursor_meta(&mut ud.cursor, spa_buf);
|
update_cursor_meta(&mut ud.cursor, spa_buf);
|
||||||
// Publish the LIVE overlay (frames or not) so the encode loop's forwarder
|
|
||||||
// tracks pointer-only motion on a static desktop — the frame-attached overlay
|
|
||||||
// alone stales between damage frames. ONLY when we actually have one: a
|
|
||||||
// gamescope node carries no `SPA_META_Cursor`, so `overlay()` is always `None`
|
|
||||||
// here, and writing that would clobber — at frame rate — the `Some` the
|
|
||||||
// attached XFixes source publishes into this SAME slot, strobing the
|
|
||||||
// composited pointer on/off. Portal cursors are `None` only before the first
|
|
||||||
// bitmap (nothing to drop), and a HIDDEN pointer is still `Some(visible:false)`.
|
|
||||||
if let Some(overlay) = ud.cursor.overlay() {
|
|
||||||
if let Ok(mut slot) = ud.cursor_live.lock() {
|
|
||||||
*slot = Some(overlay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
||||||
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
|
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
|
||||||
@@ -2575,18 +2265,19 @@ mod pipewire {
|
|||||||
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Skip this stale/cursor buffer — `newest` is requeued unconditionally below.
|
// SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
|
||||||
|
// skip path); hand it back to the stream exactly once and return without touching it
|
||||||
|
// again. Loop thread inside `.process`.
|
||||||
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consume_frame(ud, spa_buf);
|
consume_frame(ud, spa_buf);
|
||||||
}));
|
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
|
||||||
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
|
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
|
||||||
// or a caught panic in the closure above. This single requeue is what keeps the fixed
|
// use-after-requeue. Loop thread inside `.process`.
|
||||||
// buffer pool from draining.
|
|
||||||
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
|
|
||||||
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
|
|
||||||
unsafe { stream.queue_raw_buffer(newest) };
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
|
}));
|
||||||
if outcome.is_err() {
|
if outcome.is_err() {
|
||||||
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
|
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
|
||||||
// format) would fire this every frame, so power-of-two throttle it — enough to
|
// format) would fire this every frame, so power-of-two throttle it — enough to
|
||||||
@@ -2670,18 +2361,7 @@ mod pipewire {
|
|||||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
|
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
|
||||||
]
|
]
|
||||||
} else if want_dmabuf {
|
} else if want_dmabuf {
|
||||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
|
vec![build_dmabuf_format(&modifiers, preferred)?]
|
||||||
if prefer_native_nv12 {
|
|
||||||
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
|
|
||||||
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
|
|
||||||
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
|
|
||||||
}
|
|
||||||
pods.push(build_dmabuf_format(
|
|
||||||
VideoFormat::BGRx,
|
|
||||||
&modifiers,
|
|
||||||
preferred,
|
|
||||||
)?);
|
|
||||||
pods
|
|
||||||
} else {
|
} else {
|
||||||
vec![serialize_pod(obj)?]
|
vec![serialize_pod(obj)?]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,366 +0,0 @@
|
|||||||
//! XFixes cursor source for the gamescope capture path (remote-desktop-sweep Phase C).
|
|
||||||
//!
|
|
||||||
//! gamescope draws the pointer on a DRM hardware-cursor plane and its `paint_pipewire()`
|
|
||||||
//! deliberately excludes the cursor from the frame it feeds its built-in PipeWire node — so
|
|
||||||
//! `SPA_META_Cursor` never arrives and the ordinary [`cursor_live`](super::PortalCapturer) slot
|
|
||||||
//! stays empty (a KWin/GNOME session gets its cursor from that meta; gamescope can't embed one
|
|
||||||
//! either, its `set_hw_cursor` is inert). We instead read the pointer from gamescope's nested
|
|
||||||
//! Xwayland via X11 — the trick Sunshine uses — and publish a [`CursorOverlay`] into that same
|
|
||||||
//! slot, so the encoder blend composites the pointer into the video exactly like the portal path.
|
|
||||||
//!
|
|
||||||
//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode
|
|
||||||
//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an
|
|
||||||
//! inactive display's pointer is frozen. So the source connects to ALL of them and each tick
|
|
||||||
//! follows the one whose pointer actually moved (gamescope routes input to the focused surface, so
|
|
||||||
//! exactly one moves at a time). It reads that display's shape too, since each Xwayland has its own
|
|
||||||
//! current cursor. This is why a single-display read froze the pointer the moment a game on the
|
|
||||||
//! OTHER Xwayland took focus.
|
|
||||||
//!
|
|
||||||
//! Two X sources per display, split by cost (Sunshine's split):
|
|
||||||
//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no
|
|
||||||
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth. It also
|
|
||||||
//! doubles as the focus signal (the display whose pointer moves is the active one).
|
|
||||||
//! * **Shape / hotspot / visibility** — `XFixesGetCursorImage`, refreshed only after an XFixes
|
|
||||||
//! `CursorNotify` (a real cursor change). A game hiding the pointer IS a cursor change → the
|
|
||||||
//! image comes back fully transparent → `visible: false`, which the encode loop strips before
|
|
||||||
//! any blend path draws it (so a grabbed pointer shows nothing, matching native gamescope).
|
|
||||||
|
|
||||||
use std::sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc, Mutex,
|
|
||||||
};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use pf_frame::CursorOverlay;
|
|
||||||
use x11rb::connection::Connection;
|
|
||||||
use x11rb::errors::ReplyError;
|
|
||||||
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
|
|
||||||
use x11rb::protocol::xproto::{ConnectionExt as _, QueryPointerReply, Window};
|
|
||||||
use x11rb::rust_connection::RustConnection;
|
|
||||||
|
|
||||||
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
|
||||||
/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds.
|
|
||||||
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
|
|
||||||
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
|
|
||||||
/// and must out-run a 240 fps session or the pointer stutters.
|
|
||||||
const POLL: Duration = Duration::from_millis(4);
|
|
||||||
|
|
||||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
|
||||||
/// X connections — so it lives exactly as long as the capturer that owns it.
|
|
||||||
pub(super) struct XFixesCursorSource {
|
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
join: Option<std::thread::JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl XFixesCursorSource {
|
|
||||||
/// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start
|
|
||||||
/// publishing cursor overlays into `slot`, following the focused display's pointer. Returns
|
|
||||||
/// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller
|
|
||||||
/// degrades to no gamescope cursor (today's behaviour) instead of failing the session.
|
|
||||||
pub(super) fn spawn(
|
|
||||||
targets: Vec<(String, Option<String>)>,
|
|
||||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
|
||||||
) -> Option<Self> {
|
|
||||||
// Connect on the caller's thread so failures degrade cleanly and the displays are validated
|
|
||||||
// before we commit a thread.
|
|
||||||
let mut displays = Vec::new();
|
|
||||||
for (dpy, xauth) in targets {
|
|
||||||
match connect(&dpy, xauth.as_deref()) {
|
|
||||||
Ok((conn, root)) => displays.push(XDisplay::new(dpy, conn, root)),
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
dpy = %dpy,
|
|
||||||
error = %e,
|
|
||||||
"gamescope cursor: skipping a nested Xwayland we can't use"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if displays.is_empty() {
|
|
||||||
tracing::warn!(
|
|
||||||
"gamescope cursor: no usable nested Xwayland — no in-video pointer this session \
|
|
||||||
(falls back to today's cursorless gamescope stream)"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
|
||||||
tracing::info!(
|
|
||||||
displays = ?names,
|
|
||||||
"gamescope cursor: XFixes source live — following the focused Xwayland's pointer"
|
|
||||||
);
|
|
||||||
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
|
||||||
let stop_worker = Arc::clone(&stop);
|
|
||||||
let join = std::thread::Builder::new()
|
|
||||||
.name("pf-gs-cursor".into())
|
|
||||||
.spawn(move || run(displays, slot, stop_worker))
|
|
||||||
.ok()?;
|
|
||||||
Some(XFixesCursorSource {
|
|
||||||
stop,
|
|
||||||
join: Some(join),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for XFixesCursorSource {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
|
||||||
if let Some(j) = self.join.take() {
|
|
||||||
let _ = j.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the X connection, negotiate XFixes, and select cursor-change events — returning the
|
|
||||||
/// connection + root window. `RustConnection` reads `XAUTHORITY` from the env at connect time only,
|
|
||||||
/// so set it under the lock (the host isn't a gamescope child), connect, then restore.
|
|
||||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Window), String> {
|
|
||||||
let (conn, screen_num) = {
|
|
||||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let prev = std::env::var_os("XAUTHORITY");
|
|
||||||
if let Some(x) = xauthority {
|
|
||||||
std::env::set_var("XAUTHORITY", x);
|
|
||||||
}
|
|
||||||
let out = RustConnection::connect(Some(dpy));
|
|
||||||
match (&prev, xauthority) {
|
|
||||||
(Some(p), _) => std::env::set_var("XAUTHORITY", p),
|
|
||||||
(None, Some(_)) => std::env::remove_var("XAUTHORITY"),
|
|
||||||
(None, None) => {}
|
|
||||||
}
|
|
||||||
out.map_err(|e| format!("connect: {e}"))?
|
|
||||||
};
|
|
||||||
|
|
||||||
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
|
|
||||||
conn.xfixes_query_version(5, 0)
|
|
||||||
.map_err(ReplyError::from)
|
|
||||||
.and_then(|c| c.reply())
|
|
||||||
.map_err(|e| format!("XFixes unavailable: {e}"))?;
|
|
||||||
|
|
||||||
let root = conn
|
|
||||||
.setup()
|
|
||||||
.roots
|
|
||||||
.get(screen_num)
|
|
||||||
.ok_or_else(|| format!("no X screen {screen_num}"))?
|
|
||||||
.root;
|
|
||||||
|
|
||||||
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
|
|
||||||
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
|
|
||||||
.map_err(ReplyError::from)
|
|
||||||
.and_then(|c| c.check())
|
|
||||||
.map_err(|e| format!("SelectCursorInput: {e}"))?;
|
|
||||||
let _ = conn.flush();
|
|
||||||
Ok((conn, root))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One gamescope Xwayland the source tracks.
|
|
||||||
struct XDisplay {
|
|
||||||
name: String,
|
|
||||||
conn: RustConnection,
|
|
||||||
root: Window,
|
|
||||||
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
|
|
||||||
last_pos: Option<(i32, i32)>,
|
|
||||||
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
|
|
||||||
shape: Shape,
|
|
||||||
/// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active.
|
|
||||||
need_shape: bool,
|
|
||||||
/// The X connection died (game/Xwayland exited) — skip it.
|
|
||||||
dead: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl XDisplay {
|
|
||||||
fn new(name: String, conn: RustConnection, root: Window) -> Self {
|
|
||||||
XDisplay {
|
|
||||||
name,
|
|
||||||
conn,
|
|
||||||
root,
|
|
||||||
last_pos: None,
|
|
||||||
shape: Shape::default(),
|
|
||||||
need_shape: true,
|
|
||||||
dead: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached cursor shape for one display.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Shape {
|
|
||||||
/// Straight-alpha RGBA (`w*h*4`, bytes R,G,B,A); empty before the first image arrives.
|
|
||||||
rgba: Arc<Vec<u8>>,
|
|
||||||
w: u32,
|
|
||||||
h: u32,
|
|
||||||
hot_x: u32,
|
|
||||||
hot_y: u32,
|
|
||||||
/// XFixes' own per-display cursor serial — bumps on every shape change.
|
|
||||||
serial: u64,
|
|
||||||
/// A hidden pointer arrives as an all-transparent image; kept so a position-only tick preserves
|
|
||||||
/// the last known visibility.
|
|
||||||
visible: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(
|
|
||||||
mut displays: Vec<XDisplay>,
|
|
||||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
) {
|
|
||||||
let mut active = 0usize;
|
|
||||||
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
|
|
||||||
// shape OR which display is active (per-display XFixes serials aren't comparable across
|
|
||||||
// displays, so switching could reuse a number and the encoder would keep the old texture).
|
|
||||||
let mut out_serial = 0u64;
|
|
||||||
let mut last_key = (usize::MAX, u64::MAX);
|
|
||||||
let mut warned_image = false;
|
|
||||||
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
|
||||||
// 1) Poll every display's pointer; note which moved since last tick (the focus signal).
|
|
||||||
let mut active_moved = false;
|
|
||||||
let mut other_moved: Option<usize> = None;
|
|
||||||
for (i, d) in displays.iter_mut().enumerate() {
|
|
||||||
if d.dead {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Drain pending events; the only ones selected are CursorNotify, so ANY event means
|
|
||||||
// "re-read this display's shape". poll_for_event never blocks.
|
|
||||||
loop {
|
|
||||||
match d.conn.poll_for_event() {
|
|
||||||
Ok(Some(_)) => d.need_shape = true,
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(_) => {
|
|
||||||
d.dead = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match fetch_pointer(&d.conn, d.root) {
|
|
||||||
Ok(p) if p.same_screen => {
|
|
||||||
let pos = (i32::from(p.root_x), i32::from(p.root_y));
|
|
||||||
let moved = d.last_pos.is_some_and(|lp| lp != pos);
|
|
||||||
d.last_pos = Some(pos);
|
|
||||||
if moved {
|
|
||||||
if i == active {
|
|
||||||
active_moved = true;
|
|
||||||
} else if other_moved.is_none() {
|
|
||||||
other_moved = Some(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(_) => {} // pointer on another screen — keep the last position.
|
|
||||||
Err(_) => d.dead = true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Switch focus: sticky to the active display while it moves (no flapping); otherwise
|
|
||||||
// follow another display that moved. If the active one died, fall to any live display.
|
|
||||||
if !active_moved {
|
|
||||||
if let Some(j) = other_moved {
|
|
||||||
active = j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if displays.get(active).is_none_or(|d| d.dead) {
|
|
||||||
match displays.iter().position(|d| !d.dead) {
|
|
||||||
Some(k) => active = k,
|
|
||||||
None => {
|
|
||||||
std::thread::sleep(POLL); // all connections dead — idle until Drop.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Fetch the active display's shape if a CursorNotify (or a focus switch) left it stale.
|
|
||||||
if displays[active].need_shape {
|
|
||||||
match fetch_cursor_image(&displays[active].conn) {
|
|
||||||
Ok(img) => {
|
|
||||||
update_shape(&mut displays[active].shape, &img);
|
|
||||||
displays[active].need_shape = false;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if !warned_image {
|
|
||||||
warned_image = true;
|
|
||||||
tracing::warn!(error = %e, "gamescope cursor: GetCursorImage failed — retrying");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor
|
|
||||||
// of its own — so a focus switch never leaves the other display's stale pointer showing).
|
|
||||||
let d = &displays[active];
|
|
||||||
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
|
||||||
(Some((px, py)), false) => {
|
|
||||||
let key = (active, d.shape.serial);
|
|
||||||
if key != last_key {
|
|
||||||
out_serial += 1;
|
|
||||||
last_key = key;
|
|
||||||
}
|
|
||||||
Some(CursorOverlay {
|
|
||||||
// Top-left = pointer position − hotspot (the overlay contract).
|
|
||||||
x: px - d.shape.hot_x as i32,
|
|
||||||
y: py - d.shape.hot_y as i32,
|
|
||||||
w: d.shape.w,
|
|
||||||
h: d.shape.h,
|
|
||||||
rgba: Arc::clone(&d.shape.rgba),
|
|
||||||
serial: out_serial,
|
|
||||||
hot_x: d.shape.hot_x,
|
|
||||||
hot_y: d.shape.hot_y,
|
|
||||||
visible: d.shape.visible,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if let Ok(mut s) = slot.lock() {
|
|
||||||
*s = overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::thread::sleep(POLL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the
|
|
||||||
/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows.
|
|
||||||
fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) {
|
|
||||||
let visible =
|
|
||||||
img.width > 0 && img.height > 0 && img.cursor_image.iter().any(|&p| (p >> 24) & 0xff != 0);
|
|
||||||
if visible {
|
|
||||||
shape.rgba = Arc::new(argb_premul_to_straight_rgba(&img.cursor_image));
|
|
||||||
shape.w = u32::from(img.width);
|
|
||||||
shape.h = u32::from(img.height);
|
|
||||||
shape.hot_x = u32::from(img.xhot);
|
|
||||||
shape.hot_y = u32::from(img.yhot);
|
|
||||||
}
|
|
||||||
shape.visible = visible;
|
|
||||||
shape.serial = u64::from(img.cursor_serial);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One request+reply — x11rb splits errors (the request is `ConnectionError`, `reply()` is
|
|
||||||
/// `ReplyError` which is `From<ConnectionError>`), so the request `?` converts into the reply error.
|
|
||||||
fn fetch_cursor_image(conn: &RustConnection) -> Result<GetCursorImageReply, ReplyError> {
|
|
||||||
conn.xfixes_get_cursor_image()?.reply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_pointer(conn: &RustConnection, root: Window) -> Result<QueryPointerReply, ReplyError> {
|
|
||||||
conn.query_pointer(root)?.reply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// XFixes cursor pixels are packed `0xAARRGGBB` with **premultiplied** alpha (the Xrender / Xcursor
|
|
||||||
/// convention). The overlay + both blend paths want **straight** alpha RGBA (R,G,B,A bytes), like
|
|
||||||
/// the `SPA_META_Cursor` path — so un-premultiply here. (If on-glass shows over-bright fringes the
|
|
||||||
/// source wasn't premultiplied after all; drop the divide.)
|
|
||||||
fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(argb.len() * 4);
|
|
||||||
for &px in argb {
|
|
||||||
let a = (px >> 24) & 0xff;
|
|
||||||
let r = (px >> 16) & 0xff;
|
|
||||||
let g = (px >> 8) & 0xff;
|
|
||||||
let b = px & 0xff;
|
|
||||||
let (r, g, b) = match a {
|
|
||||||
0 => (0, 0, 0),
|
|
||||||
255 => (r, g, b),
|
|
||||||
a => (
|
|
||||||
((r * 255 + a / 2) / a).min(255),
|
|
||||||
((g * 255 + a / 2) / a).min(255),
|
|
||||||
((b * 255 + a / 2) / a).min(255),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
out.extend_from_slice(&[r as u8, g as u8, b as u8, a as u8]);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
@@ -161,7 +161,7 @@ pub fn install_gpu_pref_hook() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> Result<Vec<u8>> {
|
unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> Result<Vec<u8>> {
|
||||||
let mut blob: Option<ID3DBlob> = None;
|
let mut blob: Option<ID3DBlob> = None;
|
||||||
let mut errs: Option<ID3DBlob> = None;
|
let mut errs: Option<ID3DBlob> = None;
|
||||||
let r = D3DCompile(
|
let r = D3DCompile(
|
||||||
@@ -194,7 +194,7 @@ pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> R
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fullscreen-triangle vertex shader for the HDR conversion pass (3 verts, no input layout).
|
/// Fullscreen-triangle vertex shader for the HDR conversion pass (3 verts, no input layout).
|
||||||
pub(crate) const HDR_VS: &str = r"
|
const HDR_VS: &str = r"
|
||||||
struct VOut { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; };
|
struct VOut { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; };
|
||||||
VOut main(uint vid : SV_VertexID) {
|
VOut main(uint vid : SV_VertexID) {
|
||||||
float2 uv = float2((vid << 1) & 2, vid & 2);
|
float2 uv = float2((vid << 1) & 2, vid & 2);
|
||||||
@@ -308,9 +308,8 @@ float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
|||||||
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
|
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
|
||||||
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
|
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
|
||||||
/// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`]
|
/// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`]
|
||||||
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format. (There is no runtime
|
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format so the caller can fall
|
||||||
/// fallback — the error propagates through `try_consume` and ends the session; the "R10 path" the
|
/// back to the existing R10 path.
|
||||||
/// original design referenced was never kept.)
|
|
||||||
pub(crate) struct HdrP010Converter {
|
pub(crate) struct HdrP010Converter {
|
||||||
vs: ID3D11VertexShader,
|
vs: ID3D11VertexShader,
|
||||||
ps_y: ID3D11PixelShader,
|
ps_y: ID3D11PixelShader,
|
||||||
@@ -738,157 +737,14 @@ fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
|||||||
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn hdr_p010_selftest() -> Result<()> {
|
pub fn hdr_p010_selftest() -> Result<()> {
|
||||||
hdr_p010_selftest_at(64, 64, None)
|
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
|
||||||
}
|
use windows::Win32::Graphics::Dxgi::IDXGIAdapter;
|
||||||
|
|
||||||
/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor
|
// 64x64, even dims. A 4x4 grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform →
|
||||||
/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on
|
// exact chroma comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus
|
||||||
/// top of the 64×64 default because the field sessions run at capture resolutions whose height
|
// a couple of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||||
/// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar
|
const W: u32 = 64;
|
||||||
/// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default
|
const H: u32 = 64;
|
||||||
/// adapter is not the one the session encodes on.
|
|
||||||
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
|
||||||
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
|
||||||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
|
||||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
|
||||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
|
||||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
|
||||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
|
||||||
/// (325,448,598) (226,650,535) (64,512,512).
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub fn hdr_p010_convert_bars_on_luid(
|
|
||||||
luid: [u8; 8],
|
|
||||||
w: u32,
|
|
||||||
h: u32,
|
|
||||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
|
||||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
|
||||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
|
||||||
|
|
||||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
|
||||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
|
||||||
}
|
|
||||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
|
||||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
|
||||||
const BARS: [(f32, f32, f32); 8] = [
|
|
||||||
(1.0, 1.0, 1.0),
|
|
||||||
(1.0, 1.0, 0.0),
|
|
||||||
(0.0, 1.0, 1.0),
|
|
||||||
(0.0, 1.0, 0.0),
|
|
||||||
(1.0, 0.0, 1.0),
|
|
||||||
(1.0, 0.0, 0.0),
|
|
||||||
(0.0, 0.0, 1.0),
|
|
||||||
(0.0, 0.0, 0.0),
|
|
||||||
];
|
|
||||||
let bar_w = (w / 8).max(1) as usize;
|
|
||||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
|
||||||
for y in 0..h as usize {
|
|
||||||
for x in 0..w as usize {
|
|
||||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
|
||||||
let i = (y * w as usize + x) * 4;
|
|
||||||
fp16[i] = f32_to_f16(r);
|
|
||||||
fp16[i + 1] = f32_to_f16(g);
|
|
||||||
fp16[i + 2] = f32_to_f16(b);
|
|
||||||
fp16[i + 3] = f32_to_f16(1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
|
||||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
|
||||||
// their references.
|
|
||||||
unsafe {
|
|
||||||
let luid = windows::Win32::Foundation::LUID {
|
|
||||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
|
||||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
|
||||||
};
|
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
|
||||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
|
||||||
let mut device: Option<ID3D11Device> = None;
|
|
||||||
let mut context: Option<ID3D11DeviceContext> = None;
|
|
||||||
D3D11CreateDevice(
|
|
||||||
&adapter,
|
|
||||||
D3D_DRIVER_TYPE_UNKNOWN,
|
|
||||||
HMODULE::default(),
|
|
||||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
|
||||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
|
||||||
D3D11_SDK_VERSION,
|
|
||||||
Some(&mut device),
|
|
||||||
None,
|
|
||||||
Some(&mut context),
|
|
||||||
)
|
|
||||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
|
||||||
let device = device.context("null device")?;
|
|
||||||
let context = context.context("null context")?;
|
|
||||||
|
|
||||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: w,
|
|
||||||
Height: h,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let init = D3D11_SUBRESOURCE_DATA {
|
|
||||||
pSysMem: fp16.as_ptr() as *const c_void,
|
|
||||||
SysMemPitch: w * 8,
|
|
||||||
SysMemSlicePitch: 0,
|
|
||||||
};
|
|
||||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
|
||||||
.context("CreateTexture2D(fp16 bars)")?;
|
|
||||||
let src_tex = src_tex.context("null src tex")?;
|
|
||||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
|
||||||
device
|
|
||||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
|
||||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
|
||||||
let src_srv = src_srv.context("null src srv")?;
|
|
||||||
|
|
||||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
|
||||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: w,
|
|
||||||
Height: h,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_P010,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut p010: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
|
||||||
.context("CreateTexture2D(P010 bars dst)")?;
|
|
||||||
let p010 = p010.context("null p010 tex")?;
|
|
||||||
|
|
||||||
let conv = HdrP010Converter::new(&device)?;
|
|
||||||
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
|
|
||||||
Ok((device, p010))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
|
||||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
|
||||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
|
||||||
|
|
||||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
|
||||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
|
||||||
}
|
|
||||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
|
||||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
|
||||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
let (W, H) = (w, h);
|
|
||||||
const BLK: u32 = 16;
|
const BLK: u32 = 16;
|
||||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||||
let named: [(&str, f32, f32, f32); 8] = [
|
let named: [(&str, f32, f32, f32); 8] = [
|
||||||
@@ -941,36 +797,12 @@ pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
|||||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||||
// proven individually at the `read_u16` closure below.
|
// proven individually at the `read_u16` closure below.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
|
||||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
|
||||||
// the GPU it actually tested.
|
|
||||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
|
||||||
None => None,
|
|
||||||
Some(want) => {
|
|
||||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
|
||||||
let mut found = None;
|
|
||||||
for i in 0.. {
|
|
||||||
let Ok(a) = factory.EnumAdapters(i) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let desc = a.GetDesc().context("adapter desc")?;
|
|
||||||
if desc.VendorId == want {
|
|
||||||
found = Some(a);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut device: Option<ID3D11Device> = None;
|
let mut device: Option<ID3D11Device> = None;
|
||||||
let mut context: Option<ID3D11DeviceContext> = None;
|
let mut context: Option<ID3D11DeviceContext> = None;
|
||||||
D3D11CreateDevice(
|
D3D11CreateDevice(
|
||||||
adapter.as_ref(),
|
None::<&IDXGIAdapter>,
|
||||||
if adapter.is_some() {
|
D3D_DRIVER_TYPE_HARDWARE,
|
||||||
D3D_DRIVER_TYPE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
D3D_DRIVER_TYPE_HARDWARE
|
|
||||||
},
|
|
||||||
HMODULE::default(),
|
HMODULE::default(),
|
||||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||||
@@ -982,22 +814,6 @@ pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
|||||||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||||||
let device = device.context("null device")?;
|
let device = device.context("null device")?;
|
||||||
let context = context.context("null context")?;
|
let context = context.context("null context")?;
|
||||||
{
|
|
||||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
|
||||||
device.cast().context("device -> IDXGIDevice")?;
|
|
||||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
|
||||||
let name = String::from_utf16_lossy(
|
|
||||||
&desc.Description[..desc
|
|
||||||
.Description
|
|
||||||
.iter()
|
|
||||||
.position(|&c| c == 0)
|
|
||||||
.unwrap_or(desc.Description.len())],
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
|
||||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source FP16 texture (initialized) + SRV.
|
// Source FP16 texture (initialized) + SRV.
|
||||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||||
@@ -1359,16 +1175,3 @@ impl VideoConverter {
|
|||||||
blt.context("VideoProcessorBlt")
|
blt.context("VideoProcessorBlt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod hdr_selftests {
|
|
||||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
|
||||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
|
||||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
|
||||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
|
||||||
#[test]
|
|
||||||
#[ignore]
|
|
||||||
fn hdr_p010_selftest_intel_1080_live() {
|
|
||||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -365,12 +365,6 @@ pub unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) ->
|
|||||||
|
|
||||||
#[path = "idd_push/channel.rs"]
|
#[path = "idd_push/channel.rs"]
|
||||||
mod channel;
|
mod channel;
|
||||||
#[path = "idd_push/cursor.rs"]
|
|
||||||
mod cursor;
|
|
||||||
#[path = "idd_push/cursor_blend.rs"]
|
|
||||||
mod cursor_blend;
|
|
||||||
#[path = "idd_push/cursor_poll.rs"]
|
|
||||||
mod cursor_poll;
|
|
||||||
#[path = "idd_push/descriptor.rs"]
|
#[path = "idd_push/descriptor.rs"]
|
||||||
mod descriptor;
|
mod descriptor;
|
||||||
#[path = "idd_push/stall.rs"]
|
#[path = "idd_push/stall.rs"]
|
||||||
@@ -392,64 +386,6 @@ pub struct IddPushCapturer {
|
|||||||
/// The sealed channel's handle-duplication broker (WUDFHost process + control device); used at open
|
/// The sealed channel's handle-duplication broker (WUDFHost process + control device); used at open
|
||||||
/// and again on every ring recreate to deliver fresh duplicates.
|
/// and again on every ring recreate to deliver fresh duplicates.
|
||||||
broker: ChannelBroker,
|
broker: ChannelBroker,
|
||||||
/// The v5 hardware-cursor channel's host end (`Some` = delivered; the driver declared the
|
|
||||||
/// hardware cursor and seqlock-publishes into it). Survives ring recreates — the section is
|
|
||||||
/// independent of the frame ring's generation. With the channel delivered, the driver's
|
|
||||||
/// hardware cursor keeps DWM from compositing ANY cursor into the frame; the SHAPE now comes
|
|
||||||
/// from [`cursor_poll::CursorPoller`] (the IddCx query is alpha-only — see cursor_poll.rs),
|
|
||||||
/// and this shm read is the fallback if that poller dies.
|
|
||||||
cursor_shared: Option<cursor::CursorShared>,
|
|
||||||
/// The GDI cursor-shape poller (design §8): the overlay source while alive. `Some` when
|
|
||||||
/// `cursor_shared` is (both ride the negotiated cursor channel + successful delivery) — or
|
|
||||||
/// when `composite_forced` (no channel, but the target's sticky declare needs a blend source).
|
|
||||||
cursor_poll: Option<cursor_poll::CursorPoller>,
|
|
||||||
/// Retained delivery sender (`IOCTL_SET_CURSOR_CHANNEL`) for RE-delivery: a driver-side
|
|
||||||
/// monitor re-arrival (match-window re-arrival resize, a sibling session recreating the
|
|
||||||
/// shared slot) destroys the driver's cursor worker — the section here survives, so the
|
|
||||||
/// channel is re-delivered on ring recreates.
|
|
||||||
cursor_sender: Option<crate::CursorChannelSender>,
|
|
||||||
/// The cursor-render flip sender (`IOCTL_SET_CURSOR_FORWARD`) — the secure-desktop guard's
|
|
||||||
/// actuator. UAC/Winlogon render only through the OS's software-cursor path (its default on
|
|
||||||
/// every mode commit); with our hardware cursor declared (and re-declared on every
|
|
||||||
/// swap-chain assign) that path never comes back, and the secure desktop never presents —
|
|
||||||
/// the stream freezes on the last normal-desktop frame for the whole UAC/lock interaction.
|
|
||||||
/// [`Self::poll_secure_desktop`] flips the declare off/on at the secure-desktop edges.
|
|
||||||
cursor_forward: Option<crate::CursorForwardSender>,
|
|
||||||
/// The secure-desktop guard's edge state: `true` = the poller reports a secure input
|
|
||||||
/// desktop and the declare is currently stood down.
|
|
||||||
secure_active: bool,
|
|
||||||
/// The CAPTURE mouse model is active — the HOST composites the pointer into the frame
|
|
||||||
/// (see cursor_blend.rs for why DWM cannot: a declared IddCx hardware cursor is forever).
|
|
||||||
composite_cursor: bool,
|
|
||||||
/// This session never negotiated the cursor channel but its target carries an IRREVOCABLE
|
|
||||||
/// hardware-cursor declare from an earlier session (`WinCaptureTarget::cursor_excluded`,
|
|
||||||
/// §8.6): DWM delivers pointer-free frames and no client draws the cursor, so the ONLY path
|
|
||||||
/// to a visible pointer is compositing here. Pins `composite_cursor` on — nothing may turn
|
|
||||||
/// it off (there is no channel to hand the pointer to).
|
|
||||||
composite_forced: bool,
|
|
||||||
/// The cursor-quad blend pass (lazy; per capture device). `None` after a build failure —
|
|
||||||
/// composite mode then degrades to pointer-less frames (warned once).
|
|
||||||
cursor_blend: Option<cursor_blend::CursorBlendPass>,
|
|
||||||
cursor_blend_failed: bool,
|
|
||||||
/// The frame-sized blend scratch (slot copy + cursor quad): texture + SRV + (w, h, fmt)
|
|
||||||
/// it was built for — rebuilt when the ring geometry changes.
|
|
||||||
blend_scratch: Option<(
|
|
||||||
ID3D11Texture2D,
|
|
||||||
ID3D11ShaderResourceView,
|
|
||||||
u32,
|
|
||||||
u32,
|
|
||||||
DXGI_FORMAT,
|
|
||||||
)>,
|
|
||||||
/// The (serial, x, y, visible) of the LAST blended pointer — the composite-regen change
|
|
||||||
/// key: pointer-only motion produces no driver publish (the declared hardware cursor
|
|
||||||
/// doesn't dirty frames), so `try_consume` regenerates from the last slot when this moves.
|
|
||||||
last_blend_key: Option<(u64, i32, i32, bool)>,
|
|
||||||
/// The ring slot of the last FRESH publish — the regen source.
|
|
||||||
last_slot: Option<usize>,
|
|
||||||
/// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on
|
|
||||||
/// each blend-scratch rebuild (first use + ring geometry changes). 2.5 ≈ the Windows
|
|
||||||
/// SDR-brightness default; without it the composited cursor renders visibly dark on HDR.
|
|
||||||
sdr_white_scale: f32,
|
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
slots: Vec<HostSlot>,
|
slots: Vec<HostSlot>,
|
||||||
@@ -675,22 +611,11 @@ impl IddPushCapturer {
|
|||||||
pyrowave: bool,
|
pyrowave: bool,
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
sender: crate::FrameChannelSender,
|
sender: crate::FrameChannelSender,
|
||||||
cursor_sender: Option<crate::CursorChannelSender>,
|
|
||||||
cursor_forward: Option<crate::CursorForwardSender>,
|
|
||||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||||
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||||
pf_win_display::display_events::spawn_once();
|
pf_win_display::display_events::spawn_once();
|
||||||
match Self::open_inner(
|
match Self::open_inner(target, preferred, client_10bit, want_444, pyrowave, sender) {
|
||||||
target,
|
|
||||||
preferred,
|
|
||||||
client_10bit,
|
|
||||||
want_444,
|
|
||||||
pyrowave,
|
|
||||||
sender,
|
|
||||||
cursor_sender,
|
|
||||||
cursor_forward,
|
|
||||||
) {
|
|
||||||
Ok(mut me) => {
|
Ok(mut me) => {
|
||||||
me._keepalive = keepalive;
|
me._keepalive = keepalive;
|
||||||
Ok(me)
|
Ok(me)
|
||||||
@@ -707,8 +632,6 @@ impl IddPushCapturer {
|
|||||||
want_444: bool,
|
want_444: bool,
|
||||||
pyrowave: bool,
|
pyrowave: bool,
|
||||||
sender: crate::FrameChannelSender,
|
sender: crate::FrameChannelSender,
|
||||||
cursor_sender: Option<crate::CursorChannelSender>,
|
|
||||||
cursor_forward: Option<crate::CursorForwardSender>,
|
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||||
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||||
@@ -731,8 +654,6 @@ impl IddPushCapturer {
|
|||||||
pyrowave,
|
pyrowave,
|
||||||
luid,
|
luid,
|
||||||
sender.clone(),
|
sender.clone(),
|
||||||
cursor_sender.clone(),
|
|
||||||
cursor_forward.clone(),
|
|
||||||
) {
|
) {
|
||||||
Ok(me) => Ok(me),
|
Ok(me) => Ok(me),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -766,8 +687,6 @@ impl IddPushCapturer {
|
|||||||
pyrowave,
|
pyrowave,
|
||||||
drv,
|
drv,
|
||||||
sender,
|
sender,
|
||||||
cursor_sender,
|
|
||||||
cursor_forward,
|
|
||||||
)
|
)
|
||||||
.context("IDD-push rebind to the driver's reported render adapter")
|
.context("IDD-push rebind to the driver's reported render adapter")
|
||||||
}
|
}
|
||||||
@@ -783,8 +702,6 @@ impl IddPushCapturer {
|
|||||||
pyrowave: bool,
|
pyrowave: bool,
|
||||||
luid: LUID,
|
luid: LUID,
|
||||||
sender: crate::FrameChannelSender,
|
sender: crate::FrameChannelSender,
|
||||||
cursor_sender: Option<crate::CursorChannelSender>,
|
|
||||||
cursor_forward: Option<crate::CursorForwardSender>,
|
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let (pw, ph, _hz) = preferred
|
let (pw, ph, _hz) = preferred
|
||||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||||
@@ -1018,61 +935,6 @@ impl IddPushCapturer {
|
|||||||
)
|
)
|
||||||
.context("deliver IDD-push frame channel to the driver")?;
|
.context("deliver IDD-push frame channel to the driver")?;
|
||||||
|
|
||||||
// v5 hardware-cursor channel (M2c): create + deliver the CursorShm section. Failure
|
|
||||||
// is NON-fatal — the driver never declares the hardware cursor without this delivery,
|
|
||||||
// so the session degrades to today's composited pointer (and the forwarder simply
|
|
||||||
// never sees a live overlay).
|
|
||||||
let cursor_shared = cursor_sender.as_ref().and_then(|send_cursor| {
|
|
||||||
match cursor::CursorShared::create(target.target_id) {
|
|
||||||
Ok(cs) => {
|
|
||||||
// Deliver via the shared helper (also used for RE-delivery after a
|
|
||||||
// driver-side monitor re-arrival destroyed the worker).
|
|
||||||
deliver_cursor_channel(&broker, target.target_id, &cs, send_cursor)
|
|
||||||
.then_some(cs)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"cursor section creation failed (composited cursor stays): {e:#}"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// No channel this session, but the target's sticky declare (an EARLIER session's —
|
|
||||||
// irrevocable, §8.6) keeps DWM's frames pointer-free with no client drawing either:
|
|
||||||
// the only visible pointer is the one composited here, so force composite mode on.
|
|
||||||
let composite_forced = target.cursor_excluded && cursor_sender.is_none();
|
|
||||||
if composite_forced {
|
|
||||||
tracing::info!(
|
|
||||||
target_id = target.target_id,
|
|
||||||
"target carries an irrevocable hardware-cursor declare from an earlier \
|
|
||||||
desktop-mode session and this session has no cursor channel — the host \
|
|
||||||
composites the pointer into frames (forced, for the session's life)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The GDI shape poller rides the SAME gate as the delivered channel: with the driver's
|
|
||||||
// hardware cursor keeping the frame cursor-free, the poller supplies the full-fidelity
|
|
||||||
// shape (masked/monochrome included — the IddCx query can't; see cursor_poll.rs).
|
|
||||||
// Forced-composite sessions need it too — it is their only shape/position source.
|
|
||||||
let cursor_poll = (cursor_shared.is_some() || composite_forced).then(|| {
|
|
||||||
// Safety of the CCD call: read-only QueryDisplayConfig over owned locals (same
|
|
||||||
// call CursorShared::create makes) — already inside open_on's unsafe region.
|
|
||||||
let rect = pf_win_display::win_display::source_desktop_rect(target.target_id)
|
|
||||||
.unwrap_or((0, 0, i32::MAX, i32::MAX));
|
|
||||||
cursor_poll::CursorPoller::spawn(target.target_id, rect)
|
|
||||||
});
|
|
||||||
// Heal the driver's persisted cursor-forward state: a session that died on the
|
|
||||||
// secure desktop (client drops at the lock screen — the common case) leaves the
|
|
||||||
// per-target desired state `false`, and the NEXT session's channel delivery would
|
|
||||||
// adopt UNdeclared (the exact cross-session composite trap of §8.6). A fresh
|
|
||||||
// session always starts declared; the secure-desktop guard re-disables if the
|
|
||||||
// secure desktop is (still) up, via its first `poll_secure_desktop` edge.
|
|
||||||
if let (Some(_), Some(fwd)) = (cursor_shared.as_ref(), cursor_forward.as_ref()) {
|
|
||||||
if let Err(e) = fwd(true) {
|
|
||||||
tracing::debug!("cursor-forward reset at open failed (pre-v6 driver?): {e:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id = target.target_id,
|
target_id = target.target_id,
|
||||||
wudf_pid = target.wudf_pid,
|
wudf_pid = target.wudf_pid,
|
||||||
@@ -1131,19 +993,6 @@ impl IddPushCapturer {
|
|||||||
last_seq: 0,
|
last_seq: 0,
|
||||||
last_present: None,
|
last_present: None,
|
||||||
status_logged: false,
|
status_logged: false,
|
||||||
cursor_shared,
|
|
||||||
cursor_poll,
|
|
||||||
cursor_sender,
|
|
||||||
cursor_forward,
|
|
||||||
secure_active: false,
|
|
||||||
composite_cursor: composite_forced,
|
|
||||||
composite_forced,
|
|
||||||
cursor_blend: None,
|
|
||||||
cursor_blend_failed: false,
|
|
||||||
blend_scratch: None,
|
|
||||||
last_blend_key: None,
|
|
||||||
last_slot: None,
|
|
||||||
sdr_white_scale: 1.0,
|
|
||||||
// Held from BEFORE the first-frame gate (the display must not idle off while we
|
// Held from BEFORE the first-frame gate (the display must not idle off while we
|
||||||
// wait for the first compose) until the capturer drops with the session.
|
// wait for the first compose) until the capturer drops with the session.
|
||||||
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
|
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
|
||||||
@@ -1488,24 +1337,10 @@ impl IddPushCapturer {
|
|||||||
"IDD push: frame-channel re-delivery failed after ring recreate"
|
"IDD push: frame-channel re-delivery failed after ring recreate"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Ring recreates ride display churn that can also have re-arrived the MONITOR driver-side
|
|
||||||
// (destroying its cursor worker with it) — re-deliver the surviving cursor section so the
|
|
||||||
// hardware-cursor declaration follows the CURRENT monitor generation.
|
|
||||||
if let (Some(cs), Some(send)) = (self.cursor_shared.as_ref(), self.cursor_sender.as_ref()) {
|
|
||||||
let _ = deliver_cursor_channel(&self.broker, self.target_id, cs, send);
|
|
||||||
}
|
|
||||||
self.blend_scratch = None; // ring geometry/format changed — rebuild at next blend
|
|
||||||
self.last_slot = None; // old-ring slot indices are meaningless now
|
|
||||||
self.last_seq = 0;
|
self.last_seq = 0;
|
||||||
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
||||||
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
||||||
self.hdr_p010_conv = None;
|
self.hdr_p010_conv = None;
|
||||||
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
|
|
||||||
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
|
|
||||||
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
|
|
||||||
// the freshly HDR-formatted pyro ring — every frame corrupted. `ensure_pyro_conv` only
|
|
||||||
// builds when None, so it must be reset here like its siblings.
|
|
||||||
self.pyro_conv = None;
|
|
||||||
self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode
|
self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode
|
||||||
self.pyro_last = None;
|
self.pyro_last = None;
|
||||||
self.out_idx = 0;
|
self.out_idx = 0;
|
||||||
@@ -1781,201 +1616,8 @@ impl IddPushCapturer {
|
|||||||
Ok(Some((self.pyro_fence_handle, value)))
|
Ok(Some((self.pyro_fence_handle, value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The (serial, x, y, visible) of the CURRENT polled cursor — the composite-regen change
|
|
||||||
/// key. `None` while the poller has no shape yet (or isn't running).
|
|
||||||
fn cursor_blend_key(&self) -> Option<(u64, i32, i32, bool)> {
|
|
||||||
self.cursor_poll
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.read())
|
|
||||||
.map(|o| (o.serial, o.x, o.y, o.visible))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Composite the pointer for this convert: ensure the frame-sized blend scratch, copy the
|
|
||||||
/// slot into it, and alpha-blend the GDI poller's shape at its polled position. Returns the
|
|
||||||
/// scratch (texture + SRV) the conversion should read INSTEAD of the slot; `None` degrades
|
|
||||||
/// to the pointer-less slot (scratch/pass creation failed — warned once). A hidden pointer
|
|
||||||
/// blends nothing (the plain copy is the correct frame).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// D3D11 calls on the owning capture/encode thread's device + immediate context, called
|
|
||||||
/// while holding the slot's keyed mutex (the copy reads the slot).
|
|
||||||
unsafe fn prepare_blend_scratch(
|
|
||||||
&mut self,
|
|
||||||
slot_tex: &ID3D11Texture2D,
|
|
||||||
) -> Option<(ID3D11Texture2D, ID3D11ShaderResourceView)> {
|
|
||||||
let fmt = self.ring_format();
|
|
||||||
// (Re)build the scratch at the current ring geometry.
|
|
||||||
let stale = self
|
|
||||||
.blend_scratch
|
|
||||||
.as_ref()
|
|
||||||
.is_none_or(|(_, _, w, h, f)| (*w, *h, *f) != (self.width, self.height, fmt));
|
|
||||||
if stale {
|
|
||||||
self.blend_scratch = None;
|
|
||||||
let desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: self.width,
|
|
||||||
Height: self.height,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: fmt,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut tex: Option<ID3D11Texture2D> = None;
|
|
||||||
let built = self
|
|
||||||
.device
|
|
||||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
|
||||||
.ok()
|
|
||||||
.and(tex)
|
|
||||||
.and_then(|t| {
|
|
||||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
|
||||||
self.device
|
|
||||||
.CreateShaderResourceView(&t, None, Some(&mut srv))
|
|
||||||
.ok()
|
|
||||||
.and(srv)
|
|
||||||
.map(|v| (t, v))
|
|
||||||
});
|
|
||||||
match built {
|
|
||||||
Some((t, v)) => {
|
|
||||||
self.blend_scratch = Some((t, v, self.width, self.height, fmt));
|
|
||||||
if self.display_hdr {
|
|
||||||
// Where DWM places SDR white on this HDR desktop — the composited
|
|
||||||
// cursor must match or it reads dark (~2.5x at the Windows default).
|
|
||||||
// Queried only here: scratch rebuilds are rare, and the CCD query
|
|
||||||
// contends on the display-config lock, which must stay OFF the
|
|
||||||
// per-frame path.
|
|
||||||
// Safety: read-only CCD query over owned locals (within unsafe fn).
|
|
||||||
let queried =
|
|
||||||
pf_win_display::win_display::sdr_white_level_scale(self.target_id);
|
|
||||||
self.sdr_white_scale = queried.unwrap_or(self.sdr_white_scale);
|
|
||||||
tracing::info!(
|
|
||||||
target_id = self.target_id,
|
|
||||||
queried = ?queried,
|
|
||||||
applied = self.sdr_white_scale,
|
|
||||||
"cursor composite: HDR SDR-white scale (1.0 = 80 nits; None = \
|
|
||||||
query failed — keeping the prior value)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if !self.cursor_blend_failed {
|
|
||||||
self.cursor_blend_failed = true;
|
|
||||||
tracing::warn!(
|
|
||||||
"cursor blend scratch creation failed — capture-model frames stay \
|
|
||||||
pointer-less this session"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let (tex, srv, ..) = self.blend_scratch.as_ref().expect("just ensured");
|
|
||||||
let (tex, srv) = (tex.clone(), srv.clone());
|
|
||||||
self.context.CopyResource(&tex, slot_tex);
|
|
||||||
// Blend the pointer (visible shapes only; hidden = the copy alone is the frame).
|
|
||||||
let overlay = self.cursor_poll.as_ref().and_then(|p| p.read());
|
|
||||||
self.last_blend_key = overlay.as_ref().map(|o| (o.serial, o.x, o.y, o.visible));
|
|
||||||
if let Some(ov) = overlay.filter(|o| o.visible) {
|
|
||||||
if self.cursor_blend.is_none() && !self.cursor_blend_failed {
|
|
||||||
match cursor_blend::CursorBlendPass::new(&self.device) {
|
|
||||||
Ok(p) => self.cursor_blend = Some(p),
|
|
||||||
Err(e) => {
|
|
||||||
self.cursor_blend_failed = true;
|
|
||||||
tracing::warn!(
|
|
||||||
"cursor blend pass build failed — capture-model frames stay \
|
|
||||||
pointer-less this session: {e:#}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(pass) = self.cursor_blend.as_mut() {
|
|
||||||
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
|
|
||||||
// scale it to the target's SDR white so it matches the desktop around it.
|
|
||||||
let scale = if self.display_hdr {
|
|
||||||
self.sdr_white_scale
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, scale) {
|
|
||||||
if !self.cursor_blend_failed {
|
|
||||||
self.cursor_blend_failed = true;
|
|
||||||
tracing::warn!("cursor blend draw failed — pointer-less frames: {e:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((tex, srv))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The secure-desktop guard (the 0.18.0 UAC/Winlogon regression). UAC consent and Winlogon
|
|
||||||
/// live on the SECURE desktop, which the OS renders through the software-cursor path — its
|
|
||||||
/// per-mode-commit default. With this session's IddCx hardware cursor declared (and
|
|
||||||
/// re-declared by the driver on every swap-chain assign), that path never materialises, the
|
|
||||||
/// secure desktop never presents into our swap-chain, and the stream freezes on the last
|
|
||||||
/// normal-desktop frame for the entire UAC/lock interaction. On the poller's secure edge:
|
|
||||||
/// stand the declare down (`SET_CURSOR_FORWARD` off — the driver stops its per-assign
|
|
||||||
/// re-declare — plus the host facade's forced same-mode re-commit that actualises the
|
|
||||||
/// software cursor); on dismissal, re-declare. Runs on the capture/encode thread every tick
|
|
||||||
/// (it must keep running while frames are stalled — that is exactly the state it exits).
|
|
||||||
fn poll_secure_desktop(&mut self) {
|
|
||||||
let Some(fwd) = self.cursor_forward.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
// Sessions with a declare possibly in play: the channel session that declared it, and
|
|
||||||
// the forced-composite session whose (reused) driver monitor may still run an earlier
|
|
||||||
// session's cursor worker. A plain session on a clean target has no poller — no guard.
|
|
||||||
if self.cursor_shared.is_none() && !self.composite_forced {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let secure = self
|
|
||||||
.cursor_poll
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|p| p.secure_desktop());
|
|
||||||
if secure == self.secure_active {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.secure_active = secure;
|
|
||||||
if secure {
|
|
||||||
tracing::info!(
|
|
||||||
target_id = self.target_id,
|
|
||||||
"secure desktop (UAC/Winlogon) active — standing the IddCx hardware-cursor \
|
|
||||||
declare down so the OS software-cursor path can render it"
|
|
||||||
);
|
|
||||||
if let Err(e) = fwd(false) {
|
|
||||||
tracing::warn!(
|
|
||||||
"secure-desktop cursor-forward stand-down failed (secure content may stay \
|
|
||||||
invisible this session): {e:#}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::info!(
|
|
||||||
target_id = self.target_id,
|
|
||||||
"secure desktop dismissed — restoring the cursor render model"
|
|
||||||
);
|
|
||||||
// Re-declare only for the session that RUNS the cursor channel; a forced-composite
|
|
||||||
// session never wanted the declare (leaving the driver's desired state off also
|
|
||||||
// stops a reused worker's per-assign re-declares for good — the next channel
|
|
||||||
// session's open-time reset re-arms it).
|
|
||||||
if self.cursor_shared.is_some() {
|
|
||||||
if let Err(e) = fwd(true) {
|
|
||||||
tracing::warn!(
|
|
||||||
"secure-desktop cursor-forward re-enable failed (client-drawn cursor \
|
|
||||||
may double with a composited one): {e:#}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
|
fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
|
||||||
self.log_driver_status_once();
|
self.log_driver_status_once();
|
||||||
// The secure-desktop guard first: while UAC/Winlogon is up there may be NO fresh frames
|
|
||||||
// at all — this edge is what brings them back.
|
|
||||||
self.poll_secure_desktop();
|
|
||||||
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
||||||
self.poll_display_hdr();
|
self.poll_display_hdr();
|
||||||
// Recover-or-drop (GB1): if a descriptor change triggered a recreate but no fresh frame has resumed
|
// Recover-or-drop (GB1): if a descriptor change triggered a recreate but no fresh frame has resumed
|
||||||
@@ -2034,26 +1676,10 @@ impl IddPushCapturer {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let seq = u64::from(tok.seq);
|
let seq = u64::from(tok.seq);
|
||||||
let mut slot = tok.slot as usize;
|
let slot = tok.slot as usize;
|
||||||
let fresh = seq != self.last_seq && slot < self.slots.len();
|
if seq == self.last_seq || slot >= self.slots.len() {
|
||||||
let mut regen = false;
|
|
||||||
if !fresh {
|
|
||||||
// Composite cursor model: pointer-only motion produces NO new publish (the declared
|
|
||||||
// hardware cursor never dirties the frame), so a static desktop would freeze the
|
|
||||||
// blended pointer. Regenerate from the LAST slot whenever the polled cursor state
|
|
||||||
// changed — the re-converted out-ring frame carries the pointer's new position.
|
|
||||||
let moved = self.composite_cursor
|
|
||||||
&& self.last_slot.is_some()
|
|
||||||
&& self.cursor_blend_key() != self.last_blend_key;
|
|
||||||
if !moved {
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
slot = self.last_slot.expect("checked above");
|
|
||||||
if slot >= self.slots.len() {
|
|
||||||
return Ok(None); // ring shrank across a recreate — wait for a fresh publish
|
|
||||||
}
|
|
||||||
regen = true;
|
|
||||||
}
|
|
||||||
// Build the ring + converter BEFORE acquiring the slot so nothing between Acquire and Release
|
// Build the ring + converter BEFORE acquiring the slot so nothing between Acquire and Release
|
||||||
// can `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
// can `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
||||||
// PyroWave uses its OWN two-plane ring (`pyro_ring`); everything else the single NV12/BGRA ring.
|
// PyroWave uses its OWN two-plane ring (`pyro_ring`); everything else the single NV12/BGRA ring.
|
||||||
@@ -2086,31 +1712,18 @@ impl IddPushCapturer {
|
|||||||
// Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the
|
// Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the
|
||||||
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
|
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
|
||||||
// the slot back immediately and the encode of the PREVIOUS frame overlaps this convert.
|
// the slot back immediately and the encode of the PREVIOUS frame overlaps this convert.
|
||||||
// Clone the slot's COM interfaces (an AddRef each) so the guard borrows LOCALS, leaving
|
|
||||||
// `self` free for the composite blend prep inside the lock.
|
|
||||||
let (slot_tex, slot_srv, slot_mutex) = {
|
|
||||||
let s = &self.slots[slot];
|
let s = &self.slots[slot];
|
||||||
(s.tex.clone(), s.srv.clone(), s.mutex.clone())
|
|
||||||
};
|
|
||||||
// Acquire the slot's keyed mutex via a RAII guard, scoped to JUST the convert/copy below so it
|
// Acquire the slot's keyed mutex via a RAII guard, scoped to JUST the convert/copy below so it
|
||||||
// releases at the same point as the old hand-written `ReleaseSync` (the driver gets the slot back
|
// releases at the same point as the old hand-written `ReleaseSync` (the driver gets the slot back
|
||||||
// immediately, NOT held across the rest of `try_consume`) — but now leak-proof on any early return.
|
// immediately, NOT held across the rest of `try_consume`) — but now leak-proof on any early return.
|
||||||
{
|
{
|
||||||
let Some(_lock) = KeyedMutexGuard::acquire(&slot_mutex, 0, 8) else {
|
let Some(_lock) = KeyedMutexGuard::acquire(&s.mutex, 0, 8) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
// SAFETY: convert on the owning (encode) thread's immediate context, holding the slot lock.
|
// SAFETY: convert on the owning (encode) thread's immediate context, holding the slot lock.
|
||||||
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
|
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
|
||||||
// the slot back to the driver.
|
// the slot back to the driver.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Composite cursor model: divert the convert input through the blend scratch —
|
|
||||||
// a slot copy with the pointer quad alpha-blended on top. `None` = compositing
|
|
||||||
// off or degraded (the conversion then reads the slot as always).
|
|
||||||
let blended = if self.composite_cursor {
|
|
||||||
self.prepare_blend_scratch(&slot_tex)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if self.pyrowave {
|
if self.pyrowave {
|
||||||
// PyroWave: ring slot SRV (BGRA for SDR, scRGB FP16 for HDR) → the two separate
|
// PyroWave: ring slot SRV (BGRA for SDR, scRGB FP16 for HDR) → the two separate
|
||||||
// plane textures via the mode-aware CSC; the shared fence signalled just after
|
// plane textures via the mode-aware CSC; the shared fence signalled just after
|
||||||
@@ -2118,17 +1731,22 @@ impl IddPushCapturer {
|
|||||||
// convert. The composition format is pinned to the negotiated depth.
|
// convert. The composition format is pinned to the negotiated depth.
|
||||||
let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot");
|
let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot");
|
||||||
if let Some(conv) = self.pyro_conv.as_ref() {
|
if let Some(conv) = self.pyro_conv.as_ref() {
|
||||||
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
|
conv.convert(
|
||||||
conv.convert(&self.context, src, y_rtv, cbcr_rtv, self.width, self.height)?;
|
&self.context,
|
||||||
|
&s.srv,
|
||||||
|
y_rtv,
|
||||||
|
cbcr_rtv,
|
||||||
|
self.width,
|
||||||
|
self.height,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
} else if self.display_hdr {
|
} else if self.display_hdr {
|
||||||
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
|
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
|
||||||
if let Some(conv) = self.hdr_p010_conv.as_ref() {
|
if let Some(conv) = self.hdr_p010_conv.as_ref() {
|
||||||
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
|
|
||||||
conv.convert(
|
conv.convert(
|
||||||
&self.device,
|
&self.device,
|
||||||
&self.context,
|
&self.context,
|
||||||
src,
|
&s.srv,
|
||||||
out.as_ref().expect("out ring"),
|
out.as_ref().expect("out ring"),
|
||||||
self.width,
|
self.width,
|
||||||
self.height,
|
self.height,
|
||||||
@@ -2138,14 +1756,12 @@ impl IddPushCapturer {
|
|||||||
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
|
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
|
||||||
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
|
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
|
||||||
// copy-engine move; the slot releases back to the driver immediately.
|
// copy-engine move; the slot releases back to the driver immediately.
|
||||||
let src = blended.as_ref().map(|(t, _)| t).unwrap_or(&slot_tex);
|
|
||||||
self.context
|
self.context
|
||||||
.CopyResource(out.as_ref().expect("out ring"), src);
|
.CopyResource(out.as_ref().expect("out ring"), &s.tex);
|
||||||
} else {
|
} else {
|
||||||
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
|
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
|
||||||
if let Some(conv) = self.video_conv.as_ref() {
|
if let Some(conv) = self.video_conv.as_ref() {
|
||||||
let src = blended.as_ref().map(|(t, _)| t).unwrap_or(&slot_tex);
|
conv.convert(&s.tex, out.as_ref().expect("out ring"))?;
|
||||||
conv.convert(src, out.as_ref().expect("out ring"))?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2153,20 +1769,13 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
self.out_idx = (i + 1) % ring_len;
|
self.out_idx = (i + 1) % ring_len;
|
||||||
self.last_seq = seq;
|
self.last_seq = seq;
|
||||||
if fresh {
|
|
||||||
self.last_slot = Some(slot);
|
|
||||||
}
|
|
||||||
if let Some((y, _, cbcr, _)) = pyro_slot.as_ref() {
|
if let Some((y, _, cbcr, _)) = pyro_slot.as_ref() {
|
||||||
self.pyro_last = Some((y.clone(), cbcr.clone()));
|
self.pyro_last = Some((y.clone(), cbcr.clone()));
|
||||||
} else {
|
} else {
|
||||||
self.last_present = Some((out.as_ref().expect("out ring").clone(), pf));
|
self.last_present = Some((out.as_ref().expect("out ring").clone(), pf));
|
||||||
}
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if regen {
|
if self.recovering_since.take().is_some() {
|
||||||
// A regen re-encodes OLD desktop content at a new pointer position — it is not a
|
|
||||||
// fresh driver frame; feeding the freshness/stall bookkeeping would mask a dead
|
|
||||||
// driver and pollute stall attribution.
|
|
||||||
} else if self.recovering_since.take().is_some() {
|
|
||||||
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
|
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
|
||||||
// recreate, already logged by the recreate path) — reset the stall watch so it
|
// recreate, already logged by the recreate path) — reset the stall watch so it
|
||||||
// doesn't read as a DWM stall.
|
// doesn't read as a DWM stall.
|
||||||
@@ -2238,9 +1847,7 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !regen {
|
|
||||||
self.last_fresh = now; // feeds the driver-death watch
|
self.last_fresh = now; // feeds the driver-death watch
|
||||||
}
|
|
||||||
// Build the frame. For PyroWave the encode input is the Y plane
|
// Build the frame. For PyroWave the encode input is the Y plane
|
||||||
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
|
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
|
||||||
// after the convert above. SAFETY: on the owning capture/encode thread.
|
// after the convert above. SAFETY: on the owning capture/encode thread.
|
||||||
@@ -2254,7 +1861,6 @@ impl IddPushCapturer {
|
|||||||
cbcr,
|
cbcr,
|
||||||
fence_handle,
|
fence_handle,
|
||||||
fence_value,
|
fence_value,
|
||||||
ring_gen: self.generation,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -2313,7 +1919,6 @@ impl IddPushCapturer {
|
|||||||
cbcr: dst_cbcr,
|
cbcr: dst_cbcr,
|
||||||
fence_handle,
|
fence_handle,
|
||||||
fence_value,
|
fence_value,
|
||||||
ring_gen: self.generation,
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
cursor: None,
|
cursor: None,
|
||||||
@@ -2368,82 +1973,7 @@ impl std::fmt::Display for AttachTexFail {
|
|||||||
|
|
||||||
impl std::error::Error for AttachTexFail {}
|
impl std::error::Error for AttachTexFail {}
|
||||||
|
|
||||||
/// Duplicate `cs`'s section into the driver's WUDFHost and send `IOCTL_SET_CURSOR_CHANNEL`.
|
|
||||||
/// `true` = the driver adopted it (worker declared per its `cursor_forward_on` state). Shared by
|
|
||||||
/// the open-time delivery and every RE-delivery (ring recreate / flip NOT_FOUND) — the request is
|
|
||||||
/// idempotent driver-side (a replaced worker is stopped + joined).
|
|
||||||
fn deliver_cursor_channel(
|
|
||||||
broker: &ChannelBroker,
|
|
||||||
target_id: u32,
|
|
||||||
cs: &cursor::CursorShared,
|
|
||||||
send_cursor: &crate::CursorChannelSender,
|
|
||||||
) -> bool {
|
|
||||||
// SAFETY: `cs.section_handle()` borrows the section mapping `cs` owns (live across this
|
|
||||||
// synchronous call); the broker's WUDFHost process handle is live for the broker's lifetime.
|
|
||||||
let value = match unsafe { broker.dup_into_public(cs.section_handle()) } {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("cursor section duplication failed (composited cursor stays): {e:#}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let req = pf_driver_proto::control::SetCursorChannelRequest {
|
|
||||||
target_id,
|
|
||||||
_pad: 0,
|
|
||||||
header_handle: value,
|
|
||||||
};
|
|
||||||
match send_cursor(&req) {
|
|
||||||
Ok(()) => {
|
|
||||||
tracing::info!(
|
|
||||||
target_id,
|
|
||||||
"IDD push(host): cursor channel delivered — driver declares the hardware cursor"
|
|
||||||
);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
broker.close_remote_public(value);
|
|
||||||
tracing::warn!("cursor channel delivery failed (composited cursor stays): {e:#}");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Capturer for IddPushCapturer {
|
impl Capturer for IddPushCapturer {
|
||||||
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
|
||||||
// A LIVE poller is the sole source — even while it still reports `None` (pre-first-shape):
|
|
||||||
// falling back to the shm mid-session would interleave two serial namespaces and poison
|
|
||||||
// the client's shape cache. The shm read only serves a poller that failed to start/died.
|
|
||||||
if let Some(p) = &self.cursor_poll {
|
|
||||||
if p.alive() {
|
|
||||||
return p.read();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.cursor_shared.as_mut().and_then(|c| c.read())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_cursor_forward(&mut self, on: bool) {
|
|
||||||
// The composite (capture) model is implemented HOST-side: the driver's hardware cursor
|
|
||||||
// stays declared for the session's whole life — the only dependable state (there is NO
|
|
||||||
// working un-declare; see cursor_blend.rs) — keeping every frame pointer-free, and the
|
|
||||||
// capturer blends the GDI poller's shape into the frame itself. No driver round-trip.
|
|
||||||
// `composite_forced` (a channel-less session on a sticky-declared target) is pinned ON:
|
|
||||||
// with no client drawing, un-compositing would erase the pointer entirely.
|
|
||||||
let composite = (!on && self.cursor_shared.is_some()) || self.composite_forced;
|
|
||||||
if self.composite_cursor != composite {
|
|
||||||
self.composite_cursor = composite;
|
|
||||||
self.last_blend_key = None; // regenerate immediately at the current pointer state
|
|
||||||
tracing::info!(
|
|
||||||
composite,
|
|
||||||
"cursor render model: host compositing {}",
|
|
||||||
if composite {
|
|
||||||
"ON (capture model — blending the pointer into frames)"
|
|
||||||
} else {
|
|
||||||
"OFF (client draws locally)"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(20);
|
let deadline = Instant::now() + Duration::from_secs(20);
|
||||||
loop {
|
loop {
|
||||||
@@ -2577,16 +2107,6 @@ fn warn_444_hdr_downgrade_once() {
|
|||||||
|
|
||||||
impl Drop for IddPushCapturer {
|
impl Drop for IddPushCapturer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// A channel session ending while the secure-desktop guard is engaged must not leave the
|
|
||||||
// driver's per-target desired state off — the next session's channel delivery would
|
|
||||||
// adopt UNdeclared and silently run the composite model (§8.6's cross-session trap).
|
|
||||||
// The open-time reset also covers this (host-crash case); this is the orderly-teardown
|
|
||||||
// belt.
|
|
||||||
if self.secure_active && self.cursor_shared.is_some() {
|
|
||||||
if let Some(fwd) = self.cursor_forward.as_ref() {
|
|
||||||
let _ = fwd(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.slots.clear();
|
self.slots.clear();
|
||||||
// The shared header section (`MappedSection`), the frame-ready `event` (`OwnedHandle`) and the
|
// The shared header section (`MappedSection`), the frame-ready `event` (`OwnedHandle`) and the
|
||||||
// broker's WUDFHost process handle free themselves via RAII (unmap view, then close handle) —
|
// broker's WUDFHost process handle free themselves via RAII (unmap view, then close handle) —
|
||||||
|
|||||||
@@ -105,22 +105,6 @@ impl ChannelBroker {
|
|||||||
Ok(out.0 as usize as u64)
|
Ok(out.0 as usize as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Duplicate the cursor section into WUDFHost (v5 cursor channel) with the same
|
|
||||||
/// least-privilege section rights as the frame header. Thin `pub(super)` face over
|
|
||||||
/// [`dup_into`](Self::dup_into) for the cursor-delivery path in `open_on`.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `h` must be a live handle of the current process.
|
|
||||||
pub(super) unsafe fn dup_into_public(&self, h: HANDLE) -> Result<u64> {
|
|
||||||
// SAFETY: forwarded contract — `h` is live per this fn's own contract.
|
|
||||||
unsafe { self.dup_into(h, Some(SECTION_MAP_RW)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`close_remote`](Self::close_remote) for the cursor-delivery failure path.
|
|
||||||
pub(super) fn close_remote_public(&self, value: u64) {
|
|
||||||
self.close_remote(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
|
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
|
||||||
/// with no target closes the source handle regardless of the (ignored) result.
|
/// with no target closes the source handle regardless of the (ignored) result.
|
||||||
fn close_remote(&self, value: u64) {
|
fn close_remote(&self, value: u64) {
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
//! Host side of the v5 hardware-cursor channel (remote-desktop-sweep M2c): the capturer creates
|
|
||||||
//! an unnamed [`CursorShm`] section, delivers it to the pf-vdisplay driver (which declares an
|
|
||||||
//! IddCx hardware cursor — DWM then EXCLUDES the pointer from the frames we consume), and reads
|
|
||||||
//! the driver's seqlock publishes here at encode-tick pace, converting them into the same
|
|
||||||
//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the
|
|
||||||
//! cursor forwarder, the wire, the client renderer) is shared.
|
|
||||||
|
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use pf_driver_proto::cursor::{
|
|
||||||
CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET,
|
|
||||||
CURSOR_SHM_SIZE, CURSOR_TYPE_MASKED_COLOR,
|
|
||||||
};
|
|
||||||
use std::sync::atomic::AtomicU32;
|
|
||||||
|
|
||||||
/// The host end of one monitor's cursor channel: the section (we created it — the mapping stays
|
|
||||||
/// valid for the capturer's life) plus the reader's conversion cache.
|
|
||||||
pub(super) struct CursorShared {
|
|
||||||
section: MappedSection,
|
|
||||||
/// The monitor's desktop origin — IddCx reports positions in DESKTOP coordinates; the
|
|
||||||
/// overlay wants frame-relative. Fetched at attach (the virtual monitor's placement is
|
|
||||||
/// stable for the session; a topology change recreates the pipeline anyway).
|
|
||||||
origin: (i32, i32),
|
|
||||||
/// Conversion cache: the last `shape_id` whose pixels were converted, and the result.
|
|
||||||
/// Position-only updates (the common case) reuse it — a refcount bump, no pixel work.
|
|
||||||
cached_id: u32,
|
|
||||||
cached: Option<ConvertedShape>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ConvertedShape {
|
|
||||||
rgba: std::sync::Arc<Vec<u8>>,
|
|
||||||
w: u32,
|
|
||||||
h: u32,
|
|
||||||
hot_x: u32,
|
|
||||||
hot_y: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorShared {
|
|
||||||
/// Create + initialize the section (magic stamped, seq even/zero). The returned handle is
|
|
||||||
/// the section itself (owned by `self`); the caller duplicates it into the WUDFHost.
|
|
||||||
pub(super) fn create(target_id: u32) -> Result<CursorShared> {
|
|
||||||
// SAFETY: plain FFI. Unnamed pagefile-backed section, host-lifetime owned; the view is
|
|
||||||
// mapped once and unmapped never (the capturer's life = the session's life).
|
|
||||||
let section = unsafe {
|
|
||||||
let map = CreateFileMappingW(
|
|
||||||
INVALID_HANDLE_VALUE,
|
|
||||||
None,
|
|
||||||
PAGE_READWRITE,
|
|
||||||
0,
|
|
||||||
CURSOR_SHM_SIZE as u32,
|
|
||||||
PCWSTR::null(),
|
|
||||||
)
|
|
||||||
.context("CreateFileMapping(cursor)")?;
|
|
||||||
let map = OwnedHandle::from_raw_handle(map.0 as _);
|
|
||||||
let view = MapViewOfFile(
|
|
||||||
HANDLE(map.as_raw_handle()),
|
|
||||||
FILE_MAP_ALL_ACCESS,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
CURSOR_SHM_SIZE,
|
|
||||||
);
|
|
||||||
if view.Value.is_null() {
|
|
||||||
bail!("MapViewOfFile failed for the cursor section");
|
|
||||||
}
|
|
||||||
let shm = view.Value.cast::<CursorShm>();
|
|
||||||
std::ptr::write_bytes(view.Value.cast::<u8>(), 0, CURSOR_SHM_SIZE);
|
|
||||||
// Magic LAST-ish (the driver validates it at adopt; seq 0 = even = consistent).
|
|
||||||
std::sync::atomic::fence(Ordering::Release);
|
|
||||||
(*shm).magic = CURSOR_MAGIC;
|
|
||||||
MappedSection { handle: map, view }
|
|
||||||
};
|
|
||||||
// Desktop origin of this monitor's source — for the desktop→frame coordinate shift.
|
|
||||||
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned
|
|
||||||
// locals (same call the compose-kick path makes).
|
|
||||||
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
|
||||||
let origin = rect.map(|(x, y, _w, _h)| (x, y)).unwrap_or((0, 0));
|
|
||||||
Ok(CursorShared {
|
|
||||||
section,
|
|
||||||
origin,
|
|
||||||
cached_id: 0,
|
|
||||||
cached: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The section handle for the broker's duplication into the WUDFHost.
|
|
||||||
pub(super) fn section_handle(&self) -> HANDLE {
|
|
||||||
HANDLE(self.section.handle.as_raw_handle())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seqlock-read the driver's latest publish → a frame-relative [`pf_frame::CursorOverlay`].
|
|
||||||
/// `None` until the first publish lands (or while the pointer has never been seen). A hidden
|
|
||||||
/// pointer returns `Some` with `visible: false` — the forwarder turns that into the client's
|
|
||||||
/// relative-mode hint, exactly like the Linux path.
|
|
||||||
pub(super) fn read(&mut self) -> Option<pf_frame::CursorOverlay> {
|
|
||||||
let shm = self.section.ptr::<CursorShm>();
|
|
||||||
// SAFETY: the view spans CURSOR_SHM_SIZE for self's lifetime; seq is 4-aligned in the
|
|
||||||
// fixed layout (offset 4).
|
|
||||||
let seq = unsafe { &*std::ptr::addr_of!((*shm).seq).cast::<AtomicU32>() };
|
|
||||||
for _ in 0..64 {
|
|
||||||
let s1 = seq.load(Ordering::Acquire);
|
|
||||||
if s1 == 0 {
|
|
||||||
return None; // no publish yet
|
|
||||||
}
|
|
||||||
if s1 & 1 != 0 {
|
|
||||||
std::hint::spin_loop();
|
|
||||||
continue; // writer mid-update
|
|
||||||
}
|
|
||||||
// SAFETY: header reads within the mapped view; consistency is validated by the
|
|
||||||
// seq re-check below (a torn read is discarded and retried).
|
|
||||||
let hdr = unsafe { std::ptr::read_volatile(shm) };
|
|
||||||
// Shape pixels: convert only when the OS minted a new shape id.
|
|
||||||
if hdr.visible != 0 && hdr.shape_id != self.cached_id {
|
|
||||||
let rows = hdr.height.min(CURSOR_SHAPE_MAX) as usize;
|
|
||||||
let width = hdr.width.min(CURSOR_SHAPE_MAX) as usize;
|
|
||||||
let pitch = (hdr.pitch as usize).min(CURSOR_SHAPE_BYTES / rows.max(1));
|
|
||||||
let mut raw = vec![0u8; rows * pitch];
|
|
||||||
// SAFETY: the shape region spans CURSOR_SHAPE_BYTES from CURSOR_SHAPE_OFFSET
|
|
||||||
// inside the mapped view; `rows * pitch` is clamped to it above.
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(
|
|
||||||
self.section.ptr::<u8>().add(CURSOR_SHAPE_OFFSET),
|
|
||||||
raw.as_mut_ptr(),
|
|
||||||
rows * pitch,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Discard the copy if the writer raced us mid-shape (seq moved) — retry.
|
|
||||||
if seq.load(Ordering::Acquire) != s1 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
self.cached = Some(convert_shape(&hdr, &raw, width, rows, pitch));
|
|
||||||
self.cached_id = hdr.shape_id;
|
|
||||||
} else if seq.load(Ordering::Acquire) != s1 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let shape = self.cached.as_ref()?;
|
|
||||||
return Some(pf_frame::CursorOverlay {
|
|
||||||
x: hdr.x - self.origin.0,
|
|
||||||
y: hdr.y - self.origin.1,
|
|
||||||
w: shape.w,
|
|
||||||
h: shape.h,
|
|
||||||
rgba: shape.rgba.clone(),
|
|
||||||
serial: u64::from(hdr.shape_id),
|
|
||||||
hot_x: shape.hot_x,
|
|
||||||
hot_y: shape.hot_y,
|
|
||||||
visible: hdr.visible != 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
None // persistent tearing (writer wedged mid-seq) — skip this tick
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert the OS's 32-bpp pitch-strided shape rows into the overlay's packed straight RGBA.
|
|
||||||
/// ALPHA cursors are BGRA with straight per-pixel alpha (swap R↔B). MASKED_COLOR approximates:
|
|
||||||
/// alpha 0x00 = opaque color pixel; 0xFF = an XOR pixel we cannot honor client-side — rendered
|
|
||||||
/// as a translucent mid-gray so inversion cursors stay visible instead of vanishing.
|
|
||||||
fn convert_shape(
|
|
||||||
hdr: &CursorShm,
|
|
||||||
raw: &[u8],
|
|
||||||
width: usize,
|
|
||||||
rows: usize,
|
|
||||||
pitch: usize,
|
|
||||||
) -> ConvertedShape {
|
|
||||||
let masked = hdr.cursor_type == CURSOR_TYPE_MASKED_COLOR;
|
|
||||||
let mut rgba = Vec::with_capacity(width * rows * 4);
|
|
||||||
for y in 0..rows {
|
|
||||||
let row = &raw[y * pitch..];
|
|
||||||
for x in 0..width {
|
|
||||||
let o = x * 4;
|
|
||||||
if o + 4 > row.len() {
|
|
||||||
rgba.extend_from_slice(&[0, 0, 0, 0]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (b, g, r, a) = (row[o], row[o + 1], row[o + 2], row[o + 3]);
|
|
||||||
if masked {
|
|
||||||
if a == 0 {
|
|
||||||
rgba.extend_from_slice(&[r, g, b, 0xFF]);
|
|
||||||
} else {
|
|
||||||
rgba.extend_from_slice(&[0x80, 0x80, 0x80, 0xB4]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
rgba.extend_from_slice(&[r, g, b, a]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConvertedShape {
|
|
||||||
rgba: std::sync::Arc::new(rgba),
|
|
||||||
w: width as u32,
|
|
||||||
h: rows as u32,
|
|
||||||
hot_x: hdr.hot_x.min(width.saturating_sub(1) as u32),
|
|
||||||
hot_y: hdr.hot_y.min(rows.saturating_sub(1) as u32),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
//! Host-side cursor compositing for the CAPTURE mouse model (design/remote-desktop-sweep.md §8).
|
|
||||||
//!
|
|
||||||
//! Why the host draws it: once a monitor has ever declared an IddCx hardware cursor, DWM will
|
|
||||||
//! not composite the software cursor back into its frames — there is no un-declare DDI (the
|
|
||||||
//! empty-caps re-setup is rejected `STATUS_INVALID_PARAMETER`), and a successful same-mode
|
|
||||||
//! re-commit with the driver's re-declare provably suppressed still leaves the pointer excluded
|
|
||||||
//! (all observed on-glass, 26100). So the driver keeps its hardware cursor declared for the
|
|
||||||
//! session's whole life — the state that works — and when the client flips to the capture model
|
|
||||||
//! the HOST composites the pointer into the frame itself: a slot→scratch copy plus one
|
|
||||||
//! alpha-blended quad (the GDI poller's full-fidelity shape at its polled position), entirely
|
|
||||||
//! GPU-side on the capture device, before the normal conversion runs from the scratch.
|
|
||||||
|
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use windows::core::s;
|
|
||||||
use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
|
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
|
||||||
ID3D11BlendState, ID3D11Buffer, ID3D11PixelShader, ID3D11SamplerState, ID3D11VertexShader,
|
|
||||||
D3D11_BIND_CONSTANT_BUFFER, D3D11_BLEND_DESC, D3D11_BLEND_INV_SRC_ALPHA, D3D11_BLEND_ONE,
|
|
||||||
D3D11_BLEND_OP_ADD, D3D11_BLEND_SRC_ALPHA, D3D11_BUFFER_DESC, D3D11_COMPARISON_NEVER,
|
|
||||||
D3D11_CPU_ACCESS_WRITE, D3D11_FILTER_MIN_MAG_MIP_LINEAR, D3D11_MAPPED_SUBRESOURCE,
|
|
||||||
D3D11_MAP_WRITE_DISCARD, D3D11_RENDER_TARGET_BLEND_DESC, D3D11_SAMPLER_DESC,
|
|
||||||
D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DYNAMIC, D3D11_VIEWPORT,
|
|
||||||
};
|
|
||||||
use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_R8G8B8A8_UNORM;
|
|
||||||
|
|
||||||
/// Straight-alpha sample of the cursor bitmap. `linear_scale` = 0 passes sRGB through (SDR
|
|
||||||
/// ring); non-zero linearizes sRGB→scRGB AND multiplies by the target's SDR-white scale
|
|
||||||
/// (`sdr_white_level_scale` — 1.0 would put cursor-white at 80 nits, visibly DARKER than the
|
|
||||||
/// surrounding SDR desktop content DWM composes at the user's SDR-brightness setting).
|
|
||||||
const CURSOR_PS: &str = r"
|
|
||||||
Texture2D<float4> tx : register(t0);
|
|
||||||
SamplerState sm : register(s0);
|
|
||||||
cbuffer C : register(b0) { float linear_scale; float3 pad; };
|
|
||||||
float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target {
|
|
||||||
float4 c = tx.Sample(sm, uv);
|
|
||||||
if (linear_scale != 0.0) {
|
|
||||||
c.rgb = pow(abs(c.rgb), 2.2) * linear_scale;
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
";
|
|
||||||
|
|
||||||
/// The cursor-quad blend pass + its shape-texture cache. One per capturer (device-scoped).
|
|
||||||
pub(super) struct CursorBlendPass {
|
|
||||||
vs: ID3D11VertexShader,
|
|
||||||
ps: ID3D11PixelShader,
|
|
||||||
sampler: ID3D11SamplerState,
|
|
||||||
blend: ID3D11BlendState,
|
|
||||||
cbuf: ID3D11Buffer,
|
|
||||||
cbuf_scale: Option<f32>,
|
|
||||||
/// The uploaded shape (serial-keyed): SRV + dims in host pixels.
|
|
||||||
shape: Option<(u64, ID3D11ShaderResourceView, u32, u32)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorBlendPass {
|
|
||||||
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
|
||||||
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
|
|
||||||
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
|
|
||||||
let mut vs = None;
|
|
||||||
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
|
||||||
let mut ps = None;
|
|
||||||
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
|
|
||||||
let sd = D3D11_SAMPLER_DESC {
|
|
||||||
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
|
|
||||||
// half-texel edges; linear keeps them soft instead of ringing.
|
|
||||||
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
|
||||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
|
||||||
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
|
|
||||||
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
|
|
||||||
ComparisonFunc: D3D11_COMPARISON_NEVER,
|
|
||||||
MaxLOD: f32::MAX,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut sampler = None;
|
|
||||||
device.CreateSamplerState(&sd, Some(&mut sampler))?;
|
|
||||||
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
|
|
||||||
let mut bd = D3D11_BLEND_DESC::default();
|
|
||||||
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
|
|
||||||
BlendEnable: true.into(),
|
|
||||||
SrcBlend: D3D11_BLEND_SRC_ALPHA,
|
|
||||||
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
|
|
||||||
BlendOp: D3D11_BLEND_OP_ADD,
|
|
||||||
SrcBlendAlpha: D3D11_BLEND_ONE,
|
|
||||||
DestBlendAlpha: D3D11_BLEND_ONE,
|
|
||||||
BlendOpAlpha: D3D11_BLEND_OP_ADD,
|
|
||||||
RenderTargetWriteMask: 0x0F,
|
|
||||||
};
|
|
||||||
let mut blend = None;
|
|
||||||
device.CreateBlendState(&bd, Some(&mut blend))?;
|
|
||||||
let cbd = D3D11_BUFFER_DESC {
|
|
||||||
ByteWidth: 16, // float to_linear + float3 pad
|
|
||||||
Usage: D3D11_USAGE_DYNAMIC,
|
|
||||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
|
||||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut cbuf = None;
|
|
||||||
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
|
|
||||||
Ok(Self {
|
|
||||||
vs: vs.context("cursor blend vs")?,
|
|
||||||
ps: ps.context("cursor blend ps")?,
|
|
||||||
sampler: sampler.context("cursor blend sampler")?,
|
|
||||||
blend: blend.context("cursor blend state")?,
|
|
||||||
cbuf: cbuf.context("cursor blend cbuf")?,
|
|
||||||
cbuf_scale: None,
|
|
||||||
shape: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
|
||||||
unsafe fn ensure_shape(
|
|
||||||
&mut self,
|
|
||||||
device: &ID3D11Device,
|
|
||||||
ov: &pf_frame::CursorOverlay,
|
|
||||||
) -> Result<()> {
|
|
||||||
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
|
|
||||||
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
|
|
||||||
}
|
|
||||||
let desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: ov.w,
|
|
||||||
Height: ov.h,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let init = D3D11_SUBRESOURCE_DATA {
|
|
||||||
pSysMem: ov.rgba.as_ptr().cast(),
|
|
||||||
SysMemPitch: ov.w * 4,
|
|
||||||
SysMemSlicePitch: 0,
|
|
||||||
};
|
|
||||||
let mut tex: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
|
|
||||||
.context("CreateTexture2D(cursor shape)")?;
|
|
||||||
let tex = tex.context("null cursor shape texture")?;
|
|
||||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
|
||||||
device
|
|
||||||
.CreateShaderResourceView(&tex, None, Some(&mut srv))
|
|
||||||
.context("CreateShaderResourceView(cursor shape)")?;
|
|
||||||
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
|
|
||||||
/// `linear_scale`: 0 = SDR passthrough; non-zero = the frame is FP16 scRGB (HDR
|
|
||||||
/// composition) — linearize and scale to the target's SDR white. The quad is placed purely
|
|
||||||
/// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
|
|
||||||
/// the target automatically.
|
|
||||||
pub(super) unsafe fn blend(
|
|
||||||
&mut self,
|
|
||||||
device: &ID3D11Device,
|
|
||||||
ctx: &ID3D11DeviceContext,
|
|
||||||
dst: &ID3D11Texture2D,
|
|
||||||
ov: &pf_frame::CursorOverlay,
|
|
||||||
linear_scale: f32,
|
|
||||||
) -> Result<()> {
|
|
||||||
self.ensure_shape(device, ov)?;
|
|
||||||
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
|
|
||||||
if self.cbuf_scale != Some(linear_scale) {
|
|
||||||
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0];
|
|
||||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
|
||||||
if ctx
|
|
||||||
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
|
||||||
ctx.Unmap(&self.cbuf, 0);
|
|
||||||
}
|
|
||||||
self.cbuf_scale = Some(linear_scale);
|
|
||||||
}
|
|
||||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
|
||||||
device
|
|
||||||
.CreateRenderTargetView(dst, None, Some(&mut rtv))
|
|
||||||
.context("CreateRenderTargetView(cursor blend scratch)")?;
|
|
||||||
let rtv = rtv.context("null cursor blend rtv")?;
|
|
||||||
|
|
||||||
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
|
|
||||||
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
|
|
||||||
ctx.VSSetShader(&self.vs, None);
|
|
||||||
ctx.PSSetShader(&self.ps, None);
|
|
||||||
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
|
|
||||||
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
|
||||||
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
|
|
||||||
ctx.IASetInputLayout(None);
|
|
||||||
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
|
||||||
// Placement IS the viewport: the VS fills it, the OS clips it to the target.
|
|
||||||
let vp = D3D11_VIEWPORT {
|
|
||||||
TopLeftX: ov.x as f32,
|
|
||||||
TopLeftY: ov.y as f32,
|
|
||||||
Width: *w as f32,
|
|
||||||
Height: *h as f32,
|
|
||||||
MinDepth: 0.0,
|
|
||||||
MaxDepth: 1.0,
|
|
||||||
};
|
|
||||||
ctx.RSSetViewports(Some(&[vp]));
|
|
||||||
ctx.Draw(3, 0);
|
|
||||||
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
|
|
||||||
ctx.OMSetRenderTargets(None, None);
|
|
||||||
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
|
|
||||||
ctx.PSSetShaderResources(0, Some(&none_srv));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
//! GDI cursor poller — the Windows cursor-SHAPE source for the cursor-forward channel
|
|
||||||
//! (design/remote-desktop-sweep.md §8, the M2c redesign).
|
|
||||||
//!
|
|
||||||
//! Why not the IddCx hardware-cursor query (the v5 `CursorShm` path, now the fallback): it is
|
|
||||||
//! alpha-only BY DESIGN — `IDDCX_CURSOR_SHAPE_TYPE` has no monochrome value, the OS pre-converts
|
|
||||||
//! monochrome to masked-color (IDDCX_CURSOR_CAPS docs), and masked-color delivery is dead code on
|
|
||||||
//! modern builds (proven on-glass at every `ColorXorCursorSupport` level; no public evidence of a
|
|
||||||
//! MASKED_COLOR delivery anywhere). The driver keeps its hardware cursor declared at XOR FULL
|
|
||||||
//! purely so DWM EXCLUDES every cursor type from the IDD frame.
|
|
||||||
//!
|
|
||||||
//! Why not DXGI Desktop Duplication `GetFramePointerShape`: its `PointerPosition.Visible` goes
|
|
||||||
//! stale when the cursor moves only via injected input on current Win11 (Sunshine #5293 — exactly
|
|
||||||
//! a Punktfunk session's topology), it burns one of the session's four duplication slots, and its
|
|
||||||
//! per-output metadata on IDD monitors has conflicting field reports. The GDI path below is the
|
|
||||||
//! metadata-forwarding-remote-desktop pattern (RustDesk, WebRTC/Chrome Remote Desktop, OBS): the
|
|
||||||
//! cursor is per-session global state in win32k, readable cross-process, and `CURSOR_SHOWING` is
|
|
||||||
//! the logical visibility — immune to all of the above.
|
|
||||||
//!
|
|
||||||
//! Works because the capture host runs as SYSTEM *inside the interactive session* on
|
|
||||||
//! `winsta0\default` (the service supervisor retargets the token — `windows/service.rs`
|
|
||||||
//! `spawn_host`), so the poller thread sees the session's cursor directly; no helper process.
|
|
||||||
|
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use windows::Win32::Graphics::Gdi::{
|
|
||||||
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
|
|
||||||
BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::StationsAndDesktops::{
|
|
||||||
CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop,
|
|
||||||
DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, UOI_NAME,
|
|
||||||
};
|
|
||||||
use windows::Win32::UI::HiDpi::{
|
|
||||||
SetThreadDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
|
||||||
};
|
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
|
||||||
CopyIcon, DestroyIcon, GetCursorInfo, GetIconInfo, CURSORINFO, HICON, ICONINFO,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// `CURSORINFO.flags` bits (WindowsAndMessaging): the pointer is logically shown /
|
|
||||||
/// touch-or-pen-suppressed. Named locally so the visibility rule below reads as the docs do.
|
|
||||||
const CURSOR_SHOWING: u32 = 0x1;
|
|
||||||
const CURSOR_SUPPRESSED: u32 = 0x2;
|
|
||||||
|
|
||||||
/// A converted shape: the cache the per-tick overlay is assembled from. `rgba` is `Arc` so the
|
|
||||||
/// slot publish (and every downstream frame attach) is a refcount bump.
|
|
||||||
struct Shape {
|
|
||||||
rgba: std::sync::Arc<Vec<u8>>,
|
|
||||||
w: u32,
|
|
||||||
h: u32,
|
|
||||||
hot_x: u32,
|
|
||||||
hot_y: u32,
|
|
||||||
serial: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` at ~60 Hz, rasterises the `HCURSOR` only
|
|
||||||
/// when its handle value changes, and publishes a ready [`pf_frame::CursorOverlay`] snapshot; the
|
|
||||||
/// capture thread's per-tick cost is one uncontended mutex read + an `Arc` clone
|
|
||||||
/// (same split as [`DescriptorPoller`], and for the same reason: user32/gdi32 calls have no place
|
|
||||||
/// on the capture/encode thread).
|
|
||||||
pub(super) struct CursorPoller {
|
|
||||||
slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>>,
|
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
/// The input desktop is a SECURE desktop (Winlogon — UAC consent / lock / logon). Classified
|
|
||||||
/// on every reattach; the capturer polls it to stand the IddCx hardware-cursor declare down
|
|
||||||
/// while the secure desktop needs the software-cursor path to render (see
|
|
||||||
/// `IddPushCapturer::poll_secure_desktop`).
|
|
||||||
secure: Arc<AtomicBool>,
|
|
||||||
thread: Option<std::thread::JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorPoller {
|
|
||||||
/// ~250 Hz: the polled position is ALSO the composite-blend position (capture model), so
|
|
||||||
/// it must out-pace the fastest session — at 16 ms a 240 fps stream re-used a stale
|
|
||||||
/// position for ~4 consecutive frames and the composited pointer visibly stuttered
|
|
||||||
/// against the video. A tick is one `GetCursorInfo` syscall (rasterisation only on shape
|
|
||||||
/// change), so 250 Hz is still negligible CPU.
|
|
||||||
const INTERVAL: Duration = Duration::from_millis(4);
|
|
||||||
/// Unconditional input-desktop reattach cadence — catches secure-desktop (UAC/lock) switches
|
|
||||||
/// without a failure signal (`GetCursorInfo` on a stale desktop *succeeds* with stale data).
|
|
||||||
/// 250 ms, not the original 2 s: the reattach now also feeds [`Self::secure_desktop`], which
|
|
||||||
/// gates when the secure desktop becomes VISIBLE in the stream (the hardware-cursor
|
|
||||||
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
|
|
||||||
/// syscalls/s are not.
|
|
||||||
const REATTACH: Duration = Duration::from_millis(250);
|
|
||||||
|
|
||||||
/// Spawn the poller for the virtual display `target_id`. `rect` = the target's desktop rect
|
|
||||||
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
|
|
||||||
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
|
|
||||||
/// (per-output semantics, matching the driver shm path and the Linux portal).
|
|
||||||
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
|
|
||||||
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
|
||||||
let secure = Arc::new(AtomicBool::new(false));
|
|
||||||
let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone());
|
|
||||||
let thread = std::thread::Builder::new()
|
|
||||||
.name("pf-cursor-poll".into())
|
|
||||||
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t))
|
|
||||||
.ok();
|
|
||||||
if thread.is_none() {
|
|
||||||
tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm");
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
slot,
|
|
||||||
stop,
|
|
||||||
secure,
|
|
||||||
thread,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The latest overlay snapshot (`None` until the first successful shape rasterisation).
|
|
||||||
pub(super) fn read(&self) -> Option<pf_frame::CursorOverlay> {
|
|
||||||
self.slot.lock().unwrap_or_else(|p| p.into_inner()).clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the input desktop is currently a SECURE desktop (UAC consent / Winlogon lock or
|
|
||||||
/// logon). Latched by the poll thread on its reattach cadence (≤ [`Self::REATTACH`] stale).
|
|
||||||
pub(super) fn secure_desktop(&self) -> bool {
|
|
||||||
self.secure.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read.
|
|
||||||
pub(super) fn alive(&self) -> bool {
|
|
||||||
self.thread.as_ref().is_some_and(|t| !t.is_finished())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for CursorPoller {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
|
||||||
if let Some(t) = self.thread.take() {
|
|
||||||
let _ = t.join(); // worker sleeps ≤ INTERVAL — a bounded join
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
|
|
||||||
fn run(
|
|
||||||
target_id: u32,
|
|
||||||
rect: (i32, i32, i32, i32),
|
|
||||||
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
|
||||||
stop: &AtomicBool,
|
|
||||||
secure: &AtomicBool,
|
|
||||||
) {
|
|
||||||
// Physical-pixel coordinates on this thread regardless of the process's DPI awareness:
|
|
||||||
// `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position
|
|
||||||
// would land in the wrong frame pixel on any scaled display. Thread-scoped, so the rest of
|
|
||||||
// the host is untouched.
|
|
||||||
// SAFETY: takes and returns only a by-value context handle; affects this thread only.
|
|
||||||
let _ = unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) };
|
|
||||||
|
|
||||||
let mut desktop = DesktopBinding::default();
|
|
||||||
// best-effort: already on winsta0\default if this fails
|
|
||||||
publish_secure(secure, desktop.reattach());
|
|
||||||
let mut last_attach = Instant::now();
|
|
||||||
|
|
||||||
let mut shape: Option<Shape> = None;
|
|
||||||
let mut cached_handle: isize = 0;
|
|
||||||
let mut failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
|
|
||||||
let mut serial: u64 = 0;
|
|
||||||
let mut logged_live = false;
|
|
||||||
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
|
||||||
std::thread::sleep(CursorPoller::INTERVAL);
|
|
||||||
if last_attach.elapsed() >= CursorPoller::REATTACH {
|
|
||||||
last_attach = Instant::now();
|
|
||||||
publish_secure(secure, desktop.reattach());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut ci = CURSORINFO {
|
|
||||||
cbSize: std::mem::size_of::<CURSORINFO>() as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
// SAFETY: `ci` is a live, correctly-sized out-param for this synchronous call; no pointer
|
|
||||||
// escapes it.
|
|
||||||
if unsafe { GetCursorInfo(&mut ci) }.is_err() {
|
|
||||||
// Desktop went away under us (secure-desktop switch mid-call) — rebind and retry
|
|
||||||
// next tick; the slot keeps its last snapshot meanwhile.
|
|
||||||
publish_secure(secure, desktop.reattach());
|
|
||||||
last_attach = Instant::now();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let flags = ci.flags.0;
|
|
||||||
let showing = flags & CURSOR_SHOWING != 0 && flags & CURSOR_SUPPRESSED == 0;
|
|
||||||
|
|
||||||
// Rasterise on handle change only (position-only ticks are a header update). Hidden
|
|
||||||
// cursors keep the cached shape — the forwarder's hidden-but-known contract needs the
|
|
||||||
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS
|
|
||||||
// behavior); frame cycling via DrawIconEx istep is a known follow-up.
|
|
||||||
let handle = ci.hCursor.0 as isize;
|
|
||||||
if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
|
|
||||||
match rasterize(ci.hCursor) {
|
|
||||||
Some((rgba, w, h, hot_x, hot_y)) => {
|
|
||||||
serial += 1;
|
|
||||||
shape = Some(Shape {
|
|
||||||
rgba: std::sync::Arc::new(rgba),
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
hot_x,
|
|
||||||
hot_y,
|
|
||||||
serial,
|
|
||||||
});
|
|
||||||
cached_handle = handle;
|
|
||||||
failed_handle = 0;
|
|
||||||
if !logged_live {
|
|
||||||
logged_live = true;
|
|
||||||
tracing::info!(
|
|
||||||
target_id,
|
|
||||||
"cursor poller live — GDI shape source publishing (serial 1: {w}x{h})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// The owning app may have destroyed the cursor mid-read; keep the previous
|
|
||||||
// shape and don't hammer this handle again until it changes.
|
|
||||||
failed_handle = handle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let overlay = shape.as_ref().map(|s| {
|
|
||||||
let (px, py) = (ci.ptScreenPos.x - rect.0, ci.ptScreenPos.y - rect.1);
|
|
||||||
let in_rect = px >= 0 && py >= 0 && px < rect.2 && py < rect.3;
|
|
||||||
pf_frame::CursorOverlay {
|
|
||||||
// Overlay x/y = bitmap top-left (reported position − hotspot), frame pixels.
|
|
||||||
x: px - s.hot_x as i32,
|
|
||||||
y: py - s.hot_y as i32,
|
|
||||||
w: s.w,
|
|
||||||
h: s.h,
|
|
||||||
rgba: s.rgba.clone(),
|
|
||||||
serial: s.serial,
|
|
||||||
hot_x: s.hot_x,
|
|
||||||
hot_y: s.hot_y,
|
|
||||||
visible: showing && in_rect,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a reattach's secure-desktop verdict (`None` = classification unavailable — keep the
|
|
||||||
/// previous state rather than flapping the capturer's hardware-cursor stand-down).
|
|
||||||
fn publish_secure(secure: &AtomicBool, verdict: Option<bool>) {
|
|
||||||
if let Some(s) = verdict {
|
|
||||||
secure.store(s, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The thread's owned input-desktop handle — the [`SendInputInjector`] reattach model
|
|
||||||
/// (`pf-inject` sendinput.rs): keep the current binding, swap on demand, close exactly once.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct DesktopBinding(Option<HDESK>);
|
|
||||||
|
|
||||||
impl DesktopBinding {
|
|
||||||
/// Rebind to the CURRENT input desktop. Returns whether that desktop is a SECURE one
|
|
||||||
/// (`UOI_NAME` != "Default": "Winlogon" during UAC consent / lock / logon) — `None` when the
|
|
||||||
/// input desktop could not be opened, in which case the binding (and the caller's secure
|
|
||||||
/// state) stays put.
|
|
||||||
fn reattach(&mut self) -> Option<bool> {
|
|
||||||
const GENERIC_ALL: u32 = 0x1000_0000;
|
|
||||||
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` take only by-value args.
|
|
||||||
// `OpenInputDesktop` yields an owned `HDESK` only on `Ok`; it is either installed (and the
|
|
||||||
// previously-owned handle closed exactly once) or closed on failure — no handle is leaked
|
|
||||||
// or used after close. `SetThreadDesktop` rebinds only this calling thread (which owns
|
|
||||||
// no windows/hooks, so the rebind cannot fail on that account).
|
|
||||||
unsafe {
|
|
||||||
match OpenInputDesktop(
|
|
||||||
DESKTOP_CONTROL_FLAGS(0),
|
|
||||||
false,
|
|
||||||
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
|
||||||
) {
|
|
||||||
Ok(h) => {
|
|
||||||
let secure = desktop_is_secure(h);
|
|
||||||
if SetThreadDesktop(h).is_ok() {
|
|
||||||
if let Some(old) = self.0.replace(h) {
|
|
||||||
let _ = CloseDesktop(old);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let _ = CloseDesktop(h);
|
|
||||||
}
|
|
||||||
Some(secure)
|
|
||||||
}
|
|
||||||
Err(_) => None, // not privileged for this desktop; stay put
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `UOI_NAME` of `h` != "Default" — i.e. the input desktop is Winlogon (UAC consent / lock /
|
|
||||||
/// logon) or a screen-saver desktop, both of which need the OS's software-cursor render path.
|
|
||||||
/// Unnameable desktops read as NOT secure: the only in-contract failure is a too-small buffer,
|
|
||||||
/// and misreading secure-as-normal merely keeps today's behavior for a beat.
|
|
||||||
fn desktop_is_secure(h: HDESK) -> bool {
|
|
||||||
let mut name = [0u16; 64]; // "Default"/"Winlogon"/"Screen-saver" all fit with room to spare
|
|
||||||
let mut needed = 0u32;
|
|
||||||
// SAFETY: `h` is the live desktop handle the caller just opened; `name`/`needed` are live
|
|
||||||
// out-params sized exactly as passed; the call writes at most `nlength` bytes.
|
|
||||||
let ok = unsafe {
|
|
||||||
GetUserObjectInformationW(
|
|
||||||
windows::Win32::Foundation::HANDLE(h.0),
|
|
||||||
UOI_NAME,
|
|
||||||
Some(name.as_mut_ptr().cast()),
|
|
||||||
(name.len() * 2) as u32,
|
|
||||||
Some(&mut needed),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if ok.is_err() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let len = name.iter().position(|&c| c == 0).unwrap_or(name.len());
|
|
||||||
let name = String::from_utf16_lossy(&name[..len]);
|
|
||||||
!name.eq_ignore_ascii_case("Default")
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DesktopBinding {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Some(h) = self.0.take() {
|
|
||||||
// SAFETY: `h` is our owned desktop handle, closed exactly once here.
|
|
||||||
let _ = unsafe { CloseDesktop(h) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rasterise `hcursor` to straight-alpha RGBA: `(rgba, w, h, hot_x, hot_y)`. `None` on any
|
|
||||||
/// failure (caller keeps the previous shape).
|
|
||||||
fn rasterize(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> RasterOut {
|
|
||||||
// CopyIcon first: the owning process can destroy its HCURSOR between GetCursorInfo and the
|
|
||||||
// reads below; the copy is ours (the OBS/WebRTC guard).
|
|
||||||
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
|
|
||||||
// icons in user32); CopyIcon yields an owned HICON we destroy below.
|
|
||||||
let Ok(icon) = (unsafe { CopyIcon(HICON(hcursor.0)) }) else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let mut ii = ICONINFO::default();
|
|
||||||
// SAFETY: `ii` is a live out-param. On Ok it hands us COPIES of the mask/color bitmaps —
|
|
||||||
// both deleted below (GDI-handle leak otherwise).
|
|
||||||
let got = unsafe { GetIconInfo(icon, &mut ii) };
|
|
||||||
let out = if got.is_ok() { convert(&ii) } else { None };
|
|
||||||
// SAFETY: deleting the two bitmap copies GetIconInfo returned (null-safe: DeleteObject on a
|
|
||||||
// null HGDIOBJ fails harmlessly) and the icon copy — each exactly once.
|
|
||||||
unsafe {
|
|
||||||
let _ = DeleteObject(ii.hbmColor.into());
|
|
||||||
let _ = DeleteObject(ii.hbmMask.into());
|
|
||||||
let _ = DestroyIcon(icon);
|
|
||||||
}
|
|
||||||
out.map(|(rgba, w, h)| {
|
|
||||||
let hot_x = ii.xHotspot.min(w.saturating_sub(1));
|
|
||||||
let hot_y = ii.yHotspot.min(h.saturating_sub(1));
|
|
||||||
(rgba, w, h, hot_x, hot_y)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type RasterOut = Option<(Vec<u8>, u32, u32, u32, u32)>;
|
|
||||||
|
|
||||||
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
|
|
||||||
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
|
|
||||||
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
|
|
||||||
/// - monochrome (`hbmColor` null): `hbmMask` is DOUBLE height — AND plane over XOR plane, the
|
|
||||||
/// WebRTC truth table: (0,0) black, (0,1) white, (1,0) transparent, (1,1) invert. Invert
|
|
||||||
/// pixels — unrepresentable in straight alpha — become opaque black with a white outline
|
|
||||||
/// grown into adjacent transparency (the WebRTC approximation; keeps the I-beam legible on
|
|
||||||
/// any background, which the old translucent-gray stand-in did not).
|
|
||||||
fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
|
|
||||||
// SAFETY: GetDC(None) yields the screen DC, released below on every path; it is only used
|
|
||||||
// as the GetDIBits reference DC.
|
|
||||||
let dc = unsafe { GetDC(None) };
|
|
||||||
let result = (|| {
|
|
||||||
if !ii.hbmColor.is_invalid() {
|
|
||||||
let color = read_bitmap_32(dc, ii.hbmColor)?;
|
|
||||||
let (w, h) = (color.w as u32, color.h as u32);
|
|
||||||
let mut rgba = bgra_to_rgba(&color.bgra);
|
|
||||||
if rgba.chunks_exact(4).all(|p| p[3] == 0) {
|
|
||||||
// Alpha-less color cursor: transparency lives in the AND mask.
|
|
||||||
let mask = read_bitmap_32(dc, ii.hbmMask)?;
|
|
||||||
if mask.w != color.w || mask.h < color.h {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) {
|
|
||||||
px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((rgba, w, h))
|
|
||||||
} else {
|
|
||||||
let mask = read_bitmap_32(dc, ii.hbmMask)?;
|
|
||||||
if mask.h < 2 || mask.h % 2 != 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
|
|
||||||
let row = w * 4;
|
|
||||||
let (and_plane, xor_plane) = mask.bgra.split_at(h * row);
|
|
||||||
let mut rgba = vec![0u8; w * h * 4];
|
|
||||||
let mut invert = vec![false; w * h];
|
|
||||||
for i in 0..w * h {
|
|
||||||
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
|
|
||||||
let px = &mut rgba[i * 4..i * 4 + 4];
|
|
||||||
match (a, x) {
|
|
||||||
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
|
|
||||||
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
|
|
||||||
(true, false) => {} // transparent (already zeroed)
|
|
||||||
(true, true) => {
|
|
||||||
px.copy_from_slice(&[0, 0, 0, 0xFF]);
|
|
||||||
invert[i] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// White outline around invert regions so the (now black) shape survives dark
|
|
||||||
// backgrounds: any transparent 8-neighbor of an invert pixel turns opaque white.
|
|
||||||
for y in 0..h as i32 {
|
|
||||||
for x in 0..w as i32 {
|
|
||||||
if !invert[(y * w as i32 + x) as usize] {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (dx, dy) in NEIGHBORS {
|
|
||||||
let (nx, ny) = (x + dx, y + dy);
|
|
||||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let o = (ny * w as i32 + nx) as usize * 4;
|
|
||||||
if rgba[o + 3] == 0 {
|
|
||||||
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((rgba, w as u32, h as u32))
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
// SAFETY: releasing the screen DC obtained above, exactly once.
|
|
||||||
unsafe {
|
|
||||||
ReleaseDC(None, dc);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEIGHBORS: [(i32, i32); 8] = [
|
|
||||||
(-1, -1),
|
|
||||||
(0, -1),
|
|
||||||
(1, -1),
|
|
||||||
(-1, 0),
|
|
||||||
(1, 0),
|
|
||||||
(-1, 1),
|
|
||||||
(0, 1),
|
|
||||||
(1, 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
struct RawBitmap {
|
|
||||||
w: i32,
|
|
||||||
h: i32,
|
|
||||||
/// 32bpp top-down BGRA rows, `w*h*4` (monochrome sources arrive expanded: 0x00/0xFF channels).
|
|
||||||
bgra: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read any GDI bitmap as 32bpp top-down via `GetDIBits` (which performs the 1bpp→32bpp
|
|
||||||
/// expansion for the mask planes).
|
|
||||||
fn read_bitmap_32(dc: HDC, hbm: HBITMAP) -> Option<RawBitmap> {
|
|
||||||
let mut bm = BITMAP::default();
|
|
||||||
// SAFETY: `bm` is a live out-param sized exactly as passed; GetObjectW only writes into it.
|
|
||||||
let n = unsafe {
|
|
||||||
GetObjectW(
|
|
||||||
hbm.into(),
|
|
||||||
std::mem::size_of::<BITMAP>() as i32,
|
|
||||||
Some((&mut bm as *mut BITMAP).cast()),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if n == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 1024 {
|
|
||||||
return None; // 512/1024: sanity caps (256² is the wire max; XL accessibility ≤ that)
|
|
||||||
}
|
|
||||||
let (w, h) = (bm.bmWidth, bm.bmHeight);
|
|
||||||
let mut info = BITMAPINFO {
|
|
||||||
bmiHeader: BITMAPINFOHEADER {
|
|
||||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
|
||||||
biWidth: w,
|
|
||||||
biHeight: -h, // top-down
|
|
||||||
biPlanes: 1,
|
|
||||||
biBitCount: 32,
|
|
||||||
biCompression: BI_RGB.0,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut buf = vec![0u8; (w as usize) * (h as usize) * 4];
|
|
||||||
// SAFETY: `buf` spans exactly `h` rows of `w` 32bpp pixels as described by `info`; both are
|
|
||||||
// live locals for this synchronous call, `hbm` is a live bitmap not selected into any DC
|
|
||||||
// (fresh GetIconInfo copies).
|
|
||||||
let rows = unsafe {
|
|
||||||
GetDIBits(
|
|
||||||
dc,
|
|
||||||
hbm,
|
|
||||||
0,
|
|
||||||
h as u32,
|
|
||||||
Some(buf.as_mut_ptr().cast()),
|
|
||||||
&mut info,
|
|
||||||
DIB_RGB_COLORS,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
(rows != 0).then_some(RawBitmap { w, h, bgra: buf })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = bgra.to_vec();
|
|
||||||
for px in out.chunks_exact_mut(4) {
|
|
||||||
px.swap(0, 2);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
@@ -64,10 +64,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
|||||||
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the
|
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the
|
||||||
# method itself is feature-gated behind this.
|
# method itself is feature-gated behind this.
|
||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
# The OS-clipboard bridge (clipboard.rs): Open/Get/SetClipboardData + the sequence
|
|
||||||
# number, and the GlobalAlloc block the clipboard takes ownership of.
|
|
||||||
"Win32_System_DataExchange",
|
|
||||||
"Win32_System_Memory",
|
|
||||||
] }
|
] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -20,83 +20,6 @@ const MIC_FRAME: usize = 960;
|
|||||||
|
|
||||||
struct Terminate;
|
struct Terminate;
|
||||||
|
|
||||||
/// A selectable PipeWire endpoint for the settings pickers.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct AudioDevice {
|
|
||||||
/// `node.name` — the stable key the streams target via `target.object`.
|
|
||||||
pub name: String,
|
|
||||||
/// `node.description` — the human label the picker shows.
|
|
||||||
pub description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enumerate audio endpoints: `(sinks, sources)`. One registry roundtrip on a private
|
|
||||||
/// mainloop (a few ms against a live PipeWire); no daemon errors out and the caller
|
|
||||||
/// simply shows no pickers.
|
|
||||||
pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
|
||||||
use pipewire as pw;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
static PW_INIT: std::sync::Once = std::sync::Once::new();
|
|
||||||
PW_INIT.call_once(pw::init);
|
|
||||||
|
|
||||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
|
||||||
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
|
|
||||||
let core = context
|
|
||||||
.connect_rc(None)
|
|
||||||
.context("pw connect (is PipeWire running in this session?)")?;
|
|
||||||
let registry = core.get_registry_rc().context("pw registry")?;
|
|
||||||
|
|
||||||
let found: Rc<RefCell<(Vec<AudioDevice>, Vec<AudioDevice>)>> = Rc::default();
|
|
||||||
let _reg_listener = registry
|
|
||||||
.add_listener_local()
|
|
||||||
.global({
|
|
||||||
let found = found.clone();
|
|
||||||
move |g| {
|
|
||||||
let Some(props) = g.props else { return };
|
|
||||||
let sink = match props.get("media.class") {
|
|
||||||
Some("Audio/Sink") => true,
|
|
||||||
Some("Audio/Source") => false,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let Some(name) = props.get("node.name") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let description = props
|
|
||||||
.get("node.description")
|
|
||||||
.or_else(|| props.get("node.nick"))
|
|
||||||
.unwrap_or(name)
|
|
||||||
.to_string();
|
|
||||||
let dev = AudioDevice {
|
|
||||||
name: name.to_string(),
|
|
||||||
description,
|
|
||||||
};
|
|
||||||
let mut f = found.borrow_mut();
|
|
||||||
if sink { &mut f.0 } else { &mut f.1 }.push(dev);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.register();
|
|
||||||
|
|
||||||
// The registry replays existing globals asynchronously; one core sync marks the
|
|
||||||
// point they've all been delivered — quit the loop there.
|
|
||||||
let pending = core.sync(0).context("pw sync")?;
|
|
||||||
let _core_listener = core
|
|
||||||
.add_listener_local()
|
|
||||||
.done({
|
|
||||||
let mainloop = mainloop.clone();
|
|
||||||
move |_, seq| {
|
|
||||||
if seq == pending {
|
|
||||||
mainloop.quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.register();
|
|
||||||
mainloop.run();
|
|
||||||
|
|
||||||
let result = found.borrow().clone();
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AudioPlayer {
|
pub struct AudioPlayer {
|
||||||
pcm_tx: SyncSender<Vec<f32>>,
|
pcm_tx: SyncSender<Vec<f32>>,
|
||||||
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
|
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
|
||||||
@@ -195,7 +118,10 @@ fn pw_thread(
|
|||||||
move |_| mainloop.quit()
|
move |_| mainloop.quit()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut props = properties! {
|
let stream = pw::stream::StreamBox::new(
|
||||||
|
&core,
|
||||||
|
"punktfunk-client",
|
||||||
|
properties! {
|
||||||
*pw::keys::MEDIA_TYPE => "Audio",
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
*pw::keys::MEDIA_ROLE => "Game",
|
*pw::keys::MEDIA_ROLE => "Game",
|
||||||
@@ -203,18 +129,9 @@ fn pw_thread(
|
|||||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
||||||
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
||||||
*pw::keys::NODE_LATENCY => "240/48000",
|
*pw::keys::NODE_LATENCY => "240/48000",
|
||||||
};
|
},
|
||||||
// The Settings speaker pick (session main maps `Settings::speaker_device` here);
|
)
|
||||||
// unset/empty = PipeWire's default routing.
|
.context("pw Stream")?;
|
||||||
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SINK") {
|
|
||||||
if !target.is_empty() {
|
|
||||||
// Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer
|
|
||||||
// libpipewire than we require; the wire name is stable.
|
|
||||||
props.insert("target.object", target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let stream =
|
|
||||||
pw::stream::StreamBox::new(&core, "punktfunk-client", props).context("pw Stream")?;
|
|
||||||
|
|
||||||
let ud = PlayerData {
|
let ud = PlayerData {
|
||||||
rx: pcm_rx,
|
rx: pcm_rx,
|
||||||
@@ -399,22 +316,17 @@ fn mic_thread(
|
|||||||
move |_| mainloop.quit()
|
move |_| mainloop.quit()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut props = properties! {
|
let stream = pw::stream::StreamBox::new(
|
||||||
|
&core,
|
||||||
|
"punktfunk-mic-capture",
|
||||||
|
properties! {
|
||||||
*pw::keys::MEDIA_TYPE => "Audio",
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||||
*pw::keys::MEDIA_ROLE => "Communication",
|
*pw::keys::MEDIA_ROLE => "Communication",
|
||||||
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
||||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
|
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
|
||||||
};
|
},
|
||||||
// The Settings microphone pick (`Settings::mic_device` via session main).
|
)
|
||||||
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SOURCE") {
|
|
||||||
if !target.is_empty() {
|
|
||||||
// Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer
|
|
||||||
// libpipewire than we require; the wire name is stable.
|
|
||||||
props.insert("target.object", target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let stream = pw::stream::StreamBox::new(&core, "punktfunk-mic-capture", props)
|
|
||||||
.context("pw mic Stream")?;
|
.context("pw mic Stream")?;
|
||||||
|
|
||||||
let ud = MicData {
|
let ud = MicData {
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
//! OS-clipboard bridge for the spawned session client (`design/clipboard-and-file-transfer.md`
|
|
||||||
//! §5). The protocol half already exists in `punktfunk_core::clipboard` — the per-session task
|
|
||||||
//! that runs fetch streams — and `NativeClient` exposes it as `clip_control` / `clip_offer` /
|
|
||||||
//! `clip_fetch` / `clip_serve` / `next_clip`. What was missing on Windows is exactly what §5.2
|
|
||||||
//! writes in Swift for macOS: the code that talks to the actual pasteboard. This is that half.
|
|
||||||
//!
|
|
||||||
//! Shape (one thread, owned by the session pump):
|
|
||||||
//!
|
|
||||||
//! * **Local → remote** stays lazy by construction. A poll of `GetClipboardSequenceNumber`
|
|
||||||
//! spots a local copy, we announce the FORMAT LIST (`clip_offer`) and nothing else; the
|
|
||||||
//! bytes are read only if the host actually pastes and sends a `FetchRequest`.
|
|
||||||
//! * **Remote → local** is EAGER in this first cut, and that is a deliberate deviation from
|
|
||||||
//! §5.2's promise-based apply. macOS gets laziness free from `NSPasteboardItemDataProvider`;
|
|
||||||
//! the Windows equivalent is delayed rendering (`SetClipboardData(fmt, NULL)` answered on
|
|
||||||
//! `WM_RENDERFORMAT`), which needs a clipboard-owning window running its own message pump —
|
|
||||||
//! a bigger piece than this. So we fetch on the offer and place real bytes, under
|
|
||||||
//! [`EAGER_FETCH_CAP`] so a huge host-side copy can't pull megabytes nobody pastes. Text is
|
|
||||||
//! tiny and always crosses; a large image simply isn't mirrored until delayed rendering lands.
|
|
||||||
//! * **Echo suppression** is §3.4's Windows rule verbatim: record the clipboard sequence
|
|
||||||
//! number right after our own `SetClipboardData` and ignore exactly that change, or every
|
|
||||||
//! copy ping-pongs between the two machines forever.
|
|
||||||
//!
|
|
||||||
//! Secrets are respected: a clipboard carrying `ExcludeClipboardContentFromMonitorProcessing`
|
|
||||||
//! (what password managers set) is never announced and never served — the Windows counterpart
|
|
||||||
//! of §5.2's `org.nspasteboard.ConcealedType` skip.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use punktfunk_core::clipboard::ClipEventCore;
|
|
||||||
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
|
|
||||||
|
|
||||||
/// Wire mime for UTF-8 text — the one format every peer must handle (§3.5).
|
|
||||||
const MIME_TEXT: &str = "text/plain;charset=utf-8";
|
|
||||||
/// Wire mime for the image floor (§3.5). Read/written through the "PNG" registered clipboard
|
|
||||||
/// format; apps that only publish `CF_DIB` are a follow-up (the conversion the host already
|
|
||||||
/// has in `image_to_dib`).
|
|
||||||
const MIME_PNG: &str = "image/png";
|
|
||||||
|
|
||||||
/// Ceiling on an EAGERLY fetched remote payload (see the module docs). Text never approaches
|
|
||||||
/// it; it exists so a host-side copy of something enormous doesn't cross for a paste that may
|
|
||||||
/// never happen. Lifted once delayed rendering makes the fetch lazy.
|
|
||||||
const EAGER_FETCH_CAP: u64 = 4 << 20;
|
|
||||||
|
|
||||||
/// How often the local clipboard is polled for changes. §3.2 asks for ≥ 100 ms between offers;
|
|
||||||
/// 400 ms keeps a copy→focus→paste round trip comfortably ahead of the user.
|
|
||||||
const POLL: Duration = Duration::from_millis(400);
|
|
||||||
|
|
||||||
/// Drain-and-poll cadence: how long `next_clip` blocks before we re-check the clipboard and
|
|
||||||
/// the stop flag.
|
|
||||||
const EVENT_WAIT: Duration = Duration::from_millis(120);
|
|
||||||
|
|
||||||
/// Run the clipboard bridge until `stop` is set or the session closes. Returns immediately
|
|
||||||
/// (doing nothing) when the host didn't advertise `HOST_CAP_CLIPBOARD` — an older host, or one
|
|
||||||
/// whose backend can't do it — so this is safe to spawn unconditionally.
|
|
||||||
pub fn run(client: Arc<NativeClient>, stop: Arc<AtomicBool>) {
|
|
||||||
if client.host_caps() & HOST_CAP_CLIPBOARD == 0 {
|
|
||||||
tracing::info!("host has no clipboard capability — shared clipboard off");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Opt-in per §3.1: nothing is announced or served until this crosses enabled.
|
|
||||||
if let Err(e) = client.clip_control(true, 0) {
|
|
||||||
tracing::warn!(error = %e, "clipboard: enable failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tracing::info!("shared clipboard enabled");
|
|
||||||
|
|
||||||
let mut state = State {
|
|
||||||
// Adopt the CURRENT sequence number without announcing: whatever is on the clipboard
|
|
||||||
// from before the session started is the user's, not a copy they made for this stream.
|
|
||||||
last_seq: os::sequence_number(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut next_poll = Instant::now() + POLL;
|
|
||||||
|
|
||||||
while !stop.load(Ordering::SeqCst) {
|
|
||||||
// Inbound first — a pending FetchRequest is the host waiting on us. `NoFrame` is the
|
|
||||||
// ordinary poll timeout (nothing pending); anything else means the connection is gone
|
|
||||||
// and the session teardown is already on its way.
|
|
||||||
match client.next_clip(EVENT_WAIT) {
|
|
||||||
Ok(ev) => handle_event(&client, &mut state, ev),
|
|
||||||
Err(punktfunk_core::error::PunktfunkError::NoFrame) => {}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
// The local clipboard is polled on its OWN cadence, not once per inbound wait: the
|
|
||||||
// event wait is short (it bounds teardown latency), and hammering the Win32 clipboard
|
|
||||||
// eight times a second would contend with whatever app the user is actually copying in.
|
|
||||||
let now = Instant::now();
|
|
||||||
if now >= next_poll {
|
|
||||||
poll_local(&client, &mut state);
|
|
||||||
next_poll = now + POLL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Best-effort: tell the host to stop announcing into a session that's ending.
|
|
||||||
let _ = client.clip_control(false, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct State {
|
|
||||||
/// Clipboard sequence number as of our last look — a change means someone copied.
|
|
||||||
last_seq: u32,
|
|
||||||
/// The sequence number our OWN `SetClipboardData` produced (§3.4 echo suppression).
|
|
||||||
self_written_seq: Option<u32>,
|
|
||||||
/// Monotonic offer counter (§3.2 — newest wins).
|
|
||||||
offer_seq: u32,
|
|
||||||
/// Rate-limit guard for offers (§3.2 asks ≥ 100 ms).
|
|
||||||
last_offer: Option<Instant>,
|
|
||||||
/// The host's current offer, so a fetch can name its `seq`.
|
|
||||||
remote_offer: Option<u32>,
|
|
||||||
/// In-flight eager fetch → the mime it will deliver, so `Data` knows how to place it.
|
|
||||||
pending_fetch: Option<(u32, String)>,
|
|
||||||
/// The last payload we placed locally, kept only to answer a host fetch of our own echo
|
|
||||||
/// without re-reading the OS clipboard.
|
|
||||||
last_applied: Option<(String, Vec<u8>)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A local clipboard change → announce the format list (never the bytes).
|
|
||||||
fn poll_local(client: &NativeClient, state: &mut State) {
|
|
||||||
let seq = os::sequence_number();
|
|
||||||
if seq == state.last_seq {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.last_seq = seq;
|
|
||||||
// Our own apply — swallow it, or the two clipboards chase each other forever.
|
|
||||||
if state.self_written_seq == Some(seq) {
|
|
||||||
state.self_written_seq = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(t) = state.last_offer {
|
|
||||||
if t.elapsed() < Duration::from_millis(100) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if os::is_concealed() {
|
|
||||||
tracing::debug!("clipboard: concealed content — not announced");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut kinds: Vec<ClipKind> = Vec::new();
|
|
||||||
for (mime, size) in os::available_kinds() {
|
|
||||||
kinds.push(ClipKind {
|
|
||||||
mime: mime.to_string(),
|
|
||||||
size_hint: size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if kinds.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.offer_seq = state.offer_seq.wrapping_add(1);
|
|
||||||
state.last_offer = Some(Instant::now());
|
|
||||||
let seq_id = state.offer_seq;
|
|
||||||
tracing::debug!(
|
|
||||||
seq = seq_id,
|
|
||||||
kinds = kinds.len(),
|
|
||||||
"clipboard: offering local copy"
|
|
||||||
);
|
|
||||||
if let Err(e) = client.clip_offer(seq_id, kinds) {
|
|
||||||
tracing::warn!(error = %e, "clipboard: offer failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
|
|
||||||
match ev {
|
|
||||||
ClipEventCore::State {
|
|
||||||
enabled,
|
|
||||||
policy,
|
|
||||||
reason,
|
|
||||||
} => {
|
|
||||||
tracing::info!(enabled, policy, reason, "clipboard: host state");
|
|
||||||
}
|
|
||||||
// The host copied. Pull the best format we can place (see the module docs on why this
|
|
||||||
// is eager for now) — text preferred, then PNG.
|
|
||||||
ClipEventCore::RemoteOffer { seq, kinds } => {
|
|
||||||
state.remote_offer = Some(seq);
|
|
||||||
let pick = kinds
|
|
||||||
.iter()
|
|
||||||
.find(|k| k.mime == MIME_TEXT)
|
|
||||||
.or_else(|| kinds.iter().find(|k| k.mime == MIME_PNG));
|
|
||||||
let Some(kind) = pick else {
|
|
||||||
tracing::debug!("clipboard: remote offer has no format we can place");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if kind.size_hint > EAGER_FETCH_CAP {
|
|
||||||
tracing::info!(
|
|
||||||
mime = %kind.mime,
|
|
||||||
size = kind.size_hint,
|
|
||||||
"clipboard: remote payload over the eager-fetch cap — not mirrored"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match client.clip_fetch(seq, kind.mime.clone(), CLIP_FILE_INDEX_NONE) {
|
|
||||||
Ok(xfer) => state.pending_fetch = Some((xfer, kind.mime.clone())),
|
|
||||||
Err(e) => tracing::warn!(error = %e, "clipboard: fetch failed to start"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Bytes for the fetch above — place them, then record the sequence number they cause.
|
|
||||||
ClipEventCore::Data {
|
|
||||||
xfer_id,
|
|
||||||
bytes,
|
|
||||||
last,
|
|
||||||
} => {
|
|
||||||
let Some((pending, mime)) = state.pending_fetch.clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if pending != xfer_id {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if last {
|
|
||||||
state.pending_fetch = None;
|
|
||||||
}
|
|
||||||
match os::set(&mime, &bytes) {
|
|
||||||
Ok(()) => {
|
|
||||||
// §3.4: this is the change WE caused; ignore exactly it.
|
|
||||||
state.self_written_seq = Some(os::sequence_number());
|
|
||||||
state.last_applied = Some((mime.clone(), bytes));
|
|
||||||
tracing::debug!(mime = %mime, "clipboard: applied remote content");
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, mime = %mime, "clipboard: apply failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The host is pasting what we offered: read the bytes NOW (this is the lazy half) and
|
|
||||||
// answer. A read failure still answers — with a cancel — so the host isn't left waiting.
|
|
||||||
ClipEventCore::FetchRequest {
|
|
||||||
req_id,
|
|
||||||
seq: _,
|
|
||||||
file_index: _,
|
|
||||||
mime,
|
|
||||||
} => {
|
|
||||||
if os::is_concealed() {
|
|
||||||
let _ = client.clip_cancel(req_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Serve our own last-applied payload verbatim when it still matches — avoids a
|
|
||||||
// lossy OS round trip for content that originated on the host anyway.
|
|
||||||
let bytes = match &state.last_applied {
|
|
||||||
Some((m, b)) if *m == mime && state.self_written_seq.is_some() => Ok(b.clone()),
|
|
||||||
_ => os::get(&mime),
|
|
||||||
};
|
|
||||||
match bytes {
|
|
||||||
Ok(b) => {
|
|
||||||
tracing::debug!(mime = %mime, len = b.len(), "clipboard: serving to host");
|
|
||||||
if let Err(e) = client.clip_serve(req_id, b, true) {
|
|
||||||
tracing::warn!(error = %e, "clipboard: serve failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, mime = %mime, "clipboard: nothing to serve");
|
|
||||||
let _ = client.clip_cancel(req_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ClipEventCore::Cancelled { id } => tracing::debug!(id, "clipboard: transfer cancelled"),
|
|
||||||
ClipEventCore::Error { id, code } => {
|
|
||||||
tracing::debug!(id, code, "clipboard: transfer error");
|
|
||||||
if state.pending_fetch.as_ref().is_some_and(|(x, _)| *x == id) {
|
|
||||||
state.pending_fetch = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
mod os {
|
|
||||||
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
|
|
||||||
//! closes it — holding it across a network fetch would block every other app on the box.
|
|
||||||
|
|
||||||
use super::{MIME_PNG, MIME_TEXT};
|
|
||||||
use anyhow::{anyhow, bail, Result};
|
|
||||||
use windows::core::PCWSTR;
|
|
||||||
use windows::Win32::Foundation::{HANDLE, HGLOBAL};
|
|
||||||
use windows::Win32::System::DataExchange::{
|
|
||||||
CloseClipboard, EmptyClipboard, GetClipboardData, GetClipboardSequenceNumber,
|
|
||||||
IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW, SetClipboardData,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Memory::{
|
|
||||||
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CF_UNICODETEXT: u32 = 13;
|
|
||||||
|
|
||||||
/// A registered clipboard format id, by name (`PNG`, the concealed marker, …).
|
|
||||||
fn registered(name: &str) -> u32 {
|
|
||||||
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
|
|
||||||
unsafe { RegisterClipboardFormatW(PCWSTR(wide.as_ptr())) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn png_format() -> u32 {
|
|
||||||
registered("PNG")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RAII clipboard open — `CloseClipboard` must run even on the error paths.
|
|
||||||
struct Clip;
|
|
||||||
impl Clip {
|
|
||||||
fn open() -> Result<Clip> {
|
|
||||||
// A retry loop: another app can hold the clipboard for a moment.
|
|
||||||
for _ in 0..10 {
|
|
||||||
if unsafe { OpenClipboard(None) }.is_ok() {
|
|
||||||
return Ok(Clip);
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
bail!("clipboard busy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Drop for Clip {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = unsafe { CloseClipboard() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sequence_number() -> u32 {
|
|
||||||
unsafe { GetClipboardSequenceNumber() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Password managers mark secrets with this format; §5.2's concealed-type rule.
|
|
||||||
pub fn is_concealed() -> bool {
|
|
||||||
let fmt = registered("ExcludeClipboardContentFromMonitorProcessing");
|
|
||||||
unsafe { IsClipboardFormatAvailable(fmt) }.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The wire kinds the current clipboard can supply, with size hints where they're free.
|
|
||||||
pub fn available_kinds() -> Vec<(&'static str, u64)> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if unsafe { IsClipboardFormatAvailable(CF_UNICODETEXT) }.is_ok() {
|
|
||||||
out.push((MIME_TEXT, 0));
|
|
||||||
}
|
|
||||||
if unsafe { IsClipboardFormatAvailable(png_format()) }.is_ok() {
|
|
||||||
out.push((MIME_PNG, 0));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read one wire format off the clipboard.
|
|
||||||
pub fn get(mime: &str) -> Result<Vec<u8>> {
|
|
||||||
let _clip = Clip::open()?;
|
|
||||||
match mime {
|
|
||||||
MIME_TEXT => {
|
|
||||||
let h = unsafe { GetClipboardData(CF_UNICODETEXT) }?;
|
|
||||||
let g = HGLOBAL(h.0);
|
|
||||||
let p = unsafe { GlobalLock(g) } as *const u16;
|
|
||||||
if p.is_null() {
|
|
||||||
bail!("clipboard text lock failed");
|
|
||||||
}
|
|
||||||
// GlobalSize is a byte count of a NUL-terminated UTF-16 buffer.
|
|
||||||
let bytes = unsafe { GlobalSize(g) };
|
|
||||||
let mut len = bytes / 2;
|
|
||||||
let slice = unsafe { std::slice::from_raw_parts(p, len) };
|
|
||||||
if let Some(nul) = slice.iter().position(|&c| c == 0) {
|
|
||||||
len = nul;
|
|
||||||
}
|
|
||||||
let text = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) });
|
|
||||||
let _ = unsafe { GlobalUnlock(g) };
|
|
||||||
Ok(text.into_bytes())
|
|
||||||
}
|
|
||||||
MIME_PNG => {
|
|
||||||
let h = unsafe { GetClipboardData(png_format()) }?;
|
|
||||||
let g = HGLOBAL(h.0);
|
|
||||||
let p = unsafe { GlobalLock(g) } as *const u8;
|
|
||||||
if p.is_null() {
|
|
||||||
bail!("clipboard png lock failed");
|
|
||||||
}
|
|
||||||
let len = unsafe { GlobalSize(g) };
|
|
||||||
let out = unsafe { std::slice::from_raw_parts(p, len) }.to_vec();
|
|
||||||
let _ = unsafe { GlobalUnlock(g) };
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
other => Err(anyhow!("unsupported clipboard format {other}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Place one wire format on the clipboard, replacing its contents.
|
|
||||||
pub fn set(mime: &str, bytes: &[u8]) -> Result<()> {
|
|
||||||
let (fmt, payload) = match mime {
|
|
||||||
MIME_TEXT => {
|
|
||||||
let text = String::from_utf8_lossy(bytes);
|
|
||||||
let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
|
|
||||||
let raw: Vec<u8> = wide.iter().flat_map(|c| c.to_le_bytes()).collect();
|
|
||||||
(CF_UNICODETEXT, raw)
|
|
||||||
}
|
|
||||||
MIME_PNG => (png_format(), bytes.to_vec()),
|
|
||||||
other => bail!("unsupported clipboard format {other}"),
|
|
||||||
};
|
|
||||||
let _clip = Clip::open()?;
|
|
||||||
unsafe { EmptyClipboard() }?;
|
|
||||||
// The clipboard OWNS this block once SetClipboardData succeeds — do not free it.
|
|
||||||
let g = unsafe { GlobalAlloc(GMEM_MOVEABLE, payload.len()) }?;
|
|
||||||
let p = unsafe { GlobalLock(g) } as *mut u8;
|
|
||||||
if p.is_null() {
|
|
||||||
bail!("clipboard alloc lock failed");
|
|
||||||
}
|
|
||||||
unsafe { std::ptr::copy_nonoverlapping(payload.as_ptr(), p, payload.len()) };
|
|
||||||
let _ = unsafe { GlobalUnlock(g) };
|
|
||||||
unsafe { SetClipboardData(fmt, Some(HANDLE(g.0))) }?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
mod os {
|
|
||||||
//! Non-Windows stub. Linux needs the Wayland `data-control` seam (the same protocol the
|
|
||||||
//! host side already speaks) — the bridge above is platform-neutral and will drive it
|
|
||||||
//! unchanged once this module grows a real implementation.
|
|
||||||
use anyhow::{bail, Result};
|
|
||||||
|
|
||||||
pub fn sequence_number() -> u32 {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
pub fn is_concealed() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
pub fn available_kinds() -> Vec<(&'static str, u64)> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
pub fn get(_mime: &str) -> Result<Vec<u8>> {
|
|
||||||
bail!("clipboard unsupported on this platform")
|
|
||||||
}
|
|
||||||
pub fn set(_mime: &str, _bytes: &[u8]) -> Result<()> {
|
|
||||||
bail!("clipboard unsupported on this platform")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -876,16 +876,10 @@ impl Worker {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let pref = match self.pad_info(id) {
|
let pref = self
|
||||||
// Steam Input's virtual pad standing in front of the Deck's built-in controls (the
|
.pad_info(id)
|
||||||
// only-pad-forwarded case, [`Self::forwarded_ids`]): declare the DECK kind, not the
|
.map(|p| p.pref)
|
||||||
// wrapper's Xbox 360 identity. [`Self::auto_pref`] already resolves the SESSION
|
.unwrap_or(GamepadPref::Xbox360);
|
||||||
// default this way, but a current host honors the per-pad arrival over the session
|
|
||||||
// default — so without this the host builds an X-Box 360 pad on a real Deck.
|
|
||||||
Some(p) if p.steam_virtual && is_steam_deck() => GamepadPref::SteamDeck,
|
|
||||||
Some(p) => p.pref,
|
|
||||||
None => GamepadPref::Xbox360,
|
|
||||||
};
|
|
||||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||||
Ok(pad) => {
|
Ok(pad) => {
|
||||||
let mut slot = Slot::new(id, index, pref, pad);
|
let mut slot = Slot::new(id, index, pref, pad);
|
||||||
|
|||||||
@@ -41,11 +41,6 @@ mod video_software;
|
|||||||
mod video_vaapi;
|
mod video_vaapi;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
mod video_vulkan;
|
mod video_vulkan;
|
||||||
// The OS-clipboard bridge for the shared clipboard (design/clipboard-and-file-transfer.md §5).
|
|
||||||
// Built everywhere the session client is; the platform seam inside is Windows-real,
|
|
||||||
// stub elsewhere.
|
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
|
||||||
pub mod clipboard;
|
|
||||||
// PyroWave decode — Linux + Windows (plan §4.5; the Apple Metal port is its own phase).
|
// PyroWave decode — Linux + Windows (plan §4.5; the Apple Metal port is its own phase).
|
||||||
// Windows joined once its client moved to the SAME spawned Vulkan session presenter as
|
// Windows joined once its client moved to the SAME spawned Vulkan session presenter as
|
||||||
// Linux's: the decoder is plain Vulkan compute on the presenter's device (no fds, no
|
// Linux's: the decoder is plain Vulkan compute on the presenter's device (no fds, no
|
||||||
|
|||||||
@@ -41,16 +41,6 @@ pub struct SessionParams {
|
|||||||
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
|
||||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
|
||||||
pub clipboard: bool,
|
|
||||||
/// Advertise `quic::CLIENT_CAP_CURSOR`: this embedder renders the host cursor locally
|
|
||||||
/// (the presenter's cursor channel, design/remote-desktop-sweep.md M2), so the host may
|
|
||||||
/// stop compositing the pointer into the video. Only set when the embedder actually
|
|
||||||
/// draws it (the SDL presenter in desktop mouse mode) — a session that advertises it
|
|
||||||
/// without rendering streams with NO visible cursor. The host answers `HOST_CAP_CURSOR`
|
|
||||||
/// when its capture can forward (Linux portal, not gamescope/Windows).
|
|
||||||
pub cursor_forward: bool,
|
|
||||||
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
||||||
/// `video::Decoder::new`).
|
/// `video::Decoder::new`).
|
||||||
pub decoder: String,
|
pub decoder: String,
|
||||||
@@ -262,11 +252,6 @@ fn pump(
|
|||||||
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
||||||
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
||||||
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
||||||
if params.cursor_forward {
|
|
||||||
punktfunk_core::quic::CLIENT_CAP_CURSOR
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
|
||||||
params.launch.clone(),
|
params.launch.clone(),
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
@@ -354,20 +339,6 @@ fn pump(
|
|||||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
|
||||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
|
||||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
|
||||||
let clipboard_thread = params
|
|
||||||
.clipboard
|
|
||||||
.then(|| {
|
|
||||||
let c = connector.clone();
|
|
||||||
let s = stop.clone();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("pf-clipboard".into())
|
|
||||||
.spawn(move || crate::clipboard::run(c, s))
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.flatten();
|
|
||||||
let _mic = params
|
let _mic = params
|
||||||
.mic_enabled
|
.mic_enabled
|
||||||
.then(|| {
|
.then(|| {
|
||||||
@@ -459,16 +430,8 @@ fn pump(
|
|||||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||||
match connector.next_frame(Duration::from_millis(20)) {
|
match connector.next_frame(Duration::from_millis(20)) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
// The `received` point: reassembly COMPLETION, stamped by the core session as
|
// The `received` point: AU fully reassembled, in hand, before decode.
|
||||||
// the AU crossed poll_frame (ABI v9). Stamping here at the hand-off pull instead
|
let received_ns = now_ns();
|
||||||
// would fold the pre-decode queue wait into `host+network` — a client-side
|
|
||||||
// standing backlog masquerading as network latency (the 2026-07 two-pair
|
|
||||||
// investigation). 0 = a core predating the stamp; fall back to the pull instant.
|
|
||||||
let received_ns = if frame.received_ns > 0 {
|
|
||||||
frame.received_ns
|
|
||||||
} else {
|
|
||||||
now_ns()
|
|
||||||
};
|
|
||||||
// fps / goodput count every received AU (spec), decoded or not.
|
// fps / goodput count every received AU (spec), decoded or not.
|
||||||
frames_n += 1;
|
frames_n += 1;
|
||||||
bytes_n += frame.data.len() as u64;
|
bytes_n += frame.data.len() as u64;
|
||||||
@@ -836,9 +799,6 @@ fn pump(
|
|||||||
if let Some(t) = audio_thread {
|
if let Some(t) = audio_thread {
|
||||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||||
}
|
}
|
||||||
if let Some(t) = clipboard_thread {
|
|
||||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
|
||||||
}
|
|
||||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,12 +124,6 @@ pub struct KnownHost {
|
|||||||
/// pre-existing stores load; empty until first learned.
|
/// pre-existing stores load; empty until first learned.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mac: Vec<String>,
|
pub mac: Vec<String>,
|
||||||
/// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md
|
|
||||||
/// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a
|
|
||||||
/// host your clipboard is a trust decision about that host. Default off; the host must
|
|
||||||
/// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled.
|
|
||||||
#[serde(default)]
|
|
||||||
pub clipboard_sync: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
@@ -207,7 +201,6 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
|||||||
paired,
|
paired,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
mac: Vec::new(),
|
mac: Vec::new(),
|
||||||
clipboard_sync: false,
|
|
||||||
});
|
});
|
||||||
let _ = known.save();
|
let _ = known.save();
|
||||||
}
|
}
|
||||||
@@ -325,11 +318,6 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S
|
|||||||
"Client and host versions don't match — update both to the same release.".into()
|
"Client and host versions don't match — update both to the same release.".into()
|
||||||
}
|
}
|
||||||
R::Busy => "The host is busy with another session.".into(),
|
R::Busy => "The host is busy with another session.".into(),
|
||||||
R::SetupFailed => {
|
|
||||||
"The host accepted the connection but couldn't start the stream — the host's log \
|
|
||||||
(web console → Log) has the cause."
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,48 +449,6 @@ impl TouchMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a physical mouse drives the host — the desktop-sweep mouse model
|
|
||||||
/// (design/remote-desktop-sweep.md M1). Stored stringly in [`Settings::mouse_mode`] so the
|
|
||||||
/// file stays readable; parsed with [`MouseMode::from_name`].
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub enum MouseMode {
|
|
||||||
/// Pointer lock (relative deltas, hidden cursor) — the game model, and the default:
|
|
||||||
/// the only cursor you see is the host's.
|
|
||||||
Capture,
|
|
||||||
/// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and
|
|
||||||
/// motion goes on the wire as absolute positions through the letterbox. The remote
|
|
||||||
/// desktop model. Requires a host injector with absolute support (not gamescope).
|
|
||||||
Desktop,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MouseMode {
|
|
||||||
/// Cycle/picker order (also the settings pickers' option order).
|
|
||||||
pub const ALL: [MouseMode; 2] = [MouseMode::Capture, MouseMode::Desktop];
|
|
||||||
|
|
||||||
/// Parse the persisted name, defaulting to `Capture` for unset/unknown values.
|
|
||||||
pub fn from_name(s: &str) -> MouseMode {
|
|
||||||
match s {
|
|
||||||
"desktop" => MouseMode::Desktop,
|
|
||||||
_ => MouseMode::Capture,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The persisted name (the inverse of [`from_name`](Self::from_name)).
|
|
||||||
pub fn as_name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
MouseMode::Capture => "capture",
|
|
||||||
MouseMode::Desktop => "desktop",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
MouseMode::Capture => "Capture (games)",
|
|
||||||
MouseMode::Desktop => "Desktop (absolute)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
@@ -537,12 +483,6 @@ pub struct Settings {
|
|||||||
/// stores load as trackpad.
|
/// stores load as trackpad.
|
||||||
#[serde(default = "default_touch_mode")]
|
#[serde(default = "default_touch_mode")]
|
||||||
pub touch_mode: String,
|
pub touch_mode: String,
|
||||||
/// How a physical mouse drives the host: a [`MouseMode`] name — `"capture"` (default,
|
|
||||||
/// pointer lock + relative) or `"desktop"` (uncaptured absolute pointer). Read at
|
|
||||||
/// connect via [`Settings::mouse_mode`]. `default` so pre-existing stores load as
|
|
||||||
/// capture — today's behavior.
|
|
||||||
#[serde(default = "default_mouse_mode")]
|
|
||||||
pub mouse_mode: String,
|
|
||||||
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
||||||
pub inhibit_shortcuts: bool,
|
pub inhibit_shortcuts: bool,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
@@ -586,27 +526,6 @@ pub struct Settings {
|
|||||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||||
pub library_enabled: bool,
|
pub library_enabled: bool,
|
||||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
|
||||||
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
|
|
||||||
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
|
|
||||||
/// an offline-looking host is really just unreachable by broadcast and the wake +
|
|
||||||
/// wait only adds a delay.
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub auto_wake: bool,
|
|
||||||
/// Reverse the wheel/trackpad scroll direction sent to the host (the Apple client's
|
|
||||||
/// "Invert scroll direction"). Default off = the host scrolls the way this machine does.
|
|
||||||
#[serde(default)]
|
|
||||||
pub invert_scroll: bool,
|
|
||||||
/// Playback endpoint for stream audio — on Linux the PipeWire `node.name` the
|
|
||||||
/// playback stream targets (`target.object`); empty = the session default (the
|
|
||||||
/// Apple client's Speaker picker). The session maps it onto `PUNKTFUNK_AUDIO_SINK`.
|
|
||||||
/// Ignored on Windows until the WASAPI endpoint leg exists.
|
|
||||||
#[serde(default)]
|
|
||||||
pub speaker_device: String,
|
|
||||||
/// Capture endpoint for the mic uplink (same semantics as `speaker_device`;
|
|
||||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
|
||||||
#[serde(default)]
|
|
||||||
pub mic_device: String,
|
|
||||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||||
@@ -630,10 +549,6 @@ fn default_touch_mode() -> String {
|
|||||||
"trackpad".into()
|
"trackpad".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_mouse_mode() -> String {
|
|
||||||
"capture".into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -661,10 +576,6 @@ impl Settings {
|
|||||||
TouchMode::from_name(&self.touch_mode)
|
TouchMode::from_name(&self.touch_mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mouse_mode(&self) -> MouseMode {
|
|
||||||
MouseMode::from_name(&self.mouse_mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||||
pub fn preferred_codec(&self) -> u8 {
|
pub fn preferred_codec(&self) -> u8 {
|
||||||
match self.codec.as_str() {
|
match self.codec.as_str() {
|
||||||
@@ -692,7 +603,6 @@ impl Default for Settings {
|
|||||||
forward_pad: String::new(),
|
forward_pad: String::new(),
|
||||||
compositor: "auto".into(),
|
compositor: "auto".into(),
|
||||||
touch_mode: "trackpad".into(),
|
touch_mode: "trackpad".into(),
|
||||||
mouse_mode: "capture".into(),
|
|
||||||
inhibit_shortcuts: true,
|
inhibit_shortcuts: true,
|
||||||
mic_enabled: false,
|
mic_enabled: false,
|
||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
@@ -704,10 +614,6 @@ impl Default for Settings {
|
|||||||
stats_verbosity: None,
|
stats_verbosity: None,
|
||||||
fullscreen_on_stream: true,
|
fullscreen_on_stream: true,
|
||||||
library_enabled: false,
|
library_enabled: false,
|
||||||
auto_wake: true,
|
|
||||||
invert_scroll: false,
|
|
||||||
speaker_device: String::new(),
|
|
||||||
mic_device: String::new(),
|
|
||||||
match_window: false,
|
match_window: false,
|
||||||
last_window_w: 0,
|
last_window_w: 0,
|
||||||
last_window_h: 0,
|
last_window_h: 0,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
|||||||
use crate::theme::{Fonts, DIM, W};
|
use crate::theme::{Fonts, DIM, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||||
use skia_safe::{Canvas, Rect};
|
use skia_safe::{Canvas, Rect};
|
||||||
|
|
||||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||||
@@ -29,11 +29,10 @@ enum RowId {
|
|||||||
Pad,
|
Pad,
|
||||||
PadType,
|
PadType,
|
||||||
Touch,
|
Touch,
|
||||||
Mouse,
|
|
||||||
Stats,
|
Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROWS: [RowId; 14] = [
|
const ROWS: [RowId; 13] = [
|
||||||
RowId::Resolution,
|
RowId::Resolution,
|
||||||
RowId::Refresh,
|
RowId::Refresh,
|
||||||
RowId::Bitrate,
|
RowId::Bitrate,
|
||||||
@@ -46,7 +45,6 @@ const ROWS: [RowId; 14] = [
|
|||||||
RowId::Pad,
|
RowId::Pad,
|
||||||
RowId::PadType,
|
RowId::PadType,
|
||||||
RowId::Touch,
|
RowId::Touch,
|
||||||
RowId::Mouse,
|
|
||||||
RowId::Stats,
|
RowId::Stats,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -253,7 +251,6 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
"Touch mode",
|
"Touch mode",
|
||||||
s.touch_mode().label().into(),
|
s.touch_mode().label().into(),
|
||||||
),
|
),
|
||||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
|
||||||
RowId::Stats => (
|
RowId::Stats => (
|
||||||
Some("Interface"),
|
Some("Interface"),
|
||||||
"Statistics overlay",
|
"Statistics overlay",
|
||||||
@@ -295,11 +292,6 @@ fn detail(id: RowId) -> &'static str {
|
|||||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||||
}
|
}
|
||||||
RowId::Mouse => {
|
|
||||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
|
||||||
for games), Desktop leaves it free and sends absolute positions. \
|
|
||||||
Ctrl+Alt+Shift+M switches live while streaming."
|
|
||||||
}
|
|
||||||
RowId::Stats => {
|
RowId::Stats => {
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||||
@@ -375,11 +367,6 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
|||||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||||
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
||||||
}
|
}
|
||||||
RowId::Mouse => {
|
|
||||||
let cur = MouseMode::ALL.iter().position(|m| *m == s.mouse_mode());
|
|
||||||
step_option(cur, MouseMode::ALL.len(), delta, wrap)
|
|
||||||
.map(|i| s.mouse_mode = MouseMode::ALL[i].as_name().to_string())
|
|
||||||
}
|
|
||||||
RowId::Stats => {
|
RowId::Stats => {
|
||||||
let cur = StatsVerbosity::ALL
|
let cur = StatsVerbosity::ALL
|
||||||
.iter()
|
.iter()
|
||||||
@@ -523,33 +510,6 @@ mod tests {
|
|||||||
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mouse_mode_steps_and_wraps() {
|
|
||||||
let (mut settings, pads) = ctx_parts();
|
|
||||||
assert_eq!(settings.mouse_mode, "capture");
|
|
||||||
let library = crate::library::LibraryShared::default();
|
|
||||||
let mut ctx = Ctx {
|
|
||||||
hosts: &[],
|
|
||||||
library: &library,
|
|
||||||
settings: &mut settings,
|
|
||||||
pads: &pads,
|
|
||||||
deck: false,
|
|
||||||
device_name: "t",
|
|
||||||
t: 0.0,
|
|
||||||
};
|
|
||||||
// Capture → Desktop, then a step past the end is a boundary.
|
|
||||||
assert!(
|
|
||||||
!adjust(RowId::Mouse, -1, false, &mut ctx),
|
|
||||||
"already first = thud"
|
|
||||||
);
|
|
||||||
assert!(adjust(RowId::Mouse, 1, false, &mut ctx));
|
|
||||||
assert_eq!(ctx.settings.mouse_mode, "desktop");
|
|
||||||
assert!(!adjust(RowId::Mouse, 1, false, &mut ctx), "last = thud");
|
|
||||||
// A wraps back to the first.
|
|
||||||
assert!(adjust(RowId::Mouse, 1, true, &mut ctx));
|
|
||||||
assert_eq!(ctx.settings.mouse_mode, "capture");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_value_snaps_to_first() {
|
fn unknown_value_snaps_to_first() {
|
||||||
let (mut settings, pads) = ctx_parts();
|
let (mut settings, pads) = ctx_parts();
|
||||||
|
|||||||
@@ -66,31 +66,7 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
|
|||||||
/// too ([`MIN_DRIVER_PROTOCOL_VERSION`]) and simply falls back to the re-arrival resize against it;
|
/// too ([`MIN_DRIVER_PROTOCOL_VERSION`]) and simply falls back to the re-arrival resize against it;
|
||||||
/// a v4 driver serving an older (v3-asserting) host fails that host's strict handshake — ship
|
/// a v4 driver serving an older (v3-asserting) host fails that host's strict handshake — ship
|
||||||
/// driver+host together, as ever.
|
/// driver+host together, as ever.
|
||||||
/// v5: ADDITIVE — the IddCx HARDWARE CURSOR channel (remote-desktop-sweep M2c):
|
pub const PROTOCOL_VERSION: u32 = 4;
|
||||||
/// [`control::AddRequest::hw_cursor`] (the former tail `_reserved` — same size, same offsets)
|
|
||||||
/// asks the driver to declare a hardware cursor for this monitor (DWM then EXCLUDES the pointer
|
|
||||||
/// from the desktop frame and delivers shape/position out-of-band), and
|
|
||||||
/// [`control::IOCTL_SET_CURSOR_CHANNEL`] delivers the host-created [`cursor::CursorShm`] section
|
|
||||||
/// the driver's cursor thread seqlock-publishes into. Nothing existing changed; the host gates
|
|
||||||
/// the feature on the handshake-reported version (`>= 5`) and keeps composited-cursor behavior
|
|
||||||
/// against older drivers.
|
|
||||||
/// v6: ADDITIVE — [`control::IOCTL_SET_CURSOR_FORWARD`] (the mid-stream cursor-render flip,
|
|
||||||
/// remote-desktop-sweep §8): the client's mouse-model flip (un)declares the hardware cursor on a
|
|
||||||
/// LIVE monitor, so the capture mouse model gets DWM's composited pointer back (full fidelity)
|
|
||||||
/// and the desktop model gets exclusion + forwarding. Nothing existing changed; against a v5
|
|
||||||
/// driver the unknown IOCTL fails and the host logs + keeps the declared-at-ADD behavior.
|
|
||||||
/// v6 tail ext (no bump, the `AddRequest` luminance-tail discipline):
|
|
||||||
/// [`control::AddReply::cursor_excluded`] — the driver reports whether its ADAPTER already
|
|
||||||
/// carries a hardware-cursor declare from an earlier session. A declare is IRREVOCABLE
|
|
||||||
/// (remote-desktop-sweep §8.6, proven on-glass) and its exclusion reaches EVERY later monitor of
|
|
||||||
/// the adapter, not just the declaring target (on-glass 2026-07-23: a declare on one target left
|
|
||||||
/// a different client's fresh target cursor-less): DWM never composites the software cursor back
|
|
||||||
/// into any of the adapter's frames until the adapter resets. The host uses the flag to
|
|
||||||
/// self-composite the pointer (GDI poller + blend) in sessions that never negotiate the
|
|
||||||
/// cursor channel — without it those sessions are silently cursor-less. Both skews degrade
|
|
||||||
/// cleanly: an old driver writes only the 20-byte reply prefix (host reads `0` = unknown/clean),
|
|
||||||
/// an old host retrieves a 20-byte buffer (driver writes just the prefix).
|
|
||||||
pub const PROTOCOL_VERSION: u32 = 6;
|
|
||||||
|
|
||||||
/// The OLDEST driver protocol this host still drives (v4 is additive over v3 — see the v4 note on
|
/// The OLDEST driver protocol this host still drives (v4 is additive over v3 — see the v4 note on
|
||||||
/// [`PROTOCOL_VERSION`]): a v3 driver lacks only `IOCTL_UPDATE_MODES`, which the host gates on the
|
/// [`PROTOCOL_VERSION`]): a v3 driver lacks only `IOCTL_UPDATE_MODES`, which the host gates on the
|
||||||
@@ -134,22 +110,6 @@ pub mod control {
|
|||||||
/// identity (saved per-monitor DPI) and the driver's swap-chain/stash machinery survive. A v3
|
/// identity (saved per-monitor DPI) and the driver's swap-chain/stash machinery survive. A v3
|
||||||
/// driver fails this unknown IOCTL → the host falls back to the re-arrival resize.
|
/// driver fails this unknown IOCTL → the host falls back to the re-arrival resize.
|
||||||
pub const IOCTL_UPDATE_MODES: u32 = ctl_code(0x907);
|
pub const IOCTL_UPDATE_MODES: u32 = ctl_code(0x907);
|
||||||
/// Deliver a monitor's hardware-cursor channel (v5): the handle VALUE of the unnamed
|
|
||||||
/// [`cursor::CursorShm`](crate::cursor) file mapping the host duplicated into the WUDFHost
|
|
||||||
/// (same delivery model as [`IOCTL_SET_FRAME_CHANNEL`], no event — the host polls the
|
|
||||||
/// seqlock at its encode-tick pace). Sent once after ADD, only for a monitor whose
|
|
||||||
/// [`AddRequest::hw_cursor`] was set. The driver maps it, calls
|
|
||||||
/// `IddCxMonitorSetupHardwareCursor`, and starts its cursor-query thread. Input
|
|
||||||
/// [`SetCursorChannelRequest`].
|
|
||||||
pub const IOCTL_SET_CURSOR_CHANNEL: u32 = ctl_code(0x908);
|
|
||||||
/// Flip a LIVE monitor's hardware-cursor declaration (v6, the mid-stream cursor-render
|
|
||||||
/// flip): `enable = 1` re-declares (`IddCxMonitorSetupHardwareCursor` — DWM excludes the
|
|
||||||
/// pointer, the query/shape machinery resumes), `enable = 0` un-declares (the driver stops
|
|
||||||
/// re-declaring on mode commits and asks the OS to revert to the software cursor — DWM
|
|
||||||
/// composites the pointer into the frame, the pre-channel behavior the capture mouse
|
|
||||||
/// model wants). Only meaningful for a monitor whose cursor channel was delivered. Input
|
|
||||||
/// [`SetCursorForwardRequest`].
|
|
||||||
pub const IOCTL_SET_CURSOR_FORWARD: u32 = ctl_code(0x909);
|
|
||||||
|
|
||||||
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
||||||
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
||||||
@@ -187,13 +147,9 @@ pub mod control {
|
|||||||
/// The client display's min luminance in MILLI-nits (0.001 cd/m² — the CTA min-luminance
|
/// The client display's min luminance in MILLI-nits (0.001 cd/m² — the CTA min-luminance
|
||||||
/// range lives well below 1 nit) → Desired Content Min Luminance. `0` = unknown.
|
/// range lives well below 1 nit) → Desired Content Min Luminance. `0` = unknown.
|
||||||
pub min_luminance_millinits: u32,
|
pub min_luminance_millinits: u32,
|
||||||
/// Non-zero = declare an IddCx HARDWARE CURSOR for this monitor (v5, remote-desktop-sweep
|
/// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding);
|
||||||
/// M2c): DWM stops compositing the pointer into the frame and the driver publishes
|
/// free expansion room for the next appended field.
|
||||||
/// shape/position into the [`cursor::CursorShm`](crate::cursor) section delivered by
|
pub _reserved: u32,
|
||||||
/// [`IOCTL_SET_CURSOR_CHANNEL`]. Byte-compatible with the old tail `_reserved` (offset 36):
|
|
||||||
/// an un-upgraded driver ignores it (cursor stays composited — the host already gates on
|
|
||||||
/// the handshake version, this is defense in depth), an un-upgraded host sends `0` (off).
|
|
||||||
pub hw_cursor: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`AddRequest`]'s size before the client-HDR luminance tail — the prefix an un-upgraded
|
/// [`AddRequest`]'s size before the client-HDR luminance tail — the prefix an un-upgraded
|
||||||
@@ -218,22 +174,8 @@ pub mod control {
|
|||||||
/// `DuplicateHandle`, then [`IOCTL_SET_FRAME_CHANNEL`]). Reported per-ADD, not per-open, so a
|
/// `DuplicateHandle`, then [`IOCTL_SET_FRAME_CHANNEL`]). Reported per-ADD, not per-open, so a
|
||||||
/// WUDFHost restart between sessions can never leave the host duplicating into a dead process.
|
/// WUDFHost restart between sessions can never leave the host duplicating into a dead process.
|
||||||
pub wudf_pid: u32,
|
pub wudf_pid: u32,
|
||||||
/// Non-zero = the ADAPTER already carries an IRREVOCABLE hardware-cursor declare from an
|
|
||||||
/// earlier session (remote-desktop-sweep §8.6; reach is adapter-wide, not per-target —
|
|
||||||
/// on-glass 2026-07-23): DWM excludes the pointer from every frame on every monitor until
|
|
||||||
/// the adapter resets, and a session without the cursor channel must
|
|
||||||
/// self-composite (GDI poller + blend) or stream a cursor-less desktop. Appended after
|
|
||||||
/// [`ADD_REPLY_LEGACY_SIZE`] under the same dual-size discipline as the `AddRequest`
|
|
||||||
/// luminance tail: an un-upgraded driver writes only the legacy prefix (the host's
|
|
||||||
/// zero-initialized buffer then reads `0` = unknown/clean), an un-upgraded host retrieves a
|
|
||||||
/// legacy-size buffer (the driver writes just the prefix).
|
|
||||||
pub cursor_excluded: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`AddReply`]'s size before the `cursor_excluded` tail — the prefix an un-upgraded driver
|
|
||||||
/// writes and an un-upgraded host retrieves (see the field docs).
|
|
||||||
pub const ADD_REPLY_LEGACY_SIZE: usize = 20;
|
|
||||||
|
|
||||||
/// `IOCTL_REMOVE` input.
|
/// `IOCTL_REMOVE` input.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
@@ -311,29 +253,6 @@ pub mod control {
|
|||||||
/// at the compile-time maximum; `ring_len` says how many entries are live).
|
/// at the compile-time maximum; `ring_len` says how many entries are live).
|
||||||
pub const RING_LEN_USIZE: usize = RING_LEN as usize;
|
pub const RING_LEN_USIZE: usize = RING_LEN as usize;
|
||||||
|
|
||||||
/// `IOCTL_SET_CURSOR_CHANNEL` input (v5): the hardware-cursor section for one monitor.
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
|
||||||
pub struct SetCursorChannelRequest {
|
|
||||||
/// The OS target id from [`AddReply`] — which monitor this channel belongs to.
|
|
||||||
pub target_id: u32,
|
|
||||||
pub _pad: u32,
|
|
||||||
/// The [`cursor::CursorShm`](crate::cursor) file-mapping handle VALUE, already duplicated
|
|
||||||
/// into the driver's WUDFHost process ([`AddReply::wudf_pid`]).
|
|
||||||
pub header_handle: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `IOCTL_SET_CURSOR_FORWARD` input (v6): the mid-stream cursor-render flip for one monitor.
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
|
||||||
pub struct SetCursorForwardRequest {
|
|
||||||
/// The OS target id from [`AddReply`] — which monitor to flip.
|
|
||||||
pub target_id: u32,
|
|
||||||
/// `1` = declare the hardware cursor (exclude + forward), `0` = un-declare (DWM
|
|
||||||
/// composites — the capture mouse model).
|
|
||||||
pub enable: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
|
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
|
||||||
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
|
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
|
||||||
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
|
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
|
||||||
@@ -350,18 +269,13 @@ pub mod control {
|
|||||||
assert!(offset_of!(AddRequest, max_luminance_nits) == ADD_REQUEST_LEGACY_SIZE);
|
assert!(offset_of!(AddRequest, max_luminance_nits) == ADD_REQUEST_LEGACY_SIZE);
|
||||||
assert!(offset_of!(AddRequest, max_frame_avg_nits) == 28);
|
assert!(offset_of!(AddRequest, max_frame_avg_nits) == 28);
|
||||||
assert!(offset_of!(AddRequest, min_luminance_millinits) == 32);
|
assert!(offset_of!(AddRequest, min_luminance_millinits) == 32);
|
||||||
// v5: the former tail `_reserved` — same offset, same total size (rename-only).
|
|
||||||
assert!(offset_of!(AddRequest, hw_cursor) == 36);
|
|
||||||
assert!(size_of::<AddRequest>() == 40);
|
|
||||||
|
|
||||||
assert!(size_of::<AddReply>() == 24);
|
assert!(size_of::<AddReply>() == 20);
|
||||||
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
||||||
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
||||||
assert!(offset_of!(AddReply, target_id) == 8);
|
assert!(offset_of!(AddReply, target_id) == 8);
|
||||||
assert!(offset_of!(AddReply, resolved_monitor_id) == 12);
|
assert!(offset_of!(AddReply, resolved_monitor_id) == 12);
|
||||||
assert!(offset_of!(AddReply, wudf_pid) == 16);
|
assert!(offset_of!(AddReply, wudf_pid) == 16);
|
||||||
// The cursor-excluded tail starts exactly at the legacy boundary (prefix-compat).
|
|
||||||
assert!(offset_of!(AddReply, cursor_excluded) == ADD_REPLY_LEGACY_SIZE);
|
|
||||||
|
|
||||||
assert!(size_of::<SetFrameChannelRequest>() == 32 + 8 * RING_LEN_USIZE);
|
assert!(size_of::<SetFrameChannelRequest>() == 32 + 8 * RING_LEN_USIZE);
|
||||||
assert!(offset_of!(SetFrameChannelRequest, target_id) == 0);
|
assert!(offset_of!(SetFrameChannelRequest, target_id) == 0);
|
||||||
@@ -374,13 +288,6 @@ pub mod control {
|
|||||||
assert!(size_of::<RemoveRequest>() == 8);
|
assert!(size_of::<RemoveRequest>() == 8);
|
||||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||||
|
|
||||||
assert!(size_of::<SetCursorChannelRequest>() == 16);
|
|
||||||
assert!(offset_of!(SetCursorChannelRequest, target_id) == 0);
|
|
||||||
assert!(offset_of!(SetCursorChannelRequest, header_handle) == 8);
|
|
||||||
assert!(size_of::<SetCursorForwardRequest>() == 8);
|
|
||||||
assert!(offset_of!(SetCursorForwardRequest, target_id) == 0);
|
|
||||||
assert!(offset_of!(SetCursorForwardRequest, enable) == 4);
|
|
||||||
|
|
||||||
assert!(size_of::<UpdateModesRequest>() == 24);
|
assert!(size_of::<UpdateModesRequest>() == 24);
|
||||||
assert!(offset_of!(UpdateModesRequest, session_id) == 0);
|
assert!(offset_of!(UpdateModesRequest, session_id) == 0);
|
||||||
assert!(offset_of!(UpdateModesRequest, width) == 8);
|
assert!(offset_of!(UpdateModesRequest, width) == 8);
|
||||||
@@ -1053,84 +960,6 @@ pub mod mouse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The v5 hardware-cursor channel (remote-desktop-sweep M2c): one unnamed file mapping per
|
|
||||||
/// monitor, host-created, delivered by handle value ([`control::IOCTL_SET_CURSOR_CHANNEL`]).
|
|
||||||
/// The DRIVER's cursor thread (woken by its IddCx `hNewCursorDataAvailable` event) seqlock-writes
|
|
||||||
/// shape + position + visibility; the HOST reads at its encode-tick pace — no event crosses the
|
|
||||||
/// boundary. Writer: bump [`CursorShm::seq`] to ODD, write fields (+ shape bytes when the OS said
|
|
||||||
/// the shape changed), bump to EVEN. Reader: read seq (retry while odd), copy, re-read seq —
|
|
||||||
/// unchanged ⇒ consistent snapshot. Position-only updates never touch the shape bytes, so a
|
|
||||||
/// reader that skips unchanged `shape_id`s never copies torn pixels.
|
|
||||||
pub mod cursor {
|
|
||||||
use bytemuck::{Pod, Zeroable};
|
|
||||||
|
|
||||||
/// First field of [`CursorShm`] — `b"PFCU"` little-endian; anything else = not attached yet.
|
|
||||||
pub const CURSOR_MAGIC: u32 = u32::from_le_bytes(*b"PFCU");
|
|
||||||
|
|
||||||
/// Max cursor side (px) the driver declares to the OS (`IDDCX_CURSOR_CAPS::MaxX/MaxY`) and
|
|
||||||
/// the section's shape buffer is sized for. Windows XL accessibility cursors top out here;
|
|
||||||
/// the host's wire forwarder downscales to its own cap anyway.
|
|
||||||
pub const CURSOR_SHAPE_MAX: u32 = 256;
|
|
||||||
|
|
||||||
/// Shape-buffer bytes: 32-bpp at the declared max.
|
|
||||||
pub const CURSOR_SHAPE_BYTES: usize = (CURSOR_SHAPE_MAX * CURSOR_SHAPE_MAX * 4) as usize;
|
|
||||||
|
|
||||||
/// Byte offset of the shape pixels inside the section (one cache-line-ish header).
|
|
||||||
pub const CURSOR_SHAPE_OFFSET: usize = 64;
|
|
||||||
|
|
||||||
/// Total section size.
|
|
||||||
pub const CURSOR_SHM_SIZE: usize = CURSOR_SHAPE_OFFSET + CURSOR_SHAPE_BYTES;
|
|
||||||
|
|
||||||
/// `IDDCX_CURSOR_SHAPE_TYPE` values mirrored for the host (the driver writes the OS value
|
|
||||||
/// verbatim into [`CursorShm::cursor_type`]).
|
|
||||||
pub const CURSOR_TYPE_MASKED_COLOR: u32 = 1;
|
|
||||||
pub const CURSOR_TYPE_ALPHA: u32 = 2;
|
|
||||||
|
|
||||||
/// The section header (the shape pixels follow at [`CURSOR_SHAPE_OFFSET`]). `x`/`y` are the
|
|
||||||
/// shape's TOP-LEFT in desktop coordinates (the IddCx `IDARG_OUT_QUERY_HWCURSOR::X/Y`
|
|
||||||
/// convention — position − hotspot, can be negative); `shape_id` is the OS's per-set counter
|
|
||||||
/// (bumps on every shape set, the overlay serial); pixels are the OS's 32-bpp rows at
|
|
||||||
/// `pitch` bytes (BGRA for ALPHA; color+mask for MASKED_COLOR — the host converts).
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CursorShm {
|
|
||||||
pub magic: u32,
|
|
||||||
/// Seqlock: odd = writer mid-update.
|
|
||||||
pub seq: u32,
|
|
||||||
pub visible: u32,
|
|
||||||
pub cursor_type: u32,
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
pub shape_id: u32,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
pub pitch: u32,
|
|
||||||
pub hot_x: u32,
|
|
||||||
pub hot_y: u32,
|
|
||||||
/// Reserved expansion room up to [`CURSOR_SHAPE_OFFSET`].
|
|
||||||
pub _reserved: [u32; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout is load-bearing across the process boundary — pin it.
|
|
||||||
const _: () = {
|
|
||||||
use core::mem::{offset_of, size_of};
|
|
||||||
assert!(size_of::<CursorShm>() == 64);
|
|
||||||
assert!(size_of::<CursorShm>() <= CURSOR_SHAPE_OFFSET);
|
|
||||||
assert!(offset_of!(CursorShm, magic) == 0);
|
|
||||||
assert!(offset_of!(CursorShm, seq) == 4);
|
|
||||||
assert!(offset_of!(CursorShm, visible) == 8);
|
|
||||||
assert!(offset_of!(CursorShm, cursor_type) == 12);
|
|
||||||
assert!(offset_of!(CursorShm, x) == 16);
|
|
||||||
assert!(offset_of!(CursorShm, y) == 20);
|
|
||||||
assert!(offset_of!(CursorShm, shape_id) == 24);
|
|
||||||
assert!(offset_of!(CursorShm, width) == 28);
|
|
||||||
assert!(offset_of!(CursorShm, height) == 32);
|
|
||||||
assert!(offset_of!(CursorShm, pitch) == 36);
|
|
||||||
assert!(offset_of!(CursorShm, hot_x) == 40);
|
|
||||||
assert!(offset_of!(CursorShm, hot_y) == 44);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1250,7 +1079,7 @@ mod tests {
|
|||||||
max_luminance_nits: 800,
|
max_luminance_nits: 800,
|
||||||
max_frame_avg_nits: 400,
|
max_frame_avg_nits: 400,
|
||||||
min_luminance_millinits: 50, // 0.05 nits
|
min_luminance_millinits: 50, // 0.05 nits
|
||||||
hw_cursor: 1,
|
_reserved: 0,
|
||||||
};
|
};
|
||||||
let bytes = bytemuck::bytes_of(&req);
|
let bytes = bytemuck::bytes_of(&req);
|
||||||
assert_eq!(bytes.len(), 40);
|
assert_eq!(bytes.len(), 40);
|
||||||
@@ -1280,19 +1109,14 @@ mod tests {
|
|||||||
target_id: 262,
|
target_id: 262,
|
||||||
resolved_monitor_id: 7,
|
resolved_monitor_id: 7,
|
||||||
wudf_pid: 4242,
|
wudf_pid: 4242,
|
||||||
cursor_excluded: 1,
|
|
||||||
};
|
};
|
||||||
let rbytes = bytemuck::bytes_of(&reply);
|
let rbytes = bytemuck::bytes_of(&reply);
|
||||||
assert_eq!(rbytes.len(), 24);
|
assert_eq!(rbytes.len(), 20);
|
||||||
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
|
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
|
||||||
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
|
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
|
||||||
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
|
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
|
||||||
// The v2 duplication-target pid trails at offset 16.
|
// The v2 duplication-target pid trails at offset 16.
|
||||||
assert_eq!(rbytes[16..20], 4242u32.to_le_bytes());
|
assert_eq!(rbytes[16..20], 4242u32.to_le_bytes());
|
||||||
// The cursor-excluded tail rides after the legacy boundary; an un-upgraded driver writes
|
|
||||||
// only the prefix, so a zero-filled tail reads as "unknown/clean" (see the field docs).
|
|
||||||
assert_eq!(rbytes[20..24], 1u32.to_le_bytes());
|
|
||||||
assert_eq!(control::ADD_REPLY_LEGACY_SIZE, 20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1337,39 +1161,11 @@ mod tests {
|
|||||||
req
|
req
|
||||||
);
|
);
|
||||||
assert_eq!(bytes[8..12], 2560u32.to_le_bytes());
|
assert_eq!(bytes[8..12], 2560u32.to_le_bytes());
|
||||||
// The compat window: v4–v6 are additive over v3, so the host floor stays at 3.
|
// The compat window: v4 is additive over v3, so the host floor stays one below.
|
||||||
assert_eq!(PROTOCOL_VERSION, 6);
|
assert_eq!(PROTOCOL_VERSION, 4);
|
||||||
assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3);
|
assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cursor_shm_layout_is_pinned() {
|
|
||||||
use cursor::*;
|
|
||||||
// The header must leave the shape offset intact whatever grows inside `_reserved`.
|
|
||||||
assert_eq!(core::mem::size_of::<CursorShm>(), 64);
|
|
||||||
assert_eq!(CURSOR_SHM_SIZE, 64 + 256 * 256 * 4);
|
|
||||||
assert_eq!(CURSOR_MAGIC, u32::from_le_bytes(*b"PFCU"));
|
|
||||||
// Seqlock snapshot discipline survives a bytemuck roundtrip.
|
|
||||||
let hdr = CursorShm {
|
|
||||||
magic: CURSOR_MAGIC,
|
|
||||||
seq: 2,
|
|
||||||
visible: 1,
|
|
||||||
cursor_type: CURSOR_TYPE_ALPHA,
|
|
||||||
x: -3,
|
|
||||||
y: 7,
|
|
||||||
shape_id: 42,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
pitch: 128,
|
|
||||||
hot_x: 4,
|
|
||||||
hot_y: 5,
|
|
||||||
_reserved: [0; 4],
|
|
||||||
};
|
|
||||||
let bytes = bytemuck::bytes_of(&hdr);
|
|
||||||
assert_eq!(*bytemuck::from_bytes::<CursorShm>(bytes), hdr);
|
|
||||||
assert_eq!(bytes[16..20], (-3i32).to_le_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gamepad_names_and_magics_are_stable() {
|
fn gamepad_names_and_magics_are_stable() {
|
||||||
assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
|
assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ tracing = "0.1"
|
|||||||
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
|
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dev-dependencies]
|
|
||||||
# The QSV live e2e drives the REAL HdrP010Converter output (an RTV-written, ring-profile P010
|
|
||||||
# texture) into the encoder — the one seam the CPU-upload tests can't reach.
|
|
||||||
pf-capture = { path = "../pf-capture" }
|
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||||
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
|
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
|
||||||
openh264 = "0.9"
|
openh264 = "0.9"
|
||||||
|
|||||||
@@ -32,46 +32,6 @@ pub struct EncodedFrame {
|
|||||||
pub chunk_aligned: bool,
|
pub chunk_aligned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One slice-boundary chunk of an encoded AU, emitted by a chunked-poll backend
|
|
||||||
/// ([`Encoder::poll_chunk`], latency plan §7 LN1): the encoder hands out completed slices while
|
|
||||||
/// the rest of the frame is still encoding, so packetize/FEC/pacing can overlap the encode tail.
|
|
||||||
/// The chunks of one AU concatenate to exactly the bytes [`Encoder::poll`] would have returned,
|
|
||||||
/// and every cut lands on an Annex-B NAL boundary (slice starts). AU-level metadata
|
|
||||||
/// (`pts_ns`/`keyframe`/`recovery_anchor`/`chunk_aligned`) is authoritative on the FIRST chunk
|
|
||||||
/// (`first`) — the host opens the wire frame from it; `last` closes the AU. `keyframe` on a
|
|
||||||
/// non-final chunk is the encoder's own prediction (exact under the P-only/infinite-GOP config —
|
|
||||||
/// the driver only ever emits an IDR we asked for); the final chunk re-checks it against the
|
|
||||||
/// driver's reported picture type.
|
|
||||||
pub struct AuChunk {
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
pub pts_ns: u64,
|
|
||||||
pub keyframe: bool,
|
|
||||||
/// See [`EncodedFrame::recovery_anchor`].
|
|
||||||
pub recovery_anchor: bool,
|
|
||||||
/// See [`EncodedFrame::chunk_aligned`].
|
|
||||||
pub chunk_aligned: bool,
|
|
||||||
/// Opens the AU (carries the authoritative AU metadata).
|
|
||||||
pub first: bool,
|
|
||||||
/// Closes the AU (the concatenation is complete; the encoder's in-flight slot is released).
|
|
||||||
pub last: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuChunk {
|
|
||||||
/// A whole AU as a single self-closing chunk — what every non-chunked backend's
|
|
||||||
/// [`Encoder::poll_chunk`] default emits, so a chunk consumer needs no per-backend fork.
|
|
||||||
pub fn whole(f: EncodedFrame) -> Self {
|
|
||||||
AuChunk {
|
|
||||||
data: f.data,
|
|
||||||
pts_ns: f.pts_ns,
|
|
||||||
keyframe: f.keyframe,
|
|
||||||
recovery_anchor: f.recovery_anchor,
|
|
||||||
chunk_aligned: f.chunk_aligned,
|
|
||||||
first: true,
|
|
||||||
last: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Codec selection negotiated with the client.
|
/// Codec selection negotiated with the client.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Codec {
|
pub enum Codec {
|
||||||
@@ -259,11 +219,6 @@ pub struct EncoderCaps {
|
|||||||
|
|
||||||
/// A hardware encoder. One per session; runs on the encode thread.
|
/// A hardware encoder. One per session; runs on the encode thread.
|
||||||
pub trait Encoder: Send {
|
pub trait Encoder: Send {
|
||||||
/// Submit one captured frame for encoding. Lifetime contract: the caller must keep `frame`
|
|
||||||
/// (and its GPU payload) alive until this frame's AU has been returned by
|
|
||||||
/// [`poll`](Self::poll) — a stream-ordered backend (Linux direct-NVENC's IO-stream binding)
|
|
||||||
/// may still be reading the payload asynchronously after `submit` returns. Both host encode
|
|
||||||
/// loops already hold the frame across their poll drain; new callers must do the same.
|
|
||||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||||
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
||||||
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
||||||
@@ -309,37 +264,8 @@ pub trait Encoder: Send {
|
|||||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
|
|
||||||
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
|
||||||
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
|
||||||
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
|
||||||
/// switch may be deferred to the next safe point internally. `false` from the default impl =
|
|
||||||
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
|
|
||||||
fn set_pipelined(&mut self, _on: bool) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
/// Pull the next encoded AU if one is ready.
|
/// Pull the next encoded AU if one is ready.
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||||
/// Whether [`poll_chunk`](Self::poll_chunk) currently emits sub-AU chunks — i.e. the LIVE
|
|
||||||
/// session has slice-level readback armed (Linux direct-NVENC with the
|
|
||||||
/// `PUNKTFUNK_NVENC_SLICES` and `PUNKTFUNK_NVENC_SUBFRAME` knobs on a sync depth-1
|
|
||||||
/// retrieve). Dynamic, not static: a pipelined-retrieve escalation or a session rebuild can
|
|
||||||
/// turn it off — re-query per AU, never cache across frames. `false` (the default) means
|
|
||||||
/// `poll_chunk` degrades to one whole-AU chunk per frame.
|
|
||||||
fn supports_chunked_poll(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
/// Pull the next slice-boundary chunk of the oldest in-flight AU (latency plan §7 LN1).
|
|
||||||
/// Semantics when chunking is live: BLOCKS until the next chunk is readable, and the final
|
|
||||||
/// (`last`) chunk blocks exactly like [`poll`](Self::poll) does — the depth-1 pump treats
|
|
||||||
/// `None` as re-poll-next-tick, so a non-blocking tail would ride the AU one tick late (the
|
|
||||||
/// `6dc195f9` Vulkan bug class). `Ok(None)` only when no AU is in flight. Each AU must be
|
|
||||||
/// drained through ONE method: calling `poll` on a partially-chunked AU is a caller bug (the
|
|
||||||
/// backend errors rather than double-emit bytes). Default: delegates to `poll`, wrapping the
|
|
||||||
/// whole AU as a single `first && last` chunk.
|
|
||||||
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
|
|
||||||
Ok(self.poll()?.map(AuChunk::whole))
|
|
||||||
}
|
|
||||||
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||||
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
||||||
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
||||||
@@ -367,16 +293,6 @@ pub trait Encoder: Send {
|
|||||||
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
||||||
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
||||||
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
||||||
/// How many frames the CAPTURER guarantees the encoder may hold in flight before it starts
|
|
||||||
/// reusing an input texture (`Capturer::pipeline_depth`). Backends that encode the capturer's
|
|
||||||
/// textures IN PLACE — no `CopyResource` — must not pipeline deeper than this: the capturer
|
|
||||||
/// rotates its output ring per delivered frame with no regard for encode completion, so a
|
|
||||||
/// deeper pipeline lets it overwrite a texture mid-encode. That is visual corruption (torn or
|
|
||||||
/// mixed frames), not UB, so it fails silently and intermittently.
|
|
||||||
///
|
|
||||||
/// Called once by the session glue after the capturer is known; a backend that copies its
|
|
||||||
/// input, or is synchronous, ignores it. Default: no-op.
|
|
||||||
fn set_input_ring_depth(&mut self, _depth: usize) {}
|
|
||||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||||
fn flush(&mut self) -> Result<()>;
|
fn flush(&mut self) -> Result<()>;
|
||||||
@@ -496,23 +412,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The whole-AU chunk (every non-chunked backend's `poll_chunk` shape) must carry the AU's
|
|
||||||
/// metadata verbatim and be self-closing (`first && last`).
|
|
||||||
#[test]
|
|
||||||
fn whole_au_chunk_is_self_closing() {
|
|
||||||
let c = AuChunk::whole(EncodedFrame {
|
|
||||||
data: vec![0, 0, 0, 1, 0x40],
|
|
||||||
pts_ns: 42,
|
|
||||||
keyframe: true,
|
|
||||||
recovery_anchor: true,
|
|
||||||
chunk_aligned: false,
|
|
||||||
});
|
|
||||||
assert_eq!(c.data, vec![0, 0, 0, 1, 0x40]);
|
|
||||||
assert_eq!(c.pts_ns, 42);
|
|
||||||
assert!(c.keyframe && c.recovery_anchor && !c.chunk_aligned);
|
|
||||||
assert!(c.first && c.last);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
|
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
|
||||||
#[test]
|
#[test]
|
||||||
fn codec_wire_roundtrip_and_label() {
|
fn codec_wire_roundtrip_and_label() {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Cursor-overlay blend kernels for the CUDA/NVENC path (cursor-as-metadata). The cursor bitmap is
|
||||||
|
// straight-alpha RGBA, row-packed (stride = curW*4). Blended into the encoder-OWNED NVENC input
|
||||||
|
// surface — never the compositor's dmabuf. One thread per cursor pixel (ARGB / YUV444) or per 2x2
|
||||||
|
// chroma block (NV12). Coefficients are BT.709 limited, matching rgb2yuv.comp so the cursor colour
|
||||||
|
// matches the rest of the frame regardless of which zero-copy backend encodes it.
|
||||||
|
//
|
||||||
|
// Build (regenerate cursor_blend.ptx after editing):
|
||||||
|
// nvcc -ptx -arch=compute_75 cursor_blend.cu -o cursor_blend.ptx
|
||||||
|
// PTX is JIT'd by the driver forward to the actual GPU, so a compute_75 (Turing) baseline runs on
|
||||||
|
// every Turing-or-newer NVENC GPU. (CUDA 13's nvcc no longer targets pre-Turing archs.)
|
||||||
|
|
||||||
|
typedef unsigned char u8;
|
||||||
|
|
||||||
|
__device__ __forceinline__ u8 blend8(int dst, int src, int a) {
|
||||||
|
return (u8)((src * a + dst * (255 - a)) / 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packed 4-byte surface. NVENC's ARGB format stores bytes B,G,R,A in memory; the cursor is R,G,B,A.
|
||||||
|
extern "C" __global__ void blend_argb(
|
||||||
|
u8* surf, int pitch, int surfW, int surfH,
|
||||||
|
const u8* cur, int curW, int curH, int ox, int oy)
|
||||||
|
{
|
||||||
|
int cx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int cy = blockIdx.y * blockDim.y + threadIdx.y;
|
||||||
|
if (cx >= curW || cy >= curH) return;
|
||||||
|
int px = ox + cx, py = oy + cy;
|
||||||
|
if (px < 0 || py < 0 || px >= surfW || py >= surfH) return;
|
||||||
|
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||||
|
int a = s[3];
|
||||||
|
if (a == 0) return;
|
||||||
|
u8* d = surf + (size_t)py * pitch + (size_t)px * 4;
|
||||||
|
d[0] = blend8(d[0], s[2], a); // B <- cursor B
|
||||||
|
d[1] = blend8(d[1], s[1], a); // G <- cursor G
|
||||||
|
d[2] = blend8(d[2], s[0], a); // R <- cursor R
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planar YUV444: three full-res planes stacked at base, base+plane, base+2*plane (plane=pitch*surfH).
|
||||||
|
extern "C" __global__ void blend_yuv444(
|
||||||
|
u8* base, int pitch, int surfW, int surfH,
|
||||||
|
const u8* cur, int curW, int curH, int ox, int oy)
|
||||||
|
{
|
||||||
|
int cx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int cy = blockIdx.y * blockDim.y + threadIdx.y;
|
||||||
|
if (cx >= curW || cy >= curH) return;
|
||||||
|
int px = ox + cx, py = oy + cy;
|
||||||
|
if (px < 0 || py < 0 || px >= surfW || py >= surfH) return;
|
||||||
|
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||||
|
int a = s[3];
|
||||||
|
if (a == 0) return;
|
||||||
|
float R = s[0], G = s[1], B = s[2];
|
||||||
|
int Y = (int)(16.0f + 0.1826f * R + 0.6142f * G + 0.0620f * B + 0.5f);
|
||||||
|
int U = (int)(128.0f - 0.1006f * R - 0.3386f * G + 0.4392f * B + 0.5f);
|
||||||
|
int V = (int)(128.0f + 0.4392f * R - 0.3989f * G - 0.0403f * B + 0.5f);
|
||||||
|
size_t plane = (size_t)pitch * surfH;
|
||||||
|
u8* yp = base + (size_t)py * pitch + px;
|
||||||
|
u8* up = base + plane + (size_t)py * pitch + px;
|
||||||
|
u8* vp = base + 2 * plane + (size_t)py * pitch + px;
|
||||||
|
*yp = blend8(*yp, Y, a);
|
||||||
|
*up = blend8(*up, U, a);
|
||||||
|
*vp = blend8(*vp, V, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NV12: full-res Y plane + interleaved half-res UV plane. One thread per 2x2 luma block; each blends
|
||||||
|
// up to four Y samples and one (alpha-weighted) UV sample.
|
||||||
|
extern "C" __global__ void blend_nv12(
|
||||||
|
u8* yb, int yPitch, u8* uvb, int uvPitch, int surfW, int surfH,
|
||||||
|
const u8* cur, int curW, int curH, int ox, int oy)
|
||||||
|
{
|
||||||
|
int bx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int by = blockIdx.y * blockDim.y + threadIdx.y;
|
||||||
|
int base_cx = bx * 2, base_cy = by * 2;
|
||||||
|
if (base_cx >= curW || base_cy >= curH) return;
|
||||||
|
float ua = 0.0f, va = 0.0f, wa = 0.0f;
|
||||||
|
int cnt = 0;
|
||||||
|
for (int j = 0; j < 2; j++) {
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
int cx = base_cx + i, cy = base_cy + j;
|
||||||
|
if (cx >= curW || cy >= curH) continue;
|
||||||
|
int px = ox + cx, py = oy + cy;
|
||||||
|
if (px < 0 || py < 0 || px >= surfW || py >= surfH) continue;
|
||||||
|
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||||
|
int a = s[3];
|
||||||
|
if (a == 0) continue;
|
||||||
|
float R = s[0], G = s[1], B = s[2];
|
||||||
|
int Y = (int)(16.0f + 0.1826f * R + 0.6142f * G + 0.0620f * B + 0.5f);
|
||||||
|
u8* yp = yb + (size_t)py * yPitch + px;
|
||||||
|
*yp = blend8(*yp, Y, a);
|
||||||
|
ua += (128.0f - 0.1006f * R - 0.3386f * G + 0.4392f * B) * a;
|
||||||
|
va += (128.0f + 0.4392f * R - 0.3989f * G - 0.0403f * B) * a;
|
||||||
|
wa += a;
|
||||||
|
cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wa <= 0.0f || cnt == 0) return;
|
||||||
|
// The chroma sample covering this block's top-left surface pixel.
|
||||||
|
int uvx = (ox + base_cx) / 2;
|
||||||
|
int uvy = (oy + base_cy) / 2;
|
||||||
|
if (uvx < 0 || uvy < 0 || uvx * 2 >= surfW || uvy * 2 >= surfH) return;
|
||||||
|
int U = (int)(ua / wa + 0.5f);
|
||||||
|
int V = (int)(va / wa + 0.5f);
|
||||||
|
int amean = (int)(wa / cnt + 0.5f);
|
||||||
|
u8* uv = uvb + (size_t)uvy * uvPitch + (size_t)uvx * 2;
|
||||||
|
uv[0] = blend8(uv[0], U, amean);
|
||||||
|
uv[1] = blend8(uv[1], V, amean);
|
||||||
|
}
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
//
|
||||||
|
// Generated by NVIDIA NVVM Compiler
|
||||||
|
//
|
||||||
|
// Compiler Build ID: CL-38244171
|
||||||
|
// Cuda compilation tools, release 13.3, V13.3.73
|
||||||
|
// Based on NVVM 7.0.1
|
||||||
|
//
|
||||||
|
|
||||||
|
.version 9.3
|
||||||
|
.target sm_75
|
||||||
|
.address_size 64
|
||||||
|
|
||||||
|
// .globl blend_argb
|
||||||
|
|
||||||
|
.visible .entry blend_argb(
|
||||||
|
.param .u64 blend_argb_param_0,
|
||||||
|
.param .u32 blend_argb_param_1,
|
||||||
|
.param .u32 blend_argb_param_2,
|
||||||
|
.param .u32 blend_argb_param_3,
|
||||||
|
.param .u64 blend_argb_param_4,
|
||||||
|
.param .u32 blend_argb_param_5,
|
||||||
|
.param .u32 blend_argb_param_6,
|
||||||
|
.param .u32 blend_argb_param_7,
|
||||||
|
.param .u32 blend_argb_param_8
|
||||||
|
)
|
||||||
|
{
|
||||||
|
.reg .pred %p<10>;
|
||||||
|
.reg .b16 %rs<2>;
|
||||||
|
.reg .b32 %r<34>;
|
||||||
|
.reg .b64 %rd<17>;
|
||||||
|
|
||||||
|
|
||||||
|
ld.param.u64 %rd2, [blend_argb_param_0];
|
||||||
|
ld.param.u32 %r5, [blend_argb_param_1];
|
||||||
|
ld.param.u32 %r6, [blend_argb_param_2];
|
||||||
|
ld.param.u32 %r7, [blend_argb_param_3];
|
||||||
|
ld.param.u64 %rd3, [blend_argb_param_4];
|
||||||
|
ld.param.u32 %r8, [blend_argb_param_5];
|
||||||
|
ld.param.u32 %r11, [blend_argb_param_6];
|
||||||
|
ld.param.u32 %r9, [blend_argb_param_7];
|
||||||
|
ld.param.u32 %r10, [blend_argb_param_8];
|
||||||
|
mov.u32 %r12, %ntid.x;
|
||||||
|
mov.u32 %r13, %ctaid.x;
|
||||||
|
mov.u32 %r14, %tid.x;
|
||||||
|
mad.lo.s32 %r1, %r13, %r12, %r14;
|
||||||
|
mov.u32 %r15, %ntid.y;
|
||||||
|
mov.u32 %r16, %ctaid.y;
|
||||||
|
mov.u32 %r17, %tid.y;
|
||||||
|
mad.lo.s32 %r2, %r16, %r15, %r17;
|
||||||
|
setp.ge.s32 %p1, %r1, %r8;
|
||||||
|
setp.ge.s32 %p2, %r2, %r11;
|
||||||
|
or.pred %p3, %p1, %p2;
|
||||||
|
@%p3 bra $L__BB0_4;
|
||||||
|
|
||||||
|
add.s32 %r3, %r1, %r9;
|
||||||
|
add.s32 %r4, %r2, %r10;
|
||||||
|
or.b32 %r18, %r4, %r3;
|
||||||
|
setp.lt.s32 %p4, %r18, 0;
|
||||||
|
setp.ge.s32 %p5, %r3, %r6;
|
||||||
|
or.pred %p6, %p5, %p4;
|
||||||
|
setp.ge.s32 %p7, %r4, %r7;
|
||||||
|
or.pred %p8, %p7, %p6;
|
||||||
|
@%p8 bra $L__BB0_4;
|
||||||
|
|
||||||
|
mad.lo.s32 %r19, %r2, %r8, %r1;
|
||||||
|
mul.wide.s32 %rd4, %r19, 4;
|
||||||
|
cvta.to.global.u64 %rd5, %rd3;
|
||||||
|
add.s64 %rd1, %rd5, %rd4;
|
||||||
|
ld.global.u8 %rs1, [%rd1+3];
|
||||||
|
setp.eq.s16 %p9, %rs1, 0;
|
||||||
|
@%p9 bra $L__BB0_4;
|
||||||
|
|
||||||
|
cvt.u32.u16 %r20, %rs1;
|
||||||
|
mul.wide.s32 %rd6, %r4, %r5;
|
||||||
|
mul.wide.s32 %rd7, %r3, 4;
|
||||||
|
add.s64 %rd8, %rd6, %rd7;
|
||||||
|
cvta.to.global.u64 %rd9, %rd2;
|
||||||
|
add.s64 %rd10, %rd9, %rd8;
|
||||||
|
ld.global.u8 %r21, [%rd10];
|
||||||
|
ld.global.u8 %r22, [%rd1+2];
|
||||||
|
xor.b32 %r23, %r20, 255;
|
||||||
|
mul.lo.s32 %r24, %r23, %r21;
|
||||||
|
mad.lo.s32 %r25, %r22, %r20, %r24;
|
||||||
|
mul.wide.u32 %rd11, %r25, -2139062143;
|
||||||
|
shr.u64 %rd12, %rd11, 39;
|
||||||
|
st.global.u8 [%rd10], %rd12;
|
||||||
|
ld.global.u8 %r26, [%rd10+1];
|
||||||
|
ld.global.u8 %r27, [%rd1+1];
|
||||||
|
mul.lo.s32 %r28, %r23, %r26;
|
||||||
|
mad.lo.s32 %r29, %r27, %r20, %r28;
|
||||||
|
mul.wide.u32 %rd13, %r29, -2139062143;
|
||||||
|
shr.u64 %rd14, %rd13, 39;
|
||||||
|
st.global.u8 [%rd10+1], %rd14;
|
||||||
|
ld.global.u8 %r30, [%rd10+2];
|
||||||
|
ld.global.u8 %r31, [%rd1];
|
||||||
|
mul.lo.s32 %r32, %r23, %r30;
|
||||||
|
mad.lo.s32 %r33, %r31, %r20, %r32;
|
||||||
|
mul.wide.u32 %rd15, %r33, -2139062143;
|
||||||
|
shr.u64 %rd16, %rd15, 39;
|
||||||
|
st.global.u8 [%rd10+2], %rd16;
|
||||||
|
|
||||||
|
$L__BB0_4:
|
||||||
|
ret;
|
||||||
|
|
||||||
|
}
|
||||||
|
// .globl blend_yuv444
|
||||||
|
.visible .entry blend_yuv444(
|
||||||
|
.param .u64 blend_yuv444_param_0,
|
||||||
|
.param .u32 blend_yuv444_param_1,
|
||||||
|
.param .u32 blend_yuv444_param_2,
|
||||||
|
.param .u32 blend_yuv444_param_3,
|
||||||
|
.param .u64 blend_yuv444_param_4,
|
||||||
|
.param .u32 blend_yuv444_param_5,
|
||||||
|
.param .u32 blend_yuv444_param_6,
|
||||||
|
.param .u32 blend_yuv444_param_7,
|
||||||
|
.param .u32 blend_yuv444_param_8
|
||||||
|
)
|
||||||
|
{
|
||||||
|
.reg .pred %p<10>;
|
||||||
|
.reg .b16 %rs<5>;
|
||||||
|
.reg .f32 %f<16>;
|
||||||
|
.reg .b32 %r<49>;
|
||||||
|
.reg .b64 %rd<14>;
|
||||||
|
|
||||||
|
|
||||||
|
ld.param.u64 %rd2, [blend_yuv444_param_0];
|
||||||
|
ld.param.u32 %r5, [blend_yuv444_param_1];
|
||||||
|
ld.param.u32 %r6, [blend_yuv444_param_2];
|
||||||
|
ld.param.u32 %r7, [blend_yuv444_param_3];
|
||||||
|
ld.param.u64 %rd3, [blend_yuv444_param_4];
|
||||||
|
ld.param.u32 %r8, [blend_yuv444_param_5];
|
||||||
|
ld.param.u32 %r11, [blend_yuv444_param_6];
|
||||||
|
ld.param.u32 %r9, [blend_yuv444_param_7];
|
||||||
|
ld.param.u32 %r10, [blend_yuv444_param_8];
|
||||||
|
mov.u32 %r12, %ntid.x;
|
||||||
|
mov.u32 %r13, %ctaid.x;
|
||||||
|
mov.u32 %r14, %tid.x;
|
||||||
|
mad.lo.s32 %r1, %r13, %r12, %r14;
|
||||||
|
mov.u32 %r15, %ntid.y;
|
||||||
|
mov.u32 %r16, %ctaid.y;
|
||||||
|
mov.u32 %r17, %tid.y;
|
||||||
|
mad.lo.s32 %r2, %r16, %r15, %r17;
|
||||||
|
setp.ge.s32 %p1, %r1, %r8;
|
||||||
|
setp.ge.s32 %p2, %r2, %r11;
|
||||||
|
or.pred %p3, %p1, %p2;
|
||||||
|
@%p3 bra $L__BB1_4;
|
||||||
|
|
||||||
|
add.s32 %r3, %r1, %r9;
|
||||||
|
add.s32 %r4, %r2, %r10;
|
||||||
|
or.b32 %r18, %r4, %r3;
|
||||||
|
setp.lt.s32 %p4, %r18, 0;
|
||||||
|
setp.ge.s32 %p5, %r3, %r6;
|
||||||
|
or.pred %p6, %p5, %p4;
|
||||||
|
setp.ge.s32 %p7, %r4, %r7;
|
||||||
|
or.pred %p8, %p7, %p6;
|
||||||
|
@%p8 bra $L__BB1_4;
|
||||||
|
|
||||||
|
mad.lo.s32 %r19, %r2, %r8, %r1;
|
||||||
|
mul.wide.s32 %rd4, %r19, 4;
|
||||||
|
cvta.to.global.u64 %rd5, %rd3;
|
||||||
|
add.s64 %rd1, %rd5, %rd4;
|
||||||
|
ld.global.u8 %rs1, [%rd1+3];
|
||||||
|
setp.eq.s16 %p9, %rs1, 0;
|
||||||
|
@%p9 bra $L__BB1_4;
|
||||||
|
|
||||||
|
cvt.u32.u16 %r20, %rs1;
|
||||||
|
ld.global.u8 %rs2, [%rd1];
|
||||||
|
cvt.rn.f32.u16 %f1, %rs2;
|
||||||
|
ld.global.u8 %rs3, [%rd1+1];
|
||||||
|
cvt.rn.f32.u16 %f2, %rs3;
|
||||||
|
ld.global.u8 %rs4, [%rd1+2];
|
||||||
|
cvt.rn.f32.u16 %f3, %rs4;
|
||||||
|
fma.rn.f32 %f4, %f1, 0f3E3AFB7F, 0f41800000;
|
||||||
|
fma.rn.f32 %f5, %f2, 0f3F1D3C36, %f4;
|
||||||
|
fma.rn.f32 %f6, %f3, 0f3D7DF3B6, %f5;
|
||||||
|
add.f32 %f7, %f6, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r21, %f7;
|
||||||
|
fma.rn.f32 %f8, %f1, 0fBDCE075F, 0f43000000;
|
||||||
|
fma.rn.f32 %f9, %f2, 0fBEAD5CFB, %f8;
|
||||||
|
fma.rn.f32 %f10, %f3, 0f3EE0DED3, %f9;
|
||||||
|
add.f32 %f11, %f10, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r22, %f11;
|
||||||
|
fma.rn.f32 %f12, %f1, 0f3EE0DED3, 0f43000000;
|
||||||
|
fma.rn.f32 %f13, %f2, 0fBECC3C9F, %f12;
|
||||||
|
fma.rn.f32 %f14, %f3, 0fBD25119D, %f13;
|
||||||
|
add.f32 %f15, %f14, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r23, %f15;
|
||||||
|
mul.wide.s32 %rd6, %r4, %r5;
|
||||||
|
cvt.s64.s32 %rd7, %r3;
|
||||||
|
add.s64 %rd8, %rd6, %rd7;
|
||||||
|
cvta.to.global.u64 %rd9, %rd2;
|
||||||
|
add.s64 %rd10, %rd9, %rd8;
|
||||||
|
ld.global.u8 %r24, [%rd10];
|
||||||
|
mul.lo.s32 %r25, %r21, %r20;
|
||||||
|
xor.b32 %r26, %r20, 255;
|
||||||
|
mad.lo.s32 %r27, %r26, %r24, %r25;
|
||||||
|
mul.hi.s32 %r28, %r27, -2139062143;
|
||||||
|
add.s32 %r29, %r28, %r27;
|
||||||
|
shr.u32 %r30, %r29, 31;
|
||||||
|
shr.u32 %r31, %r29, 7;
|
||||||
|
add.s32 %r32, %r31, %r30;
|
||||||
|
st.global.u8 [%rd10], %r32;
|
||||||
|
mul.wide.s32 %rd11, %r7, %r5;
|
||||||
|
add.s64 %rd12, %rd10, %rd11;
|
||||||
|
ld.global.u8 %r33, [%rd12];
|
||||||
|
mul.lo.s32 %r34, %r22, %r20;
|
||||||
|
mad.lo.s32 %r35, %r26, %r33, %r34;
|
||||||
|
mul.hi.s32 %r36, %r35, -2139062143;
|
||||||
|
add.s32 %r37, %r36, %r35;
|
||||||
|
shr.u32 %r38, %r37, 31;
|
||||||
|
shr.u32 %r39, %r37, 7;
|
||||||
|
add.s32 %r40, %r39, %r38;
|
||||||
|
st.global.u8 [%rd12], %r40;
|
||||||
|
add.s64 %rd13, %rd12, %rd11;
|
||||||
|
ld.global.u8 %r41, [%rd13];
|
||||||
|
mul.lo.s32 %r42, %r23, %r20;
|
||||||
|
mad.lo.s32 %r43, %r26, %r41, %r42;
|
||||||
|
mul.hi.s32 %r44, %r43, -2139062143;
|
||||||
|
add.s32 %r45, %r44, %r43;
|
||||||
|
shr.u32 %r46, %r45, 31;
|
||||||
|
shr.u32 %r47, %r45, 7;
|
||||||
|
add.s32 %r48, %r47, %r46;
|
||||||
|
st.global.u8 [%rd13], %r48;
|
||||||
|
|
||||||
|
$L__BB1_4:
|
||||||
|
ret;
|
||||||
|
|
||||||
|
}
|
||||||
|
// .globl blend_nv12
|
||||||
|
.visible .entry blend_nv12(
|
||||||
|
.param .u64 blend_nv12_param_0,
|
||||||
|
.param .u32 blend_nv12_param_1,
|
||||||
|
.param .u64 blend_nv12_param_2,
|
||||||
|
.param .u32 blend_nv12_param_3,
|
||||||
|
.param .u32 blend_nv12_param_4,
|
||||||
|
.param .u32 blend_nv12_param_5,
|
||||||
|
.param .u64 blend_nv12_param_6,
|
||||||
|
.param .u32 blend_nv12_param_7,
|
||||||
|
.param .u32 blend_nv12_param_8,
|
||||||
|
.param .u32 blend_nv12_param_9,
|
||||||
|
.param .u32 blend_nv12_param_10
|
||||||
|
)
|
||||||
|
{
|
||||||
|
.reg .pred %p<43>;
|
||||||
|
.reg .b16 %rs<17>;
|
||||||
|
.reg .f32 %f<108>;
|
||||||
|
.reg .b32 %r<123>;
|
||||||
|
.reg .b64 %rd<35>;
|
||||||
|
|
||||||
|
|
||||||
|
ld.param.u64 %rd11, [blend_nv12_param_0];
|
||||||
|
ld.param.u32 %r21, [blend_nv12_param_1];
|
||||||
|
ld.param.u64 %rd10, [blend_nv12_param_2];
|
||||||
|
ld.param.u32 %r22, [blend_nv12_param_3];
|
||||||
|
ld.param.u32 %r23, [blend_nv12_param_4];
|
||||||
|
ld.param.u32 %r24, [blend_nv12_param_5];
|
||||||
|
ld.param.u64 %rd12, [blend_nv12_param_6];
|
||||||
|
ld.param.u32 %r25, [blend_nv12_param_7];
|
||||||
|
ld.param.u32 %r26, [blend_nv12_param_8];
|
||||||
|
ld.param.u32 %r27, [blend_nv12_param_9];
|
||||||
|
ld.param.u32 %r28, [blend_nv12_param_10];
|
||||||
|
cvta.to.global.u64 %rd1, %rd11;
|
||||||
|
cvta.to.global.u64 %rd2, %rd12;
|
||||||
|
mov.u32 %r29, %ntid.x;
|
||||||
|
mov.u32 %r30, %ctaid.x;
|
||||||
|
mov.u32 %r31, %tid.x;
|
||||||
|
mad.lo.s32 %r32, %r30, %r29, %r31;
|
||||||
|
mov.u32 %r33, %ntid.y;
|
||||||
|
mov.u32 %r34, %ctaid.y;
|
||||||
|
mov.u32 %r35, %tid.y;
|
||||||
|
mad.lo.s32 %r36, %r34, %r33, %r35;
|
||||||
|
shl.b32 %r1, %r32, 1;
|
||||||
|
shl.b32 %r2, %r36, 1;
|
||||||
|
setp.ge.s32 %p1, %r1, %r25;
|
||||||
|
setp.ge.s32 %p2, %r2, %r26;
|
||||||
|
or.pred %p3, %p1, %p2;
|
||||||
|
mov.f32 %f102, 0f00000000;
|
||||||
|
mov.f32 %f103, 0f00000000;
|
||||||
|
mov.f32 %f104, 0f00000000;
|
||||||
|
@%p3 bra $L__BB2_19;
|
||||||
|
|
||||||
|
cvt.s64.s32 %rd3, %r21;
|
||||||
|
add.s32 %r3, %r2, %r28;
|
||||||
|
setp.ge.s32 %p4, %r3, %r24;
|
||||||
|
mul.lo.s32 %r4, %r2, %r25;
|
||||||
|
mul.wide.s32 %rd4, %r3, %r21;
|
||||||
|
add.s32 %r5, %r1, %r27;
|
||||||
|
or.b32 %r38, %r5, %r3;
|
||||||
|
setp.lt.s32 %p5, %r38, 0;
|
||||||
|
mov.u32 %r121, 0;
|
||||||
|
setp.ge.s32 %p6, %r5, %r23;
|
||||||
|
or.pred %p7, %p6, %p5;
|
||||||
|
or.pred %p8, %p4, %p7;
|
||||||
|
@%p8 bra $L__BB2_4;
|
||||||
|
|
||||||
|
add.s32 %r40, %r1, %r4;
|
||||||
|
mul.wide.s32 %rd13, %r40, 4;
|
||||||
|
add.s64 %rd5, %rd2, %rd13;
|
||||||
|
ld.global.u8 %rs1, [%rd5+3];
|
||||||
|
setp.eq.s16 %p9, %rs1, 0;
|
||||||
|
@%p9 bra $L__BB2_4;
|
||||||
|
|
||||||
|
cvt.u32.u16 %r42, %rs1;
|
||||||
|
ld.global.u8 %rs5, [%rd5];
|
||||||
|
cvt.rn.f32.u16 %f31, %rs5;
|
||||||
|
ld.global.u8 %rs6, [%rd5+1];
|
||||||
|
cvt.rn.f32.u16 %f32, %rs6;
|
||||||
|
ld.global.u8 %rs7, [%rd5+2];
|
||||||
|
cvt.rn.f32.u16 %f33, %rs7;
|
||||||
|
fma.rn.f32 %f34, %f31, 0f3E3AFB7F, 0f41800000;
|
||||||
|
fma.rn.f32 %f35, %f32, 0f3F1D3C36, %f34;
|
||||||
|
fma.rn.f32 %f36, %f33, 0f3D7DF3B6, %f35;
|
||||||
|
add.f32 %f37, %f36, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r43, %f37;
|
||||||
|
cvt.s64.s32 %rd14, %r5;
|
||||||
|
add.s64 %rd15, %rd4, %rd14;
|
||||||
|
add.s64 %rd16, %rd1, %rd15;
|
||||||
|
ld.global.u8 %r44, [%rd16];
|
||||||
|
mul.lo.s32 %r45, %r43, %r42;
|
||||||
|
xor.b32 %r46, %r42, 255;
|
||||||
|
mad.lo.s32 %r47, %r46, %r44, %r45;
|
||||||
|
mul.hi.s32 %r48, %r47, -2139062143;
|
||||||
|
add.s32 %r49, %r48, %r47;
|
||||||
|
shr.u32 %r50, %r49, 31;
|
||||||
|
shr.u32 %r51, %r49, 7;
|
||||||
|
add.s32 %r52, %r51, %r50;
|
||||||
|
st.global.u8 [%rd16], %r52;
|
||||||
|
fma.rn.f32 %f38, %f31, 0fBDCE075F, 0f43000000;
|
||||||
|
fma.rn.f32 %f39, %f32, 0fBEAD5CFB, %f38;
|
||||||
|
fma.rn.f32 %f40, %f33, 0f3EE0DED3, %f39;
|
||||||
|
cvt.rn.f32.u16 %f104, %rs1;
|
||||||
|
fma.rn.f32 %f102, %f40, %f104, 0f00000000;
|
||||||
|
fma.rn.f32 %f41, %f31, 0f3EE0DED3, 0f43000000;
|
||||||
|
fma.rn.f32 %f42, %f32, 0fBECC3C9F, %f41;
|
||||||
|
fma.rn.f32 %f43, %f33, 0fBD25119D, %f42;
|
||||||
|
fma.rn.f32 %f103, %f43, %f104, 0f00000000;
|
||||||
|
mov.u32 %r121, 1;
|
||||||
|
|
||||||
|
$L__BB2_4:
|
||||||
|
add.s32 %r7, %r1, 1;
|
||||||
|
setp.ge.s32 %p10, %r7, %r25;
|
||||||
|
@%p10 bra $L__BB2_8;
|
||||||
|
|
||||||
|
add.s32 %r8, %r7, %r27;
|
||||||
|
or.b32 %r53, %r8, %r3;
|
||||||
|
setp.lt.s32 %p12, %r53, 0;
|
||||||
|
setp.ge.s32 %p13, %r8, %r23;
|
||||||
|
or.pred %p14, %p13, %p12;
|
||||||
|
or.pred %p15, %p4, %p14;
|
||||||
|
@%p15 bra $L__BB2_8;
|
||||||
|
|
||||||
|
add.s32 %r54, %r7, %r4;
|
||||||
|
mul.wide.s32 %rd17, %r54, 4;
|
||||||
|
add.s64 %rd6, %rd2, %rd17;
|
||||||
|
ld.global.u8 %rs2, [%rd6+3];
|
||||||
|
setp.eq.s16 %p16, %rs2, 0;
|
||||||
|
@%p16 bra $L__BB2_8;
|
||||||
|
|
||||||
|
cvt.u32.u16 %r55, %rs2;
|
||||||
|
ld.global.u8 %rs8, [%rd6];
|
||||||
|
cvt.rn.f32.u16 %f44, %rs8;
|
||||||
|
ld.global.u8 %rs9, [%rd6+1];
|
||||||
|
cvt.rn.f32.u16 %f45, %rs9;
|
||||||
|
ld.global.u8 %rs10, [%rd6+2];
|
||||||
|
cvt.rn.f32.u16 %f46, %rs10;
|
||||||
|
fma.rn.f32 %f47, %f44, 0f3E3AFB7F, 0f41800000;
|
||||||
|
fma.rn.f32 %f48, %f45, 0f3F1D3C36, %f47;
|
||||||
|
fma.rn.f32 %f49, %f46, 0f3D7DF3B6, %f48;
|
||||||
|
add.f32 %f50, %f49, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r56, %f50;
|
||||||
|
cvt.s64.s32 %rd18, %r8;
|
||||||
|
add.s64 %rd19, %rd4, %rd18;
|
||||||
|
add.s64 %rd20, %rd1, %rd19;
|
||||||
|
ld.global.u8 %r57, [%rd20];
|
||||||
|
mul.lo.s32 %r58, %r56, %r55;
|
||||||
|
xor.b32 %r59, %r55, 255;
|
||||||
|
mad.lo.s32 %r60, %r59, %r57, %r58;
|
||||||
|
mul.hi.s32 %r61, %r60, -2139062143;
|
||||||
|
add.s32 %r62, %r61, %r60;
|
||||||
|
shr.u32 %r63, %r62, 31;
|
||||||
|
shr.u32 %r64, %r62, 7;
|
||||||
|
add.s32 %r65, %r64, %r63;
|
||||||
|
st.global.u8 [%rd20], %r65;
|
||||||
|
fma.rn.f32 %f51, %f44, 0fBDCE075F, 0f43000000;
|
||||||
|
fma.rn.f32 %f52, %f45, 0fBEAD5CFB, %f51;
|
||||||
|
fma.rn.f32 %f53, %f46, 0f3EE0DED3, %f52;
|
||||||
|
cvt.rn.f32.u16 %f54, %rs2;
|
||||||
|
fma.rn.f32 %f102, %f53, %f54, %f102;
|
||||||
|
fma.rn.f32 %f55, %f44, 0f3EE0DED3, 0f43000000;
|
||||||
|
fma.rn.f32 %f56, %f45, 0fBECC3C9F, %f55;
|
||||||
|
fma.rn.f32 %f57, %f46, 0fBD25119D, %f56;
|
||||||
|
fma.rn.f32 %f103, %f57, %f54, %f103;
|
||||||
|
add.f32 %f104, %f104, %f54;
|
||||||
|
add.s32 %r121, %r121, 1;
|
||||||
|
|
||||||
|
$L__BB2_8:
|
||||||
|
add.s32 %r11, %r2, 1;
|
||||||
|
setp.ge.s32 %p17, %r11, %r26;
|
||||||
|
add.s32 %r12, %r11, %r28;
|
||||||
|
add.s32 %r13, %r4, %r25;
|
||||||
|
cvt.s64.s32 %rd21, %r12;
|
||||||
|
mul.lo.s64 %rd7, %rd21, %rd3;
|
||||||
|
@%p17 bra $L__BB2_12;
|
||||||
|
|
||||||
|
setp.ge.s32 %p18, %r12, %r24;
|
||||||
|
or.b32 %r66, %r5, %r12;
|
||||||
|
setp.lt.s32 %p19, %r66, 0;
|
||||||
|
or.pred %p21, %p6, %p19;
|
||||||
|
or.pred %p22, %p18, %p21;
|
||||||
|
@%p22 bra $L__BB2_12;
|
||||||
|
|
||||||
|
add.s32 %r67, %r1, %r13;
|
||||||
|
mul.wide.s32 %rd22, %r67, 4;
|
||||||
|
add.s64 %rd8, %rd2, %rd22;
|
||||||
|
ld.global.u8 %rs3, [%rd8+3];
|
||||||
|
setp.eq.s16 %p23, %rs3, 0;
|
||||||
|
@%p23 bra $L__BB2_12;
|
||||||
|
|
||||||
|
cvt.u32.u16 %r68, %rs3;
|
||||||
|
ld.global.u8 %rs11, [%rd8];
|
||||||
|
cvt.rn.f32.u16 %f58, %rs11;
|
||||||
|
ld.global.u8 %rs12, [%rd8+1];
|
||||||
|
cvt.rn.f32.u16 %f59, %rs12;
|
||||||
|
ld.global.u8 %rs13, [%rd8+2];
|
||||||
|
cvt.rn.f32.u16 %f60, %rs13;
|
||||||
|
fma.rn.f32 %f61, %f58, 0f3E3AFB7F, 0f41800000;
|
||||||
|
fma.rn.f32 %f62, %f59, 0f3F1D3C36, %f61;
|
||||||
|
fma.rn.f32 %f63, %f60, 0f3D7DF3B6, %f62;
|
||||||
|
add.f32 %f64, %f63, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r69, %f64;
|
||||||
|
cvt.s64.s32 %rd23, %r5;
|
||||||
|
add.s64 %rd24, %rd7, %rd23;
|
||||||
|
add.s64 %rd25, %rd1, %rd24;
|
||||||
|
ld.global.u8 %r70, [%rd25];
|
||||||
|
mul.lo.s32 %r71, %r69, %r68;
|
||||||
|
xor.b32 %r72, %r68, 255;
|
||||||
|
mad.lo.s32 %r73, %r72, %r70, %r71;
|
||||||
|
mul.hi.s32 %r74, %r73, -2139062143;
|
||||||
|
add.s32 %r75, %r74, %r73;
|
||||||
|
shr.u32 %r76, %r75, 31;
|
||||||
|
shr.u32 %r77, %r75, 7;
|
||||||
|
add.s32 %r78, %r77, %r76;
|
||||||
|
st.global.u8 [%rd25], %r78;
|
||||||
|
fma.rn.f32 %f65, %f58, 0fBDCE075F, 0f43000000;
|
||||||
|
fma.rn.f32 %f66, %f59, 0fBEAD5CFB, %f65;
|
||||||
|
fma.rn.f32 %f67, %f60, 0f3EE0DED3, %f66;
|
||||||
|
cvt.rn.f32.u16 %f68, %rs3;
|
||||||
|
fma.rn.f32 %f102, %f67, %f68, %f102;
|
||||||
|
fma.rn.f32 %f69, %f58, 0f3EE0DED3, 0f43000000;
|
||||||
|
fma.rn.f32 %f70, %f59, 0fBECC3C9F, %f69;
|
||||||
|
fma.rn.f32 %f71, %f60, 0fBD25119D, %f70;
|
||||||
|
fma.rn.f32 %f103, %f71, %f68, %f103;
|
||||||
|
add.f32 %f104, %f104, %f68;
|
||||||
|
add.s32 %r121, %r121, 1;
|
||||||
|
|
||||||
|
$L__BB2_12:
|
||||||
|
or.pred %p26, %p17, %p10;
|
||||||
|
@%p26 bra $L__BB2_16;
|
||||||
|
|
||||||
|
setp.ge.s32 %p27, %r12, %r24;
|
||||||
|
add.s32 %r16, %r7, %r27;
|
||||||
|
or.b32 %r79, %r16, %r12;
|
||||||
|
setp.lt.s32 %p28, %r79, 0;
|
||||||
|
setp.ge.s32 %p29, %r16, %r23;
|
||||||
|
or.pred %p30, %p29, %p28;
|
||||||
|
or.pred %p31, %p27, %p30;
|
||||||
|
@%p31 bra $L__BB2_16;
|
||||||
|
|
||||||
|
add.s32 %r80, %r7, %r13;
|
||||||
|
mul.wide.s32 %rd26, %r80, 4;
|
||||||
|
add.s64 %rd9, %rd2, %rd26;
|
||||||
|
ld.global.u8 %rs4, [%rd9+3];
|
||||||
|
setp.eq.s16 %p32, %rs4, 0;
|
||||||
|
@%p32 bra $L__BB2_16;
|
||||||
|
|
||||||
|
cvt.u32.u16 %r81, %rs4;
|
||||||
|
ld.global.u8 %rs14, [%rd9];
|
||||||
|
cvt.rn.f32.u16 %f72, %rs14;
|
||||||
|
ld.global.u8 %rs15, [%rd9+1];
|
||||||
|
cvt.rn.f32.u16 %f73, %rs15;
|
||||||
|
ld.global.u8 %rs16, [%rd9+2];
|
||||||
|
cvt.rn.f32.u16 %f74, %rs16;
|
||||||
|
fma.rn.f32 %f75, %f72, 0f3E3AFB7F, 0f41800000;
|
||||||
|
fma.rn.f32 %f76, %f73, 0f3F1D3C36, %f75;
|
||||||
|
fma.rn.f32 %f77, %f74, 0f3D7DF3B6, %f76;
|
||||||
|
add.f32 %f78, %f77, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r82, %f78;
|
||||||
|
cvt.s64.s32 %rd27, %r16;
|
||||||
|
add.s64 %rd28, %rd7, %rd27;
|
||||||
|
add.s64 %rd29, %rd1, %rd28;
|
||||||
|
ld.global.u8 %r83, [%rd29];
|
||||||
|
mul.lo.s32 %r84, %r82, %r81;
|
||||||
|
xor.b32 %r85, %r81, 255;
|
||||||
|
mad.lo.s32 %r86, %r85, %r83, %r84;
|
||||||
|
mul.hi.s32 %r87, %r86, -2139062143;
|
||||||
|
add.s32 %r88, %r87, %r86;
|
||||||
|
shr.u32 %r89, %r88, 31;
|
||||||
|
shr.u32 %r90, %r88, 7;
|
||||||
|
add.s32 %r91, %r90, %r89;
|
||||||
|
st.global.u8 [%rd29], %r91;
|
||||||
|
fma.rn.f32 %f79, %f72, 0fBDCE075F, 0f43000000;
|
||||||
|
fma.rn.f32 %f80, %f73, 0fBEAD5CFB, %f79;
|
||||||
|
fma.rn.f32 %f81, %f74, 0f3EE0DED3, %f80;
|
||||||
|
cvt.rn.f32.u16 %f82, %rs4;
|
||||||
|
fma.rn.f32 %f102, %f81, %f82, %f102;
|
||||||
|
fma.rn.f32 %f83, %f72, 0f3EE0DED3, 0f43000000;
|
||||||
|
fma.rn.f32 %f84, %f73, 0fBECC3C9F, %f83;
|
||||||
|
fma.rn.f32 %f85, %f74, 0fBD25119D, %f84;
|
||||||
|
fma.rn.f32 %f103, %f85, %f82, %f103;
|
||||||
|
add.f32 %f104, %f104, %f82;
|
||||||
|
add.s32 %r121, %r121, 1;
|
||||||
|
|
||||||
|
$L__BB2_16:
|
||||||
|
setp.eq.s32 %p33, %r121, 0;
|
||||||
|
setp.le.f32 %p34, %f104, 0f00000000;
|
||||||
|
or.pred %p35, %p34, %p33;
|
||||||
|
@%p35 bra $L__BB2_19;
|
||||||
|
|
||||||
|
shr.u32 %r92, %r5, 31;
|
||||||
|
add.s32 %r93, %r5, %r92;
|
||||||
|
shr.s32 %r19, %r93, 1;
|
||||||
|
setp.lt.s32 %p36, %r3, -1;
|
||||||
|
setp.lt.s32 %p37, %r5, -1;
|
||||||
|
or.pred %p38, %p37, %p36;
|
||||||
|
and.b32 %r94, %r93, -2;
|
||||||
|
setp.ge.s32 %p39, %r94, %r23;
|
||||||
|
or.pred %p40, %p38, %p39;
|
||||||
|
shr.u32 %r95, %r3, 31;
|
||||||
|
add.s32 %r96, %r3, %r95;
|
||||||
|
shr.s32 %r20, %r96, 1;
|
||||||
|
and.b32 %r97, %r96, -2;
|
||||||
|
setp.ge.s32 %p41, %r97, %r24;
|
||||||
|
or.pred %p42, %p40, %p41;
|
||||||
|
@%p42 bra $L__BB2_19;
|
||||||
|
|
||||||
|
div.rn.f32 %f86, %f102, %f104;
|
||||||
|
add.f32 %f87, %f86, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r98, %f87;
|
||||||
|
div.rn.f32 %f88, %f103, %f104;
|
||||||
|
add.f32 %f89, %f88, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r99, %f89;
|
||||||
|
cvt.rn.f32.s32 %f90, %r121;
|
||||||
|
div.rn.f32 %f91, %f104, %f90;
|
||||||
|
add.f32 %f92, %f91, 0f3F000000;
|
||||||
|
cvt.rzi.s32.f32 %r100, %f92;
|
||||||
|
mul.wide.s32 %rd30, %r20, %r22;
|
||||||
|
mul.wide.s32 %rd31, %r19, 2;
|
||||||
|
add.s64 %rd32, %rd30, %rd31;
|
||||||
|
cvta.to.global.u64 %rd33, %rd10;
|
||||||
|
add.s64 %rd34, %rd33, %rd32;
|
||||||
|
ld.global.u8 %r101, [%rd34];
|
||||||
|
mul.lo.s32 %r102, %r100, %r98;
|
||||||
|
mov.u32 %r103, 255;
|
||||||
|
sub.s32 %r104, %r103, %r100;
|
||||||
|
mad.lo.s32 %r105, %r104, %r101, %r102;
|
||||||
|
mul.hi.s32 %r106, %r105, -2139062143;
|
||||||
|
add.s32 %r107, %r106, %r105;
|
||||||
|
shr.u32 %r108, %r107, 31;
|
||||||
|
shr.u32 %r109, %r107, 7;
|
||||||
|
add.s32 %r110, %r109, %r108;
|
||||||
|
st.global.u8 [%rd34], %r110;
|
||||||
|
ld.global.u8 %r111, [%rd34+1];
|
||||||
|
mul.lo.s32 %r112, %r100, %r99;
|
||||||
|
mad.lo.s32 %r113, %r104, %r111, %r112;
|
||||||
|
mul.hi.s32 %r114, %r113, -2139062143;
|
||||||
|
add.s32 %r115, %r114, %r113;
|
||||||
|
shr.u32 %r116, %r115, 31;
|
||||||
|
shr.u32 %r117, %r115, 7;
|
||||||
|
add.s32 %r118, %r117, %r116;
|
||||||
|
st.global.u8 [%rd34+1], %r118;
|
||||||
|
|
||||||
|
$L__BB2_19:
|
||||||
|
ret;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user