Merge published main and preserve both PC tool entries

This commit is contained in:
SAPHID committed 2026-10-24 12:21:30 +00:00
commit ec2617fd9d
14 files changed
+676 -9

No files matched your search

+7
View File
@@ -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
+1
View File
@@ -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 |
+2 -2
View File
@@ -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
View File
@@ -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

+4
View File
@@ -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
+92
View File
@@ -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.
+82
View File
@@ -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"/);
});
+1 -1
View File
@@ -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'''
+261
View File
@@ -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()
+1 -1
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))};
vm.createContext(context);
+147
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; 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 &amp; 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 &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; 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
View File
@@ -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,