Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22b352c1da | |||
| 15233a68cf | |||
| 2a637eaf3f | |||
| 6c976e9dc5 | |||
| 691c064a37 | |||
| 34519566ba |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@punktfunk:registry=https://git.unom.io/api/packages/unom/npm/
|
||||
@@ -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 1–3 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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user