Compare commits

..

6 Commits

Author SHA1 Message Date
enricobuehler 22b352c1da chore(release): bump workspace version to 0.13.0
apple / swift (push) Successful in 1m19s
audit / bun-audit (push) Successful in 13s
audit / cargo-audit (push) Successful in 2m8s
ci / web (push) Successful in 53s
release / apple (push) Successful in 5m45s
ci / docs-site (push) Successful in 1m3s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 5m26s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / screenshots (push) Successful in 6m27s
windows-host / package (push) Successful in 15m6s
arch / build-publish (push) Successful in 10m50s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m58s
android / android (push) Successful in 16m0s
docker / deploy-docs (push) Successful in 11s
flatpak / build-publish (push) Failing after 8m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m11s
deb / build-publish (push) Successful in 13m4s
ci / rust (push) Successful in 19m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m26s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m51s
The eight W6 leaf crates hardcoded 0.12.0 instead of inheriting the
workspace version — switched to version.workspace = true so the next bump
is one line again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:32:35 +02:00
enricobuehler 15233a68cf ci(release): map the widgets extension to its own App Store provisioning profile
The .ipa now embeds PunktfunkWidgetsExtension (io.unom.punktfunk.widgets), a
second distribution artifact, so the manual-signing exportArchive must map its
App ID to its own App Store profile — without it the export fails with "no
profile for io.unom.punktfunk.widgets". Requires the "Punktfunk iOS Widgets
App Store Distribution" profile on the runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:32:35 +02:00
enricobuehler 2a637eaf3f Merge origin/main: shared clipboard Phase 1 + first-frame-latency v4 into the local SDK line
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
Brings the day's landed work — clipboard (pf-clipboard + macOS client, ABI v8),
the perf/first-frame-latency merge (driver proto v4, Welcome-time display prep,
in-place resize), and the W7/W8 reconciliation — into the local main that had
diverged with the SDK-publish commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:29:10 +02:00
enricobuehler 6c976e9dc5 fix(apple): land the RenderScale/DefaultsKeys definitions the client refactor references
apple / swift (push) Successful in 1m14s
ci / web (push) Successful in 1m42s
ci / docs-site (push) Successful in 2m0s
release / apple (push) Successful in 5m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / screenshots (push) Successful in 6m33s
ci / bench (push) Successful in 8m15s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
android / android (push) Successful in 14m14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
arch / build-publish (push) Successful in 18m11s
deb / build-publish (push) Successful in 18m29s
docker / deploy-docs (push) Successful in 28s
ci / rust (push) Successful in 28m37s
The W7/W8 client reconciliation committed the CONSUMERS of the render-scale
setting and the toggle-fullscreen notification (ContentView/FullscreenController
et al.) while their definitions were still uncommitted working-tree state from
the Apple-features effort — apple.yml red on main (run 10657). Lands the two
definition files (PunktfunkShared/RenderScale.swift + the DefaultsKeys additions)
so main builds; the rest of that effort's WIP stays in its working tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:20:39 +02:00
enricobuehler 691c064a37 build(sdk): publish @punktfunk/host to the Gitea npm registry
- package.json: drop private; point main/types/exports/bin at a tsc-built dist/;
  add publishConfig (unom/npm registry), files, repo metadata, and the
  MIT OR Apache-2.0 license; effect becomes a peerDependency (shared instance).
- tsconfig.build.json: emit dist/ JS + .d.ts (bun shebang preserved on the bin).
- .npmrc: map the @punktfunk scope to the registry (no token committed).
- sdk-publish.yml: publish on sdk-v* tags or manual dispatch, reusing the
  REGISTRY_TOKEN secret; typecheck/test/build/tag-matches-version gate.
- README: Install section for consumers.

Verified: build green, frozen lockfile stable, bun publish --dry-run packs
@punktfunk/host@0.1.0 (dist + README only) to the unom registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:54:36 +02:00
enricobuehler 34519566ba feat(sdk): VirtualHere DualSense passthrough example + automation recipe
Add sdk/examples/virtualhere-dualsense.ts — bind a real USB DualSense (shared
from the couch via VirtualHere USB-over-IP) to the host for the length of each
connection and release it after, for full gyro/touchpad/adaptive-trigger/USB-
rumble passthrough instead of the emulated pad. Brackets on client.connected/
disconnected and releases the pad on SIGTERM for a clean runner stop.

