diff --git a/.github/workflows/pc-host.yml b/.github/workflows/pc-host.yml index 4a0699d..cce2527 100644 --- a/.github/workflows/pc-host.yml +++ b/.github/workflows/pc-host.yml @@ -15,7 +15,7 @@ jobs: - name: Native libraries run: | sudo apt-get update -qq - sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire + sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules python3 desktop/build.py - name: Tests (real x264, fake capture consent/input) env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4279eb1..8ae61bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: shell: bash run: | sudo apt-get update -qq - sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire + sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules arch=x64; if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi python3 desktop/build.py --out "app/build/desktop/linux-$arch" - uses: msys2/setup-msys2@v2 diff --git a/bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json b/bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json new file mode 100644 index 0000000..a034e83 --- /dev/null +++ b/bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json @@ -0,0 +1,302 @@ +{ + "label": "pc-agent-frame-arm64-final", + "date": "2026-09-28T22:55:27", + "commit": "cd20243", + "config": { + "quality": "balanced", + "mode": "native", + "net": "none", + "delay_ms": 0, + "buffer_ms": 250, + "host": "frame", + "usb": false, + "ssh_opts": [], + "encoder": "", + "duration_s": 10.0, + "browser_flags": [] + }, + "frame_build": "20260925.6191901", + "mac": "26.5.2", + "host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)", + "source": "", + "pc_host": true, + "headset": "0:03.056203 [Info] - 1 - leaving standby", + "scenarios": [ + { + "scenario": "test", + "frames_sent": 400, + "frames_drawn": 400, + "frames_shown": 339, + "duration_s": 10.2, + "stages_ms": { + "capture": { + "p50": 0.1, + "p95": 0.1, + "p99": 0.2, + "n": 400 + }, + "queue": { + "p50": 0.1, + "p95": 0.2, + "p99": 0.4, + "n": 400 + }, + "encode": { + "p50": 2.0, + "p95": 2.7, + "p99": 3.1, + "n": 400 + }, + "socket": { + "p50": 0.1, + "p95": 0.3, + "p99": 0.4, + "n": 400 + }, + "network": { + "p50": 11.8, + "p95": 25.2, + "p99": 37.8, + "n": 400 + }, + "decode": { + "p50": 0.7, + "p95": 1.5, + "p99": 2.0, + "n": 400 + }, + "draw": { + "p50": 0.3, + "p95": 0.7, + "p99": 1.0, + "n": 400 + }, + "present": { + "p50": 0.8, + "p95": 19.3, + "p99": 26.8, + "n": 339 + }, + "content": { + "p50": 15.1, + "p95": 28.6, + "p99": 41.3, + "n": 400 + }, + "content_shown": { + "p50": 17.6, + "p95": 39.0, + "p99": 47.2, + "n": 339 + } + }, + "fps": 39.3, + "fps_shown": 33.4, + "late_pct": 50.25, + "stall_max": 69.3, + "stalls_over_100ms": 0, + "mbps": 0.35, + "keyframes": 6, + "size": "1280x720", + "captured": 612, + "viewer_never_drawn": 0, + "input_ms": { + "p50": 76.6, + "p95": 98.2, + "p99": 127.8, + "n": 19 + }, + "input_shown_ms": { + "p50": 80.5, + "p95": 107.8, + "p99": 128.1, + "n": 16 + }, + "input_parts_ms": { + "uplink": { + "p50": 36.5, + "p95": 57.6, + "p99": 83.2, + "n": 19 + }, + "mac": { + "p50": 14.6, + "p95": 31.3, + "p99": 31.7, + "n": 19 + }, + "back": { + "p50": 15.1, + "p95": 32.8, + "p99": 36.2, + "n": 19 + } + }, + "inputs": { + "asked": 19, + "sent": 19, + "seen": 19 + }, + "timeline": [ + { + "t": 0, + "fps": 40, + "mbps": 0.34, + "content_p50": 11.7, + "content_p95": 27.4, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 1, + "fps": 39, + "mbps": 0.37, + "content_p50": 11.9, + "content_p95": 28.2, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 2, + "fps": 41, + "mbps": 0.35, + "content_p50": 11.4, + "content_p95": 16.6, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 3, + "fps": 39, + "mbps": 0.37, + "content_p50": 12.7, + "content_p95": 28.0, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 4, + "fps": 38, + "mbps": 0.33, + "content_p50": 15.3, + "content_p95": 30.0, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 5, + "fps": 39, + "mbps": 0.37, + "content_p50": 16.7, + "content_p95": 28.5, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 6, + "fps": 41, + "mbps": 0.38, + "content_p50": 15.9, + "content_p95": 29.4, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 7, + "fps": 41, + "mbps": 0.31, + "content_p50": 15.6, + "content_p95": 28.2, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 8, + "fps": 38, + "mbps": 0.38, + "content_p50": 15.5, + "content_p95": 25.9, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 9, + "fps": 38, + "mbps": 0.29, + "content_p50": 16.7, + "content_p95": 28.8, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + }, + { + "t": 10, + "fps": 6, + "mbps": 0.03, + "content_p50": 17.6, + "content_p95": 23.1, + "bitrate": 5529600, + "tier": 0, + "w": 1280, + "net": null + } + ], + "adapt": [], + "content_p50": 15.1, + "content_p95": 28.6, + "input_p50": 76.6, + "input_p95": 98.2, + "grades": { + "input_replies": "local", + "content_p50": "local", + "content_p95": "local", + "input_p50": "acceptable", + "input_p95": "acceptable", + "fps": "bad", + "late_pct": "bad", + "stall_max": "local" + }, + "controller": { + "target": 5529600, + "ceiling": 5529600, + "tier": 0, + "fps": 60, + "scale": 1.0, + "baseRtt": 12.332, + "inFlight": 0, + "slack": 74.12, + "adapt": true + }, + "events": [], + "source_fps": 60.0, + "cpu": { + "host": "not sampled" + }, + "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz", + "show_s": 1.75, + "panel": "valve.steam.desktopgame.2001932457", + "src": "test", + "route": "network" + } + ], + "proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input." +} \ No newline at end of file diff --git a/desktop/build.py b/desktop/build.py index d231c61..4e613ad 100644 --- a/desktop/build.py +++ b/desktop/build.py @@ -50,6 +50,29 @@ def build(destination): target = plugins_out / source.name shutil.copy2(source, target) pending.append(source) + if not win: + # PipeWire dynamically loads support and protocol modules. ldd cannot + # discover those; bundle them with a private client-only config instead + # of accidentally loading a different distro's client modules/config. + libdir = plugin_dir.parent + for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so', + 'pipewire-0.3/libpipewire-module-client-node.so', + 'spa-0.2/support/libspa-support.so'): + source = libdir / relative + if not source.is_file(): + raise SystemExit('Missing PipeWire runtime library: ' + str(source)) + target = destination / 'lib' / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + pending.append(source) + config = destination / 'share' / 'pipewire' + config.mkdir(parents=True, exist_ok=True) + (config / 'client.conf').write_text( + 'context.spa-libs = { support.* = support/libspa-support }\n' + 'context.modules = [\n' + ' { name = libpipewire-module-protocol-native }\n' + ' { name = libpipewire-module-client-node }\n' + ']\n') copied = set() while pending: binary = pending.pop() diff --git a/docs/pc-in-headset.md b/docs/pc-in-headset.md index 0841095..3b1fe73 100644 --- a/docs/pc-in-headset.md +++ b/docs/pc-in-headset.md @@ -18,7 +18,9 @@ protocol, panel launcher and adaptation. It does not launch or require Sunshine, OBS or another desktop-streaming app. GStreamer and its codec plugins are ordinary libraries bundled with the Windows and Linux app; users do not install a GStreamer application. The shared library build keeps -license texts and package provenance alongside the libraries. +license texts and package provenance alongside the libraries. Linux also +bundles the PipeWire client’s dynamically loaded SPA/protocol modules and a +private client configuration; it does not change the desktop’s configuration. First-party alternatives considered (**documented**): Valve Remote Play streams a game/desktop, rather than providing this per-window panel protocol; @@ -144,6 +146,13 @@ same bounded shaping relay, without administrator privileges. - **Verified, Mac:** all 600 states in a 60-second congestion/recovery trace matched the original Swift controller. `tests/test_pc_controller.py` retains the original trace digest as a regression check. +- **Verified, real Frame, current agent at `cd20243`:** the repeated synthetic + probe drew 400 frames at 39.3 fps, content p50/p95 15.1/28.6 ms, and synthetic + input-to-drawn p50 76.6 ms. Test clicks now change the pattern color before + injection is timestamped. The frame-rate/late-frame targets still failed; + this remains a Frame-hosted x264 test through a Mac relay, not a desktop or + physical-laser measurement. Helper exit 0 and cleanup succeeded. + [Current probe result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json). - **Untested:** real Windows WGC → Media Foundation → Frame; real Linux portal → PipeWire → VA-API/x264 → Frame; physical laser input on either. No benchmark numbers for those desktop paths are claimed. diff --git a/tests/test_pcview.py b/tests/test_pcview.py index ba119b1..8a5cc1e 100644 --- a/tests/test_pcview.py +++ b/tests/test_pcview.py @@ -173,6 +173,36 @@ class NativeTests(unittest.TestCase): def tearDown(self): self.request('/close?k='+self.token, 'POST') + @unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries') + def test_bundled_pipewire_context_needs_no_system_modules(self): + # No portal request, remote connection or desktop capture: create and + # destroy a private client context to test the dynamically loaded SPA + # and protocol modules that ldd cannot discover. + code = """ +import ctypes as C, os +lib = C.CDLL(os.path.join(os.environ['FRAME_PC_NATIVE'], 'lib', 'libpipewire-0.3.so.0')) +for name, ret, args in [ + ('pw_init', None, [C.c_void_p, C.c_void_p]), + ('pw_main_loop_new', C.c_void_p, [C.c_void_p]), + ('pw_main_loop_get_loop', C.c_void_p, [C.c_void_p]), + ('pw_context_new', C.c_void_p, [C.c_void_p, C.c_void_p, C.c_size_t]), + ('pw_context_destroy', None, [C.c_void_p]), + ('pw_main_loop_destroy', None, [C.c_void_p])]: + fn = getattr(lib, name) + fn.restype, fn.argtypes = ret, args +lib.pw_init(None, None) +loop = lib.pw_main_loop_new(None) +assert loop, 'bundled SPA loop support did not load' +context = lib.pw_context_new(lib.pw_main_loop_get_loop(loop), None, 0) +assert context, 'bundled PipeWire client modules did not load' +lib.pw_context_destroy(context) +lib.pw_main_loop_destroy(loop) +""" + env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment() + env['FRAME_PC_NATIVE'] = str(capture.NATIVE) + result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stderr) + def test_auth_and_real_h264_stats(self): self.assertEqual(self.request('/status')[0], 403) status, body = self.request('/ticket?src=test&k='+self.token, 'POST') diff --git a/ui/frame_pc_agent.py b/ui/frame_pc_agent.py index d5aec3f..a99c0c5 100644 --- a/ui/frame_pc_agent.py +++ b/ui/frame_pc_agent.py @@ -585,4 +585,7 @@ def main(): if __name__ == '__main__': - main() + try: + main() + except (OSError, RuntimeError, AttributeError) as e: + raise SystemExit('PC streaming helper: ' + str(e)) diff --git a/ui/frame_pcview.py b/ui/frame_pcview.py index 65c00bf..7994a46 100644 --- a/ui/frame_pcview.py +++ b/ui/frame_pcview.py @@ -49,6 +49,10 @@ class PCView(MacView): env['PATH'] = str(NATIVE / 'bin') + os.pathsep + env.get('PATH', '') else: env['LD_LIBRARY_PATH'] = str(NATIVE / 'lib') + os.pathsep + env.get('LD_LIBRARY_PATH', '') + env['PIPEWIRE_MODULE_DIR'] = str(NATIVE / 'lib' / 'pipewire-0.3') + env['SPA_PLUGIN_DIR'] = str(NATIVE / 'lib' / 'spa-0.2') + env['PIPEWIRE_CONFIG_DIR'] = str(NATIVE / 'share' / 'pipewire') + env['PIPEWIRE_CONFIG_NAME'] = 'client.conf' return env def shutdown(self):