mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 18:01:56 +02:00
Merge published main and preserve both PC tool entries
This commit is contained in:
commit
ec2617fd9d
14 files changed
+676
-9
No files matched your search
@@ -9,6 +9,13 @@
|
||||
cannot be identified reliably. No headset access or settings changes.
|
||||
[Sources, verification and limits](docs/windows-wireless-doctor.md).
|
||||
|
||||
- Add Linux Tools → Steam desktop sharing: request `steam -pipewire` only after
|
||||
checking Steam is not running, or open KDE Application Permissions with
|
||||
instructions to revoke a verified Steam screen-sharing session manually. No
|
||||
automatic permission reset, Steam restart, or headset access. Frame Control
|
||||
Linux capture remains disabled; live portal behavior is unverified.
|
||||
[Usage and limits](docs/steam-pipewire.md).
|
||||
|
||||
- Use gamescope's input for the keyboard and trackpad, following the focused
|
||||
headset panel without installing an input service. Start KDE Connect only for
|
||||
accents and emoji, keeping queued text ordered and cancelling it on headset
|
||||
|
||||
@@ -260,6 +260,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Steam desktop sharing (Linux)](docs/steam-pipewire.md) | External Steam helper: launch with `-pipewire` after exiting Steam, or open KDE permissions to re-pick a screen manually; no automatic permission reset |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.18",
|
||||
"version": "0.4.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.18",
|
||||
"version": "0.4.19",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.18",
|
||||
"version": "0.4.19",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
@@ -1,5 +1,9 @@
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
For Steam's own Linux Wayland desktop-sharing flow, see the separate
|
||||
[Steam desktop sharing helper](steam-pipewire.md). It does not enable this
|
||||
feature on Linux.
|
||||
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Individual windows are available only
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Steam desktop sharing on a Linux PC
|
||||
|
||||
**Tools → Steam desktop sharing** helps with Steam's own `steam -pipewire`
|
||||
desktop-in-headset flow on a local Wayland PC. It needs no Frame connection.
|
||||
It is separate from Frame Control's [PC in the headset](pc-in-headset.md),
|
||||
which remains disabled on Linux. Steam and the desktop portal own the capture,
|
||||
screen picker and headset flow; this helper does not provide a stream.
|
||||
|
||||
## Launch Steam with PipeWire
|
||||
|
||||
Save your game and use **Steam → Exit** first. Then click **Launch Steam with
|
||||
PipeWire**. Frame Control checks this user's local processes, finds `steam` on
|
||||
PATH, and requests `steam -pipewire`. This flag applies to that launch only.
|
||||
No Steam settings, login files or startup entries are changed. An existing
|
||||
Steam session is never stopped, signalled or silently switched.
|
||||
|
||||
If Steam or a Steam launcher/helper is running, the button explains that Steam
|
||||
must be exited first. If the process inventory cannot be read, it refuses to
|
||||
launch. It also refuses root, SSH, headless, X11 and Flatpak-sandboxed Frame
|
||||
Control sessions. Run Frame Control as your normal user in the local Wayland
|
||||
desktop with its session bus.
|
||||
|
||||
For a Flatpak-only Steam install without a `steam` command on PATH, the helper
|
||||
provides a manual command instead of choosing an installation for you. After
|
||||
exiting Steam, run `flatpak run com.valvesoftware.Steam -pipewire` in a local
|
||||
terminal. Flatpak Steam processes are included in the existing-session check.
|
||||
|
||||
A successful request is **not confirmation of a picker or stream**. Steam may
|
||||
exit or fail after the initial check, and another launcher can race the local
|
||||
process check. Do not start Steam elsewhere at the same time. If nothing opens,
|
||||
run the command yourself in a terminal to see Steam's error.
|
||||
|
||||
## Reset screen-share choice
|
||||
|
||||
Click **Reset screen-share choice…** to request KDE's **Application Permissions**
|
||||
page. This button does **not** revoke anything or report a completed reset.
|
||||
Frame Control cannot reliably map a Steam runtime display name to a
|
||||
Steam-specific portal grant or restore token across backends. It never reads,
|
||||
edits or clears the portal permission store or Steam tokens.
|
||||
|
||||
On KDE:
|
||||
|
||||
1. Open **System Settings → Applications → Application Permissions** if the
|
||||
button cannot open it.
|
||||
2. Find the entry for Steam. The Ubuntu 26.04 KDE report showed **Steam Linux
|
||||
Runtime 3.0**. That display name alone is not proof that every session there
|
||||
is safe to remove; verify the session belongs to your Steam desktop share.
|
||||
3. Under **Screen sharing**, choose **Manage 1 Session** (the count may differ).
|
||||
4. Choose **Revoke** for that verified session only. **Do not use Revoke All**
|
||||
or change another application's grants. If ownership is ambiguous, stop.
|
||||
5. Retry Steam's desktop sharing flow. The reporter saw the portal picker again
|
||||
after revoking that session. Results can differ by Steam and portal version.
|
||||
|
||||
The reported entry sometimes appeared intermittently. If it is missing, do not
|
||||
clear other entries or the whole portal store. Check again when Steam's sharing
|
||||
session is present. If **Application Permissions** itself is missing on
|
||||
Ubuntu/Kubuntu, check your distribution's `kde-config-flatpak` package: the
|
||||
reporter needed that package and a reboot before the page appeared. Frame
|
||||
Control does not install packages, reboot, request root or change security
|
||||
settings. Older KDE modules may lack this page; other desktops receive guidance
|
||||
only, with no automatic reset or guessed permission target.
|
||||
|
||||
The launcher uses `kcmshell6 --list` to verify the exact module, then
|
||||
`kcmshell6 kcm_app-permissions`. It does not substitute the older Flatpak-only
|
||||
page. A request to open the page does not prove that a window became visible.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- User-supplied report and screenshot description:
|
||||
[ExpressGinga81's thread and replies](https://x.com/ExpressGinga81/status/2113242533530911058).
|
||||
The report describes Ubuntu 26.04 KDE, the initial picker appearing once,
|
||||
the Steam Linux Runtime 3.0 entry, and Revoke making the picker reappear.
|
||||
This is reported behavior, not a live test performed for this feature.
|
||||
- KDE upstream [`flatpak-kcm`](https://github.com/KDE/flatpak-kcm):
|
||||
`CMakeLists.txt` and `kcm_app-permissions.json` identify Application
|
||||
Permissions; `ui/SessionList.qml` distinguishes Revoke from Revoke All.
|
||||
- Automated tests mock Steam, processes and KDE commands. HTTP tests verify
|
||||
request guards, no actions on GET, and no headset work. Existing CI discovers
|
||||
these tests on Linux, macOS and Windows; they do not need a desktop portal.
|
||||
- lxso2 has no interactive Wayland/PipeWire portal for a chooser test.
|
||||
**No live chooser, revoke, Steam launch or headset display is verified here.**
|
||||
No Frame access was performed for this task, including during the hold before
|
||||
2026-10-24 13:00 UTC. The helper never invokes SSH or Frame controls.
|
||||
|
||||
## Announcement
|
||||
|
||||
Linux Wayland users can now find **Steam desktop sharing** under Tools to
|
||||
request Steam with `-pipewire` after exiting Steam, or open KDE's Application
|
||||
Permissions to revoke only a verified Steam screen-sharing session and try the
|
||||
picker again. Frame Control does not clear permissions automatically. This is
|
||||
help for Steam's own desktop-sharing flow; Frame Control's Linux capture
|
||||
feature remains disabled. Live picker behavior still needs desktop validation.
|
||||
@@ -0,0 +1,82 @@
|
||||
const {test} = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const html = fs.readFileSync(path.join(__dirname, '../ui/index.html'), 'utf8');
|
||||
const code = html.split('// Local Steam helper: never start')[1].split('// ---- shared destinations')[0];
|
||||
|
||||
function fixture() {
|
||||
const elements = new Map(), requests = [];
|
||||
const $ = id => {
|
||||
if (!elements.has(id)) elements.set(id, {disabled:true, textContent:''});
|
||||
return elements.get(id);
|
||||
};
|
||||
const ctx = {$, api:(url, body) => new Promise((resolve, reject) => requests.push({url, body, resolve, reject}))};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext('// never start' + code, ctx);
|
||||
return {ctx, $, requests};
|
||||
}
|
||||
|
||||
test('navigation reads status; only clicks request launch or permission UI', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
assert.equal(requests.length, 0);
|
||||
const load = ctx.loadSteamPipewire();
|
||||
assert.equal(requests[0].url, '/api/steam-pipewire');
|
||||
assert.equal(requests[0].body, undefined);
|
||||
requests[0].resolve({available:true, guidance:'Verify the Steam session'});
|
||||
await load;
|
||||
assert.equal($('steamPipewireLaunch').disabled, false);
|
||||
assert.equal($('steamPipewireGuidance').textContent, 'Verify the Steam session');
|
||||
const launch = $('steamPipewireLaunch').onclick();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(requests[1].body)), {action:'launch', confirmed:true});
|
||||
assert.equal($('steamPipewireReset').disabled, true);
|
||||
await $('steamPipewireReset').onclick(); // repeated action suppressed
|
||||
assert.equal(requests.length, 2);
|
||||
requests[1].reject(new Error('Exit Steam first'));
|
||||
await launch;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'Exit Steam first');
|
||||
const reset = $('steamPipewireReset').onclick();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(requests[2].body)), {action:'permissions', confirmed:true});
|
||||
requests[2].resolve({reset:false, opened:true, message:'No permission was changed'});
|
||||
await reset;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'No permission was changed');
|
||||
});
|
||||
|
||||
test('unsupported status and read failure keep actions disabled', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
const load = ctx.loadSteamPipewire();
|
||||
requests[0].resolve({available:false, reason:'Local Wayland required'});
|
||||
await load;
|
||||
assert.equal($('steamPipewireLaunch').disabled, true);
|
||||
assert.equal($('steamPipewireReset').disabled, true);
|
||||
assert.equal($('steamPipewireStatus').textContent, 'Local Wayland required');
|
||||
const retry = ctx.loadSteamPipewire();
|
||||
requests[1].reject(new Error('Unavailable'));
|
||||
await retry;
|
||||
assert.equal($('steamPipewireLaunch').disabled, true);
|
||||
});
|
||||
|
||||
test('stale status cannot overwrite an action result', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
const load = ctx.loadSteamPipewire();
|
||||
const reset = ctx.steamPipewireAction('permissions');
|
||||
requests[1].resolve({message:'No permission was changed'});
|
||||
await reset;
|
||||
requests[0].resolve({available:true});
|
||||
await load;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'No permission was changed');
|
||||
});
|
||||
|
||||
|
||||
test('Tools navigation opens the separate Steam panel without starting PC capture', () => {
|
||||
const {workspaceFixture} = require('./workspace_fixture.cjs');
|
||||
const f = workspaceFixture();
|
||||
f.navigate('#steamPipewire');
|
||||
assert.equal(f.activePage, 'tools');
|
||||
assert.equal(f.el('steamPipewire').hidden, false);
|
||||
assert.equal(f.el('macview').hidden, true);
|
||||
assert.ok(f.calls.some(call => call[0] === 'steam-pipewire'));
|
||||
assert.ok(!f.calls.some(call => call[0] === 'macview'));
|
||||
assert.match(html, /\$\("steamPipewireLink"\)\.hidden = h.mobile \|\| h.os !== "Linux"/);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""PC-only Steam helper tests. All app launches and KDE calls are mocked.
|
||||
No portal, interactive Wayland session, Steam installation or Frame is used.
|
||||
"""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_steam_pipewire as helper
|
||||
import server
|
||||
|
||||
ENV = {'XDG_SESSION_TYPE': 'wayland', 'WAYLAND_DISPLAY': 'wayland-0',
|
||||
'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/mock/session-bus', 'XDG_CURRENT_DESKTOP': 'KDE'}
|
||||
|
||||
|
||||
class HelperTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.stack = contextlib.ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
patches = [mock.patch.dict(os.environ, ENV, clear=True),
|
||||
mock.patch.object(helper.sys, 'platform', 'linux'),
|
||||
mock.patch.object(helper.os, 'geteuid', return_value=1000, create=True),
|
||||
mock.patch.object(helper.Path, 'exists', return_value=False),
|
||||
mock.patch.object(helper, '_launch', None),
|
||||
mock.patch.object(helper, '_steam_running', return_value=False),
|
||||
mock.patch.object(helper.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n),
|
||||
mock.patch.object(helper.subprocess, 'Popen'),
|
||||
mock.patch.object(helper.subprocess, 'run')]
|
||||
started = [self.stack.enter_context(p) for p in patches]
|
||||
self.running, self.which, self.spawn, self.run = started[-4:]
|
||||
self.spawn.return_value.poll.return_value = None
|
||||
self.spawn.return_value.wait.side_effect = subprocess.TimeoutExpired('app', 1)
|
||||
self.run.return_value = subprocess.CompletedProcess([], 0, 'kcm_app-permissions - Application Permissions\n', '')
|
||||
|
||||
def action(self, action):
|
||||
return helper.action({'action': action, 'confirmed': True})
|
||||
|
||||
def test_status_is_read_only(self):
|
||||
self.assertTrue(helper.status()['available'])
|
||||
self.spawn.assert_not_called()
|
||||
self.run.assert_not_called()
|
||||
self.running.assert_not_called()
|
||||
|
||||
def test_platform_and_local_session_gates(self):
|
||||
for platform in ('win32', 'darwin'):
|
||||
with self.subTest(platform=platform), mock.patch.object(helper.sys, 'platform', platform):
|
||||
self.assertFalse(helper.status()['available'])
|
||||
with self.assertRaises(helper.HelperError):
|
||||
self.action('launch')
|
||||
for change in ({'FRAME_LOCAL': '1'}, {'XDG_SESSION_TYPE': 'x11'}, {'WAYLAND_DISPLAY': ''},
|
||||
{'DBUS_SESSION_BUS_ADDRESS': ''}, {'SSH_CONNECTION': 'remote'}, {'SSH_CLIENT': 'remote'}):
|
||||
with self.subTest(change=change), mock.patch.dict(os.environ, change):
|
||||
for action in ('launch', 'permissions'):
|
||||
with self.assertRaises(helper.HelperError):
|
||||
self.action(action)
|
||||
self.spawn.assert_not_called()
|
||||
self.run.assert_not_called()
|
||||
|
||||
def test_root_and_sandbox_refused(self):
|
||||
with mock.patch.object(helper.os, 'geteuid', return_value=0):
|
||||
self.assertIn('not root', helper.status()['reason'])
|
||||
with mock.patch.object(helper.Path, 'exists', return_value=True):
|
||||
with self.assertRaisesRegex(helper.HelperError, 'sandbox'):
|
||||
self.action('launch')
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
def test_only_explicit_supported_actions(self):
|
||||
for body in ({}, {'action': 'launch'}, {'action': 'permissions', 'confirmed': 'true'},
|
||||
{'action': 'delete-all', 'confirmed': True}):
|
||||
with self.subTest(body=body), self.assertRaises(helper.HelperError):
|
||||
helper.action(body)
|
||||
self.spawn.assert_not_called()
|
||||
self.run.assert_not_called()
|
||||
|
||||
def test_launch_exact_command_and_duplicate_guard(self):
|
||||
result = self.action('launch')
|
||||
self.assertTrue(result['launched'])
|
||||
self.assertIn('does not confirm', result['message'])
|
||||
self.spawn.assert_called_once_with(['/usr/bin/steam', '-pipewire'], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
with self.assertRaisesRegex(helper.HelperError, 'already running or starting'):
|
||||
self.action('launch')
|
||||
self.assertEqual(self.spawn.call_count, 1)
|
||||
self.run.assert_not_called()
|
||||
|
||||
def test_running_and_unknown_steam_fail_closed(self):
|
||||
self.running.return_value = True
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Steam > Exit'):
|
||||
self.action('launch')
|
||||
self.running.side_effect = helper.HelperError('Cannot verify')
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
|
||||
self.action('launch')
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
def test_missing_launcher_does_not_guess_flatpak(self):
|
||||
self.which.side_effect = None
|
||||
self.which.return_value = None
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Flatpak-only'):
|
||||
self.action('launch')
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
def test_launch_error_is_not_success(self):
|
||||
self.spawn.side_effect = OSError('missing')
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Could not start'):
|
||||
self.action('launch')
|
||||
|
||||
def test_immediate_application_failure_is_not_success(self):
|
||||
self.spawn.return_value.wait.side_effect = None
|
||||
self.spawn.return_value.wait.return_value = 1
|
||||
for action in ('launch', 'permissions'):
|
||||
with self.subTest(action=action), mock.patch.object(helper, '_launch', None):
|
||||
with self.assertRaisesRegex(helper.HelperError, 'exited with an error'):
|
||||
self.action(action)
|
||||
|
||||
def test_permissions_opens_only_verified_exact_module_never_resets(self):
|
||||
result = self.action('permissions')
|
||||
self.assertTrue(result['opened'])
|
||||
self.assertFalse(result['reset'])
|
||||
self.assertIn('No permission was changed', result['message'])
|
||||
self.run.assert_called_once_with(['/usr/bin/kcmshell6', '--list'], capture_output=True,
|
||||
text=True, timeout=5, stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(self.spawn.call_args.args[0], ['/usr/bin/kcmshell6', 'kcm_app-permissions'])
|
||||
self.running.assert_not_called()
|
||||
|
||||
def test_wrong_module_missing_tool_and_other_desktops_only_give_guidance(self):
|
||||
for listing in ('kcm_flatpak - Flatpak Permissions', 'evil_kcm_app-permissions - nope', ''):
|
||||
self.run.return_value.stdout = listing
|
||||
result = self.action('permissions')
|
||||
self.assertFalse(result['reset'])
|
||||
self.assertFalse(result['opened'])
|
||||
self.which.side_effect = None
|
||||
self.which.return_value = None
|
||||
self.assertFalse(self.action('permissions')['opened'])
|
||||
self.which.reset_mock()
|
||||
with mock.patch.dict(os.environ, {'XDG_CURRENT_DESKTOP': 'GNOME'}):
|
||||
self.assertFalse(self.action('permissions')['opened'])
|
||||
self.which.assert_not_called()
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
def test_module_listing_error_or_timeout_is_guidance_not_reset(self):
|
||||
for error in (OSError('missing'), subprocess.TimeoutExpired('kcm', 5)):
|
||||
self.run.side_effect = error
|
||||
result = self.action('permissions')
|
||||
self.assertFalse(result['opened'])
|
||||
self.assertFalse(result['reset'])
|
||||
self.assertIn('kde-config-flatpak', result['message'])
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
|
||||
class ProcessTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.stack = contextlib.ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
self.tmp = self.stack.enter_context(tempfile.TemporaryDirectory())
|
||||
self.proc = Path(self.tmp)
|
||||
# Match the temp files' owner even when tests run as root or on Windows.
|
||||
self.stack.enter_context(mock.patch.object(helper.os, 'geteuid', return_value=self.proc.stat().st_uid, create=True))
|
||||
self.add('1', 'python', b'python\0server.py\0')
|
||||
|
||||
def add(self, pid, comm, cmdline):
|
||||
p = self.proc / pid
|
||||
p.mkdir()
|
||||
(p / 'comm').write_text(comm)
|
||||
(p / 'cmdline').write_bytes(cmdline)
|
||||
|
||||
def test_native_launcher_webhelper_and_flatpak_detected(self):
|
||||
for comm, cmd in [('steam', b'/x/steam\0'), ('bash', b'bash\0/x/steam.sh\0'),
|
||||
('steamwebhelper', b'/x/steamwebhelper\0'),
|
||||
('flatpak', b'flatpak\0run\0com.valvesoftware.Steam\0')]:
|
||||
with self.subTest(comm=comm):
|
||||
(self.proc / '1' / 'comm').write_text(comm)
|
||||
(self.proc / '1' / 'cmdline').write_bytes(cmd)
|
||||
self.assertTrue(helper._steam_running(self.proc))
|
||||
|
||||
def test_unrelated_names_are_not_steam(self):
|
||||
self.add('2', 'steamdeck-tools', b'/bin/steam-tools\0')
|
||||
self.assertFalse(helper._steam_running(self.proc))
|
||||
|
||||
def test_other_user_steam_is_not_this_users_instance(self):
|
||||
self.add('2', 'steam', b'steam\0')
|
||||
with mock.patch.object(helper.os, 'geteuid', return_value=self.proc.stat().st_uid + 1):
|
||||
self.assertFalse(helper._steam_running(self.proc))
|
||||
|
||||
def test_exited_process_is_skipped(self):
|
||||
(self.proc / '2').mkdir()
|
||||
self.assertFalse(helper._steam_running(self.proc))
|
||||
|
||||
def test_unreadable_or_missing_inventory_fails_closed(self):
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
|
||||
helper._steam_running(self.proc / 'missing')
|
||||
with mock.patch.object(helper.Path, 'read_bytes', side_effect=PermissionError('denied')):
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
|
||||
helper._steam_running(self.proc)
|
||||
|
||||
|
||||
class RouteTests(unittest.TestCase):
|
||||
def test_headset_and_private_servers_never_call_helper(self):
|
||||
for local, private in ((True, False), (False, True)):
|
||||
with mock.patch.object(server, 'LOCAL', local), mock.patch.object(server, 'PRIVATE', private), \
|
||||
mock.patch.object(helper, 'action') as action, mock.patch.object(helper, 'status') as status:
|
||||
self.assertFalse(server.steam_pipewire_state()['available'])
|
||||
with self.assertRaises(server.Failure):
|
||||
server.steam_pipewire_action({'action': 'launch', 'confirmed': True})
|
||||
action.assert_not_called()
|
||||
status.assert_not_called()
|
||||
|
||||
def test_http_guards_get_read_only_and_post_bypasses_headset_work(self):
|
||||
with ThreadingHTTPServer(('127.0.0.1', 0), server.Handler) as httpd:
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
def request(method, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', httpd.server_port)
|
||||
conn.request(method, '/api/steam-pipewire', json.dumps(body) if body else None, headers or {})
|
||||
reply = conn.getresponse()
|
||||
code, data = reply.status, json.loads(reply.read())
|
||||
conn.close()
|
||||
return code, data
|
||||
with mock.patch.object(server, 'LOCAL', False), mock.patch.object(server, 'PRIVATE', False), \
|
||||
mock.patch.object(server, 'working', side_effect=AssertionError('headset work')), \
|
||||
mock.patch.object(server, 'ssh', side_effect=AssertionError('Frame access')), \
|
||||
mock.patch.object(helper, 'action', return_value={'reset': False}) as action, \
|
||||
mock.patch.object(helper, 'status', return_value={'available': True}) as status:
|
||||
self.assertEqual(request('POST', {'action': 'launch'})[0], 403)
|
||||
self.assertEqual(request('GET')[0], 403)
|
||||
action.assert_not_called()
|
||||
headers = {'X-Frame-UI': server.UI_KEY, 'X-Frame-Device': 'unrelated-device'}
|
||||
self.assertEqual(request('GET', headers=headers), (200, {'available': True}))
|
||||
action.assert_not_called()
|
||||
status.assert_called_once()
|
||||
body = {'action': 'permissions', 'confirmed': True}
|
||||
self.assertEqual(request('POST', body, headers), (200, {'reset': False}))
|
||||
action.assert_called_once_with(body)
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
class PageTests(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page handlers')
|
||||
def test_production_page_handlers(self):
|
||||
result = subprocess.run(['node', '--test', str(ROOT / 'tests/steam_pipewire_ui.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -25,7 +25,7 @@ function workspaceFixture() {
|
||||
const context={URL,document:{querySelectorAll:s=>s==='.page'?pages:s==='#nav a'?links:controls[s]||[],title:'',body:{addEventListener(k,fn){listeners['body-'+k]=fn;}}},
|
||||
window:win,location,scroller:()=>({get scrollTop(){return win.scrollY;},scrollTo:o=>win.scrollTo(o)}),scrollToY:y=>win.scrollTo({top:y}),requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);},
|
||||
loadAndroid(){calls.push(['android']);},closeDevPop(){},metaLib:{connected:false},renderMetaApps(){},
|
||||
loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
|
||||
loadSteamPipewire(){calls.push(['steam-pipewire']);},loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
|
||||
gm:{src:'owned',dl:null,byId:new Map(),shown:0,shownBySource:{owned:0,store:0}},setGameSource(src){context.gm.src=src;},state:null,stateRead:{valid:false},online:null,devGen:0,androidApps:[],jobsRunning:0,viewGen:0,
|
||||
esc:s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))};
|
||||
vm.createContext(context);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Local Linux helper for Steam's own desktop sharing; never a Frame client.
|
||||
|
||||
No portal grants/tokens are read or written. KDE owns the manual Revoke UI.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
MODULE = 'kcm_app-permissions'
|
||||
GUIDANCE = (
|
||||
'In KDE System Settings > Applications > Application Permissions, find the Steam entry '
|
||||
'(reported as Steam Linux Runtime 3.0). Under Screen sharing, choose Manage 1 Session '
|
||||
'(the count may differ), verify it belongs to Steam, then Revoke only that session. '
|
||||
'Do not use Revoke All or change another app. Retry Steam desktop sharing to choose a screen. '
|
||||
'If the entry is missing or ambiguous, stop; it can appear intermittently. '
|
||||
'If Application Permissions is missing on Ubuntu/Kubuntu, check whether your distribution '
|
||||
'provides kde-config-flatpak; the reporter needed that package and a reboot. '
|
||||
'Frame Control does not install packages or change permissions.'
|
||||
)
|
||||
_lock = threading.Lock()
|
||||
_launch = None
|
||||
|
||||
|
||||
class HelperError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def unavailable():
|
||||
if sys.platform != 'linux' or os.environ.get('FRAME_LOCAL') == '1':
|
||||
return 'This helper runs on your Linux PC, not the headset.'
|
||||
if os.geteuid() == 0:
|
||||
return 'Open Frame Control as your normal desktop user, not root.'
|
||||
if Path('/.flatpak-info').exists():
|
||||
return 'This sandbox cannot verify host Steam processes. Use a terminal on your PC.'
|
||||
if os.environ.get('SSH_CONNECTION') or os.environ.get('SSH_CLIENT'):
|
||||
return 'Open Frame Control in your local Wayland desktop session, not over SSH.'
|
||||
if (os.environ.get('XDG_SESSION_TYPE', '').lower() != 'wayland'
|
||||
or not os.environ.get('WAYLAND_DISPLAY') or not os.environ.get('DBUS_SESSION_BUS_ADDRESS')):
|
||||
return 'A local Wayland desktop with its session bus is required. X11 and headless sessions are unsupported.'
|
||||
return ''
|
||||
|
||||
|
||||
def status():
|
||||
reason = unavailable()
|
||||
return {'available': not reason, 'reason': reason, 'guidance': GUIDANCE}
|
||||
|
||||
|
||||
def _steam_running(proc=Path('/proc')):
|
||||
"""Fail closed if our process inventory cannot be read. No command execution.
|
||||
|
||||
Include launch scripts and Flatpak Steam, not just the final Steam binary.
|
||||
Other users' processes are irrelevant to this user's Steam instance.
|
||||
"""
|
||||
try:
|
||||
entries = list(proc.iterdir())
|
||||
if not any(p.name.isdecimal() for p in entries):
|
||||
raise OSError('empty process inventory')
|
||||
for p in entries:
|
||||
if not p.name.isdecimal():
|
||||
continue
|
||||
try:
|
||||
if p.stat().st_uid != os.geteuid():
|
||||
continue
|
||||
comm = (p / 'comm').read_text().strip().lower()
|
||||
args = (p / 'cmdline').read_bytes().decode('utf-8', 'replace').split('\0')
|
||||
except FileNotFoundError:
|
||||
continue # a process exited while scanning
|
||||
names = {Path(a).name.lower() for a in args if a}
|
||||
if (comm in {'steam', 'steamwebhelper', 'steam.sh'}
|
||||
or names.intersection({'steam', 'steam.sh', 'steamwebhelper', 'com.valvesoftware.steam'})):
|
||||
return True
|
||||
return False
|
||||
except OSError as exc:
|
||||
raise HelperError('Cannot verify whether Steam is running. Exit Steam and launch '
|
||||
'steam -pipewire yourself from a local terminal.') from exc
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
try:
|
||||
return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
except OSError as exc:
|
||||
raise HelperError('Could not start the local application. Check it is installed and opens '
|
||||
'from your desktop. No permission was changed.') from exc
|
||||
|
||||
|
||||
def _check_start(child):
|
||||
try:
|
||||
code = child.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
return
|
||||
if code:
|
||||
raise HelperError('The local application exited with an error. Try opening it from your '
|
||||
'desktop to see its error. No permission was changed.')
|
||||
|
||||
|
||||
def action(body):
|
||||
global _launch
|
||||
kind = body.get('action')
|
||||
if kind not in ('launch', 'permissions'):
|
||||
raise HelperError('Unknown Steam helper action.')
|
||||
if body.get('confirmed') is not True:
|
||||
raise HelperError('Use the Steam helper button to request this action explicitly.')
|
||||
reason = unavailable()
|
||||
if reason:
|
||||
raise HelperError(reason)
|
||||
with _lock:
|
||||
if kind == 'launch':
|
||||
if (_launch is not None and _launch.poll() is None) or _steam_running():
|
||||
raise HelperError('Steam is already running or starting. Exit Steam using Steam > Exit '
|
||||
'(after saving games), then click Launch Steam with PipeWire again. '
|
||||
'Frame Control will not stop it or change an existing session.')
|
||||
steam = shutil.which('steam')
|
||||
if not steam:
|
||||
raise HelperError('No steam launcher was found on PATH. For a Flatpak-only install, '
|
||||
'exit Steam and run flatpak run com.valvesoftware.Steam -pipewire '
|
||||
'yourself from a local terminal. Nothing was launched.')
|
||||
_launch = _spawn([steam, '-pipewire'])
|
||||
_check_start(_launch)
|
||||
return {'launched': True, 'message': 'Requested Steam with -pipewire for this launch only. '
|
||||
'Use Steam\'s desktop-in-headset flow; Steam and your desktop portal control the picker. '
|
||||
'This does not confirm screen sharing started.'}
|
||||
|
||||
# There is no portable verified mapping from Steam's runtime display name
|
||||
# to a portal restore token. Never guess an app ID or delete a grant.
|
||||
desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').upper().split(':')
|
||||
kcm = shutil.which('kcmshell6') if 'KDE' in desktop else None
|
||||
if not kcm:
|
||||
return {'reset': False, 'opened': False, 'message': 'No permission was changed. '
|
||||
'KDE Application Permissions could not be opened automatically. ' + GUIDANCE}
|
||||
try:
|
||||
listing = subprocess.run([kcm, '--list'], capture_output=True, text=True, timeout=5,
|
||||
stdin=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
listing = None
|
||||
if (listing is None or listing.returncode
|
||||
or not re.search(r'^\s*' + re.escape(MODULE) + r'\s+-', listing.stdout, re.M)):
|
||||
return {'reset': False, 'opened': False, 'message': 'No permission was changed. '
|
||||
'The KDE Application Permissions module is unavailable. ' + GUIDANCE}
|
||||
child = _spawn([kcm, MODULE])
|
||||
_check_start(child)
|
||||
return {'reset': False, 'opened': True, 'message': 'Requested KDE Application Permissions. '
|
||||
'No permission was changed by Frame Control. ' + GUIDANCE}
|
||||
+56
-3
@@ -1431,7 +1431,7 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="wireless" hidden>
|
||||
<h2>Wireless Adapter doctor</h2>
|
||||
@@ -1463,6 +1463,23 @@
|
||||
<p class="hint">Open the saved HTML in your browser; it works offline and includes JSON/CSV downloads. Unknown readings are never treated as zero. Rails overlap; do not add them. Sensor accuracy is unverified. Reports include sensor paths and OS builds: review before sharing.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="steamPipewire" hidden>
|
||||
<h2>Steam desktop sharing <span class="tag">Linux PC only</span></h2>
|
||||
<p>Help for Steam's own <code>steam -pipewire</code> desktop-in-headset flow on Wayland.
|
||||
Frame Control's Linux “PC in the headset” feature remains disabled. This helper needs no headset connection.</p>
|
||||
<p>Save your games and exit Steam using <b>Steam → Exit</b> before launching.
|
||||
This launch does not save a setting or restart an existing Steam session.</p>
|
||||
<div class="row"><button id="steamPipewireLaunch" disabled>Launch Steam with PipeWire</button>
|
||||
<button id="steamPipewireReset" disabled>Reset screen-share choice…</button></div>
|
||||
<p class="sub">Reset opens KDE's Application Permissions so you can revoke a verified Steam screen-sharing
|
||||
session yourself. Frame Control does not reset permissions automatically. Other desktops get guidance only.</p>
|
||||
<p id="steamPipewireGuidance">KDE System Settings → Applications → Application Permissions → Steam Linux Runtime 3.0
|
||||
→ Screen sharing → Manage 1 Session → Revoke. Verify the session belongs to Steam. Never use Revoke All.
|
||||
If the entry is missing or ambiguous, stop.</p>
|
||||
<p id="steamPipewireStatus" role="status" aria-live="polite"></p>
|
||||
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/steam-pipewire.md" target="_blank" rel="noopener">Setup, missing KDE module and limitations →</a></p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
<div class="stats" id="vrMetrics"></div>
|
||||
@@ -5945,13 +5962,47 @@ $("macview").onclick = async e => {
|
||||
setTimeout(loadMacView, 800);
|
||||
};
|
||||
|
||||
// Local Steam helper: never start an action on navigation or a status read.
|
||||
let steamPipewireBusy = false, steamPipewireRead = 0;
|
||||
async function loadSteamPipewire() {
|
||||
if (steamPipewireBusy) return;
|
||||
const read = ++steamPipewireRead;
|
||||
try {
|
||||
const s = await api('/api/steam-pipewire');
|
||||
if (read !== steamPipewireRead) return;
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = !s.available;
|
||||
$('steamPipewireStatus').textContent = s.reason || '';
|
||||
if (s.guidance) $('steamPipewireGuidance').textContent = s.guidance;
|
||||
} catch (e) {
|
||||
if (read !== steamPipewireRead) return;
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||||
$('steamPipewireStatus').textContent = e.message;
|
||||
}
|
||||
}
|
||||
async function steamPipewireAction(action) {
|
||||
if (steamPipewireBusy) return;
|
||||
steamPipewireBusy = true;
|
||||
steamPipewireRead++;
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||||
try {
|
||||
const r = await api('/api/steam-pipewire', {action, confirmed: true});
|
||||
$('steamPipewireStatus').textContent = r.message;
|
||||
} catch (e) { $('steamPipewireStatus').textContent = e.message; }
|
||||
finally {
|
||||
steamPipewireBusy = false;
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = false;
|
||||
}
|
||||
}
|
||||
$('steamPipewireLaunch').onclick = () => steamPipewireAction('launch');
|
||||
$('steamPipewireReset').onclick = () => steamPipewireAction('permissions');
|
||||
|
||||
// ---- shared destinations and intentional secondary workspaces ----
|
||||
const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
steamPipewire:"tools", vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
@@ -6057,6 +6108,7 @@ function showPage(e) {
|
||||
const host = $(kind === 'tools' ? 'toolWorkspace' : 'settingsWorkspace');
|
||||
if (activeTool && $(activeTool).parentNode !== host) host.append($(activeTool));
|
||||
}
|
||||
if (tool === 'steamPipewire') loadSteamPipewire();
|
||||
if (tool === 'macview') loadMacView();
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'backup') loadBackups();
|
||||
@@ -7462,6 +7514,7 @@ api("/api/host").then(h => {
|
||||
$("macToolLink").hidden = h.mobile || h.os !== "macOS";
|
||||
$("pcToolLink").hidden = h.mobile || h.os !== "Windows";
|
||||
$("wirelessToolGroup").hidden = h.mobile || h.os !== "Windows";
|
||||
$("steamPipewireLink").hidden = h.mobile || h.os !== "Linux";
|
||||
if (!h.mobile && h.os === "Windows") TOOL_TITLES.macview = "PC in the headset";
|
||||
$("backupToolLink").hidden = !!h.mobile; // backups are folders on a computer
|
||||
if (h.mobile) return;
|
||||
|
||||
+21
-1
@@ -60,6 +60,7 @@ import frame_host # noqa: E402
|
||||
import frame_link # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
import frame_pcview # noqa: E402
|
||||
import frame_steam_pipewire # noqa: E402
|
||||
import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
import frame_quest # noqa: E402
|
||||
@@ -149,7 +150,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
# install's clean-up) to the other headset.
|
||||
_work_lock = threading.Lock()
|
||||
_work = [0]
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
|
||||
NOT_HEADSET_WORK = {"/api/steam-pipewire", "/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -3077,6 +3078,22 @@ def push_media(path):
|
||||
"id": token + "/" + name}
|
||||
|
||||
|
||||
# Local Steam helper: no headset route, SSH, or PC capture helper.
|
||||
def steam_pipewire_state():
|
||||
if LOCAL or PRIVATE:
|
||||
return {"available": False, "reason": "Open this helper in Frame Control on your Linux PC."}
|
||||
return frame_steam_pipewire.status()
|
||||
|
||||
|
||||
def steam_pipewire_action(body):
|
||||
if LOCAL or PRIVATE:
|
||||
raise Failure("Open this helper in Frame Control on your Linux PC.", 403)
|
||||
try:
|
||||
return frame_steam_pipewire.action(body)
|
||||
except frame_steam_pipewire.HelperError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
# ---- Mac in the headset (frame_macview.py), PC in the headset (frame_pcview.py) ----
|
||||
|
||||
# The tunnel gets its own connection: the shared master's options would win
|
||||
@@ -3266,6 +3283,7 @@ def power_action(body):
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/steam-pipewire": steam_pipewire_action,
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/vr": vr,
|
||||
@@ -3476,6 +3494,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == "/api/agent/approval":
|
||||
token = (parse_qs(url.query).get("confirmation") or [""])[0]
|
||||
self.send_json(frame_agent.approvals.inspect(token))
|
||||
elif path == "/api/steam-pipewire":
|
||||
self.send_json(steam_pipewire_state())
|
||||
elif path == "/api/host":
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
|
||||
Reference in new issue
Block a user