Document it in the Events & hooks page with a zero-code hooks.json variant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:54:36 +02:00
22 changed files with 395 additions and 45 deletions
+8 -1
View File
@@ -316,6 +316,10 @@ jobs:
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk iOS App Store Distribution"
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-iOS \
-destination 'generic/platform=iOS' \
@@ -335,7 +339,10 @@ jobs:
<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></dict>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
EOF
+60
View File
@@ -0,0 +1,60 @@
# Publish the TypeScript SDK (@punktfunk/host) to the Gitea npm registry
# (https://git.unom.io/api/packages/unom/npm/).
#
# Trigger: push a tag `sdk-vX.Y.Z` (must equal sdk/package.json "version"), or run manually.
# The SDK versions independently of the app's `v*` tags, so bumping the host doesn't republish it.
#
# Auth: REGISTRY_TOKEN — the same repo Actions secret docker.yml uses (a Gitea PAT with
# write:package scope). No new secret needed.
name: sdk-publish
on:
push:
tags: ['sdk-v*']
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: sdk
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
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Typecheck
run: bun run typecheck
- name: Test
run: bun test
- name: Build (dist/ JS + .d.ts)
run: bun run build
- name: Tag matches package version
if: startsWith(github.ref, 'refs/tags/')
run: |
TAG="${GITHUB_REF_NAME#sdk-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
env:
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
# .npmrc already maps the @punktfunk scope to the registry; append the auth line.
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
bun publish
Generated
+23 -23
View File
@@ -2145,7 +2145,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.12.0"
version = "0.13.0"
[[package]]
name = "lazy_static"
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"punktfunk-core",
]
@@ -2756,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2776,7 +2776,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"ash",
@@ -2817,7 +2817,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"ash",
@@ -2838,7 +2838,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"ash",
@@ -2860,7 +2860,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"ash",
"bindgen",
@@ -2869,7 +2869,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"libc",
@@ -2881,7 +2881,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2895,7 +2895,7 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.12.0"
version = "0.13.0"
[[package]]
name = "pf-inject"
@@ -2927,14 +2927,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"ash",
@@ -2979,7 +2979,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -2991,7 +2991,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"ash",
@@ -3174,7 +3174,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"android_logger",
"jni",
@@ -3190,7 +3190,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3206,7 +3206,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3221,7 +3221,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3240,7 +3240,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3271,7 +3271,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3353,7 +3353,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3367,7 +3367,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"anyhow",
"ksni",
@@ -3384,7 +3384,7 @@ dependencies = [
[[package]]
name = "pyrowave-sys"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -47,7 +47,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.12.0"
version = "0.13.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.12.0"
"version": "0.13.0"
},
"paths": {
"/api/v1/clients": {
@@ -21,6 +21,14 @@ public enum DefaultsKey {
/// is native either way, so this degenerates to Auto-native there). Read per session by the
/// stream views' `MatchWindowFollower`.
public static let matchWindow = "punktfunk.matchWindow"
/// Render-resolution multiplier (a `RenderScale` value, default 1.0): the client asks the host
/// to render/encode at `chosen resolution × scale`, then the presenter downscales the larger
/// decoded frame to this display in one Catmull-Rom pass. > 1 supersamples (sharper, at the cost
/// of more bandwidth AND client decode both grow scale²); < 1 renders below native for a
/// weak host GPU / constrained link (the presenter upscales). Purely client-side the host just
/// sees a normal (larger/smaller) `Mode`, and Automatic bitrate scales with it. Clamped even +
/// to the codec's max dimension at connect. Applies to the fixed mode and the match-window path.
public static let renderScale = "punktfunk.renderScale"
public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID"
@@ -69,6 +77,21 @@ public enum DefaultsKey {
public static let hosts = "punktfunk.hosts"
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
public static let cursorMode = "punktfunk.cursorMode"
/// 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
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
/// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel. For users whose host expects the
/// opposite convention from their local OS preference.
public static let invertScroll = "punktfunk.invertScroll"
/// Location-based modifier mapping (a `ModifierLayout` value, default `.mac`): which Windows VK
/// each PHYSICAL modifier position forwards to the host. `.mac` keeps Option Alt and
/// Command Super/Win (the Apple positions). `.windows` swaps the Alt/Super ROLE between the
/// Option and Command keys preserving side (L/R) so the key nearest the space bar acts as
/// Alt and the next one as the Windows key, matching a Windows keyboard's `Ctrl / / Alt` row.
/// Only what's FORWARDED changes; client-local shortcuts ( &co.) stay on the physical key.
/// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on
/// both keyboards).
public static let modifierLayout = "punktfunk.modifierLayout"
/// iPad: capture the mouse/trackpad pointer (pointer lock relative movement) for games,
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
@@ -132,6 +155,12 @@ extension Notification.Name {
/// discoverable menu-bar surface.
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
/// Posted by the app's Stream menu ("Toggle Fullscreen", F) and by InputCapture's monitor
/// when the same combo fires while input is captured (the menu key-equivalent never reaches a
/// captured stream view). The key window's `FullscreenController` flips the window's fullscreen
/// state. macOS only.
public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen")
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
/// which runs in the app's process): the app tears the active session down deliberately
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture`
@@ -0,0 +1,72 @@
// Render-resolution scaling the pure geometry behind `DefaultsKey.renderScale`. The client asks
// the host to render/encode at `chosen resolution × scale` and lets the presenter downscale the
// larger decoded frame to the display (a Catmull-Rom minification, > 1 = supersampling for
// sharpness) or upscale a smaller one (< 1 = a performance mode for a weak host GPU / thin link).
//
// This is where the multiplier is turned into a host-valid `Mode` dimension: multiply, preserve the
// aspect ratio, floor to even (the host's `validate_dimensions` rejects odd sizes), and clamp to the
// codec's per-axis ceiling so the connect can't ask for something the encoder will reject. Kept
// dependency-free + side-effect-free so it's unit-tested (`RenderScaleTests`) and reused by both the
// fixed-mode connect and the match-window follower.
import Foundation
public enum RenderScale {
/// The supported multiplier range. Below 1 renders under native (upscaled on present); above 1
/// supersamples. The UI clamps its slider to this and the connect clamps the raw stored value.
public static let range: ClosedRange<Double> = 0.5...4.0
/// The multipliers the picker offers. 1.0 (Native) is the default; the rest are the round stops
/// users reason about.
public static let presets: [Double] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]
/// The encoder/host per-axis ceiling for a codec preference string (`DefaultsKey.codec`). H.264
/// tops out at 4096 px/axis; HEVC / AV1 / PyroWave (and "auto", which negotiates one of those in
/// practice) at 8192. The host enforces the same walls in `codec.rs::validate_dimensions`.
public static func maxDimension(codec: String) -> Int {
codec == "h264" ? 4096 : 8192
}
/// A compact user-facing label for a multiplier: "Native (1×)", "1.5×", "2× · supersample".
/// Shared by every platform's picker so the wording stays identical.
public static func label(_ scale: Double) -> String {
if scale == 1.0 { return "Native (1×)" }
let magnitude = String(format: "%g×", scale)
return scale > 1 ? "\(magnitude) · supersample" : magnitude
}
/// Clamp a raw stored multiplier into `range`, treating a missing/zero value as 1.0 (Native).
public static func sanitize(_ raw: Double) -> Double {
guard raw > 0 else { return 1.0 }
return min(max(raw, range.lowerBound), range.upperBound)
}
/// Apply `scale` to a base pixel size, preserving aspect, even-flooring each axis, and clamping
/// uniformly so neither axis exceeds `maxDimension` (the larger axis lands on the cap, the ratio
/// is kept). Also floors each axis at `minWidth`/`minHeight` (the host never accepts < 320×200).
/// The result is a directly host-valid `Mode` width/height.
public static func apply(
baseWidth: Int,
baseHeight: Int,
scale rawScale: Double,
maxDimension: Int,
minWidth: Int = 320,
minHeight: Int = 200
) -> (width: UInt32, height: UInt32) {
let scale = sanitize(rawScale)
var w = Double(max(baseWidth, 1)) * scale
var h = Double(max(baseHeight, 1)) * scale
// Uniform down-clamp if either axis blew past the ceiling keep the aspect ratio intact.
let cap = Double(maxDimension)
let over = max(w / cap, h / cap)
if over > 1 {
w /= over
h /= over
}
let evenFloor: (Double, Int) -> UInt32 = { value, minimum in
let clamped = max(Int(value.rounded(.down)), minimum)
return UInt32(clamped / 2 * 2)
}
return (evenFloor(w, minWidth), evenFloor(h, minHeight))
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
# (FrameChannelSender), so the capture→encode edge is one-way (plan §2.4).
[package]
name = "pf-capture"
version = "0.12.0"
version.workspace = true
edition = "2021"
rust-version.workspace = true
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -5,7 +5,7 @@
# (CUDA), never pf-capture — the capture→encode edge is one-way (plan §2.4).
[package]
name = "pf-encode"
version = "0.12.0"
version.workspace = true
edition = "2021"
rust-version.workspace = true
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -5,7 +5,7 @@
# can depend on the vocabulary WITHOUT depending on each other.
[package]
name = "pf-frame"
version = "0.12.0"
version.workspace = true
edition = "2021"
rust-version.workspace = true
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -3,7 +3,7 @@
# depending on the orchestrator (plan §W6). Self-contained: reads config + writes the pref store.
[package]
name = "pf-gpu"
version = "0.12.0"
version.workspace = true
edition = "2021"
license = "MIT OR Apache-2.0"
description = "punktfunk host GPU vendor/adapter enumeration, selection preference, and active-session accounting."
+1 -1
View File
@@ -3,7 +3,7 @@
# WITHOUT depending on the orchestrator (plan §W6 — config parked above its consumers). Pure std.
[package]
name = "pf-host-config"
version = "0.12.0"
version.workspace = true
edition = "2021"
license = "MIT OR Apache-2.0"
description = "Process-wide punktfunk host configuration (env-parsed HostConfig behind a OnceLock)."
+1 -1
View File
@@ -3,7 +3,7 @@
# module they used to live in (plan §W6). Pure std + tracing; no platform I/O stack.
[package]
name = "pf-paths"
version = "0.12.0"
version.workspace = true
edition = "2021"
license = "MIT OR Apache-2.0"
description = "Host config-directory resolution + owner-private file/dir creation (0600/0700 or SYSTEM/Admins DACL)."
+1 -1
View File
@@ -6,7 +6,7 @@
# compiles to an empty lib elsewhere.
[package]
name = "pf-win-display"
version = "0.12.0"
version.workspace = true
edition = "2021"
rust-version.workspace = true
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -5,7 +5,7 @@
# plain (non-target-gated) dependency.
[package]
name = "pf-zerocopy"
version = "0.12.0"
version.workspace = true
edition = "2021"
# Inherit the workspace MSRV: clippy keys MSRV-gated lints off it (without this, e.g.
# `manual_is_multiple_of` fires on code the host crate compiles clean).
+25
View File
@@ -151,3 +151,28 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
`pairing.pending`, send yourself a notification, and call
`POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at
[`/api/docs`](/api) on your host.
## Recipe: full controller passthrough (VirtualHere)
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
triggers, USB rumble — instead of the emulated pad, share the physical device from the couch with
[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) and bind it to the host only while a
client is connected. The couch runs the VirtualHere **server** (sharing the pad); the host runs
the VirtualHere **client** and this automation drives its `-t` IPC.
Zero-code, bracketed on the stream with two hooks:
```json
{
"hooks": [
{ "on": "stream.started", "run": "vhclientx86_64 -t \"USE,couch-deck.11\"" },
{ "on": "stream.stopped", "run": "vhclientx86_64 -t \"STOP USING,couch-deck.11\"" }
]
}
```
`couch-deck.11` is the device's VirtualHere address (`vhclientx86_64 -t LIST`); same-LAN setups
auto-discover it, otherwise `MANUAL HUB ADD,<couch-ip>:7575` once. For a version that resolves the
device by name, filters to one couch, and releases the pad on a clean shutdown, see the
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
SDK example.
+1
View File
@@ -0,0 +1 @@
@punktfunk:registry=https://git.unom.io/api/packages/unom/npm/
+33
View File
@@ -15,6 +15,31 @@ Two surfaces, one core:
`effect/Schema` (REST shapes generated from the host's OpenAPI spec; event shapes mirroring
the host's snapshot-tested wire format).
## Install
Published to the unom [Gitea npm registry](https://git.unom.io/unom/-/packages). Point the
`@punktfunk` scope at it once — in your project's `.npmrc` (or `~/.npmrc`):
```ini
@punktfunk:registry=https://git.unom.io/api/packages/unom/npm/
```
Then install:
```sh
bun add @punktfunk/host # or: npm i @punktfunk/host
```
`effect` is a **peer dependency** (auto-installed by bun / npm ≥ 7) — so the SDK and your own
`@punktfunk/host/effect` code share one Effect instance.
If the registry requires authentication (private org, or from CI), add a token line with a Gitea
PAT that has `read:package`:
```ini
//git.unom.io/api/packages/unom/npm/:_authToken=${NODE_AUTH_TOKEN}
```
## Quickstart
```ts
@@ -66,6 +91,14 @@ A complexity ladder in [`examples/`](./examples) — start at the top:
Examples 13 are the plain Promise facade and cover most automation; you only need example 4's
Effect surface for composed, interruptible programs. Run any with `bun examples/<file>.ts`.
Plus a real-world recipe:
- [`virtualhere-dualsense.ts`](./examples/virtualhere-dualsense.ts) — **USB passthrough**: bind a
real DualSense (shared from the couch over [VirtualHere](https://www.virtualhere.com/)) to the
host for the length of each connection and release it after — full gyro, touchpad, adaptive
triggers and USB rumble instead of an emulated pad. Shows the `client.connected`/`disconnected`
bracket and clean release on `systemctl stop`.
## Connection resolution
`connect()` / `PunktfunkHostLive()` resolve, in order:
+4 -3
View File
@@ -4,18 +4,19 @@
"workspaces": {
"": {
"name": "@punktfunk/host",
"dependencies": {
"effect": "^4.0.0-beta.98",
},
"devDependencies": {
"@effect/openapi-generator": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@types/bun": "^1.3.0",
"effect": "^4.0.0-beta.98",
"typescript": "^5.9.3",
},
"optionalDependencies": {
"undici": "^7.0.0",
},
"peerDependencies": {
"effect": "^4.0.0-beta.98",
},
},
},
"packages": {
+86
View File
@@ -0,0 +1,86 @@
// ── Recipe · VirtualHere USB passthrough → full DualSense ────────────────────────────────────
// Hand a *real* USB DualSense to the host for the length of a session, then give it back — so the
// game sees native gyro, touchpad, adaptive triggers and USB rumble instead of an emulated pad.
//
// Topology (USB-over-IP): the DualSense is plugged into the *couch* device, which runs the
// VirtualHere **Server** and shares it. The punktfunk **host** — where this script and the game
// run — runs the VirtualHere **Client** and mounts it. This script drives that client's `-t` IPC:
// `USE` the pad when a client connects, `STOP USING` it when they leave, so the couch keeps its
// own controller whenever you're not streaming.
//
// On the host you need: the VirtualHere client running (service or app) and the couch's shared
// DualSense visible to it — same-LAN setups auto-discover; otherwise add it once with
// `<VH_CLIENT> -t "MANUAL HUB ADD,<couch-ip>:7575"`.
//
// VH_DEVICE=couch-deck.11 bun examples/virtualhere-dualsense.ts # address from `-t LIST`
// VH_DEVICE=DualSense bun examples/virtualhere-dualsense.ts # …or match by name substring
//
// Env: VH_DEVICE required — a VirtualHere address (`server.port`) or a device-name substring.
// VH_CLIENT client binary. Default `vhclientx86_64` (Linux); Windows `vhui64.exe`,
// macOS `vhclientosx`, ARM Linux `vhclientarm64`.
// VH_ONLY_CLIENT optional — only bind for this punktfunk client label (multi-couch setups).
import { execFile } from "node:child_process";
import { connect } from "../src/index.js";
const VH_CLIENT = process.env.VH_CLIENT ?? "vhclientx86_64";
const VH_DEVICE = process.env.VH_DEVICE;
const ONLY_CLIENT = process.env.VH_ONLY_CLIENT;
if (!VH_DEVICE)
throw new Error("set VH_DEVICE to a VirtualHere address (e.g. couch-deck.11) or a device-name substring");
// Send one command to the local VirtualHere client over its IPC channel and return the reply.
const vh = (command: string) =>
new Promise<string>((resolve, reject) =>
execFile(VH_CLIENT, ["-t", command], (err, stdout) => (err ? reject(err) : resolve(stdout))),
);
// Turn a name substring into a VirtualHere address (`server.port`); pass an address straight through.
async function resolveAddress(target: string): Promise<string | null> {
if (/^[\w.-]+\.\d+$/.test(target)) return target;
for (const line of (await vh("LIST")).split("\n"))
if (line.toLowerCase().includes(target.toLowerCase())) {
const addr = line.match(/[\w.-]+\.\d+/); // the address token on the device's line
if (addr) return addr[0];
}
return null;
}
const pf = await connect();
let bound: string | null = null; // the address we currently hold, so we can release exactly it
console.log(`VirtualHere passthrough ready — "${VH_DEVICE}" via ${VH_CLIENT}`);
// Bind the pad to the host as soon as a couch connects. (Prefer `stream.started`/`stream.stopped`
// instead if you'd rather pass it through only while video is actually flowing.)
pf.events.on("client.connected", async (e) => {
if (ONLY_CLIENT && e.client.name !== ONLY_CLIENT) return;
try {
const addr = await resolveAddress(VH_DEVICE);
if (!addr)
return console.warn(`"${VH_DEVICE}" not visible to the VirtualHere client — is the couch sharing it?`);
await vh(`USE,${addr}`);
bound = addr;
console.log(`bound ${addr}${e.client.name}`);
} catch (err) {
console.error("USE failed:", err);
}
});
async function release(why: string): Promise<void> {
if (!bound) return;
try {
await vh(`STOP USING,${bound}`);
console.log(`released ${bound} (${why})`);
} catch (err) {
console.error("STOP USING failed:", err);
}
bound = null;
}
pf.events.on("client.disconnected", (e) => {
if (ONLY_CLIENT && e.client.name !== ONLY_CLIENT) return;
void release(`${e.client.name} left: ${e.reason}`);
});
// Clean shutdown (`systemctl stop`, ^C): hand the pad back to the couch before exiting.
for (const sig of ["SIGINT", "SIGTERM"] as const)
process.on(sig, () => void release("runner stopping").then(() => process.exit(0)));
+33 -8
View File
@@ -3,21 +3,49 @@
"version": "0.1.0",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module",
"private": true,
"license": "MIT OR Apache-2.0",
"homepage": "https://git.unom.io/unom/punktfunk/src/branch/main/sdk",
"repository": {
"type": "git",
"url": "https://git.unom.io/unom/punktfunk.git",
"directory": "sdk"
},
"bugs": {
"url": "https://git.unom.io/unom/punktfunk/issues"
},
"keywords": ["punktfunk", "game-streaming", "automation", "sdk", "effect"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts",
"./effect": "./src/effect.ts"
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./effect": {
"types": "./dist/effect.d.ts",
"default": "./dist/effect.js"
}
},
"bin": {
"punktfunk-scripting": "./dist/runner-cli.js"
},
"files": ["dist", "README.md"],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
"scripts": {
"gen": "openapigen --spec ../api/openapi.json --name Punktfunk --format httpclient > src/gen/punktfunk.ts",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"test": "bun test",
"runner": "bun src/runner-cli.ts"
"runner": "bun src/runner-cli.ts",
"prepublishOnly": "bun run build"
},
"dependencies": {
"peerDependencies": {
"effect": "^4.0.0-beta.98"
},
"devDependencies": {
"effect": "^4.0.0-beta.98",
"@effect/openapi-generator": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@types/bun": "^1.3.0",
@@ -25,8 +53,5 @@
},
"optionalDependencies": {
"undici": "^7.0.0"
},
"bin": {
"punktfunk-scripting": "./src/runner-cli.ts"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}