mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Merge main into mac-in-headset (MCP agent routes alongside the Mac view); skip the bash syntax test on Windows
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f7573e3565
26 files changed
+1862
-8
No files matched your search
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Open Frame Control's assistant as a Chromium panel. Ctrl-C closes it and its SSH tunnel.
|
||||
|
||||
Start ui/server.py first. Requires the platform Chromium Flatpak and zsh on the
|
||||
computer (the existing panel launcher). No model endpoint or key is configured.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--port', type=int, default=47810, help='local Frame Control port')
|
||||
parser.add_argument('--frame-port', type=int, default=47812, help='Frame loopback tunnel port')
|
||||
args = parser.parse_args()
|
||||
alias = os.environ.get('FRAME_ALIAS', 'frame')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', alias) or any(not 1 <= p <= 65535 for p in (args.port, args.frame_port)):
|
||||
parser.error('Invalid alias or port')
|
||||
if not shutil.which('zsh'):
|
||||
parser.error('The panel launcher requires zsh on this computer')
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
from frame_mcp import Client
|
||||
Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host')
|
||||
profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex
|
||||
log_path = ''
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
|
||||
'-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
|
||||
'-o', 'ServerAliveCountMax=2', '-R',
|
||||
f'127.0.0.1:{args.frame_port}:127.0.0.1:{args.port}', alias])
|
||||
try:
|
||||
# Check the forwarded page before starting a browser; no arbitrary sleeps.
|
||||
probe = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
|
||||
'curl --retry 5 --retry-connrefused --retry-delay 1 --max-time 10 -fsS ' +
|
||||
shlex.quote(f'http://127.0.0.1:{args.frame_port}/assistant')],
|
||||
stdout=subprocess.DEVNULL, timeout=30)
|
||||
if probe.returncode or tunnel.poll() is not None:
|
||||
raise RuntimeError('Could not forward Frame Control to the Frame')
|
||||
launched = subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant',
|
||||
'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run',
|
||||
'--disable-background-networking', '--disable-sync',
|
||||
f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45, stdout=subprocess.PIPE, text=True)
|
||||
print(launched.stdout, end='', flush=True)
|
||||
match = re.search(r'log (/tmp/panel-on-frame\.[A-Za-z0-9]+)', launched.stdout)
|
||||
if match:
|
||||
log_path = match.group(1)
|
||||
print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True)
|
||||
tunnel.wait()
|
||||
raise RuntimeError('SSH tunnel ended')
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
tunnel.terminate()
|
||||
try:
|
||||
tunnel.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
tunnel.kill()
|
||||
tunnel.wait()
|
||||
# Only this unique browser profile, never a shared Chromium instance.
|
||||
cleanup = '''import os, pathlib, signal, shutil, sys, time
|
||||
profile = sys.argv[1]
|
||||
needle = ('--user-data-dir=' + profile).encode()
|
||||
owned = []
|
||||
for p in pathlib.Path('/proc').iterdir():
|
||||
try:
|
||||
if p.name.isdigit() and p.stat().st_uid == os.getuid() and needle in (p / 'cmdline').read_bytes().split(b'\\0'):
|
||||
owned.append(int(p.name))
|
||||
except OSError:
|
||||
pass
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
for pid in owned:
|
||||
try: os.kill(pid, sig)
|
||||
except ProcessLookupError: pass
|
||||
time.sleep(.3)
|
||||
shutil.rmtree(profile, ignore_errors=True)
|
||||
if sys.argv[2]:
|
||||
pathlib.Path(sys.argv[2]).unlink(missing_ok=True)
|
||||
'''
|
||||
result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
|
||||
'python3 - ' + shlex.quote(profile) + ' ' + shlex.quote(log_path)], input=cleanup, text=True, timeout=20)
|
||||
if result.returncode:
|
||||
print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: stop the Steam Frame from going to sleep while an agent works on it.
|
||||
#
|
||||
# The Frame sleeps when Steam's own idle timer runs out ("Sleep after
|
||||
# inactivity": 60 min on AC, 15 min on battery by default). SSH activity
|
||||
# doesn't count as input, and asleep the Frame is off the network. `on` sets
|
||||
# both timers to Never through Steam's UI (DevTools on 127.0.0.1:8080, via
|
||||
# ui/frame_steam.py) and holds a logind sleep inhibitor as a user unit.
|
||||
# `off` drops the inhibitor and restores the timers `on` saved.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/keep-awake.sh on
|
||||
# scripts/keep-awake.sh off
|
||||
# scripts/keep-awake.sh status
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
HERE=${0:A:h}
|
||||
cmd=${1:-status}
|
||||
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
||||
|
||||
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
|
||||
|
||||
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
|
||||
# timers are client settings system_idle_suspend_{ac,battery}_sec (0 = Never),
|
||||
# written the way Steam's settings page does (steamui module exporting the
|
||||
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
||||
# ("Interactive authentication required") but allows one from a user unit.
|
||||
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
||||
import json, os, subprocess, sys
|
||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||
from frame_steam import Page
|
||||
|
||||
cmd = sys.argv[1]
|
||||
saved_path = os.path.expanduser("~/.cache/frame-control/keep-awake.json")
|
||||
unit = "fc-keep-awake"
|
||||
keys = ("system_idle_suspend_ac_sec", "system_idle_suspend_battery_sec")
|
||||
|
||||
if cmd == "off": # release the lock first, even if Steam's UI is down
|
||||
subprocess.run(["systemctl", "--user", "stop", unit], stderr=subprocess.DEVNULL)
|
||||
page = Page()
|
||||
def read():
|
||||
return {k: page.eval(f"settingsStore.clientSettings.{k}") for k in keys}
|
||||
def write(values):
|
||||
page.eval("""(async () => { let req;
|
||||
webpackChunksteamui.push([[Symbol()], {}, r => { req = r }]);
|
||||
const mod = Object.keys(req.m).map(id => req.m[id].toString().includes("Settings.SetSetting") ? req(id) : null).find(Boolean);
|
||||
const set = Object.values(mod).find(f => typeof f == "function" && f.toString().includes("SetSetting("));
|
||||
for (const [k, v] of Object.entries(%s)) await set(k, v);
|
||||
await new Promise(r => setTimeout(r, 1000)); })()""" % json.dumps(values))
|
||||
def inhibitor():
|
||||
return subprocess.run(["systemctl", "--user", "is-active", "-q", unit]).returncode == 0
|
||||
|
||||
if cmd == "on":
|
||||
current = read()
|
||||
if not os.path.exists(saved_path):
|
||||
with open(saved_path, "w") as f:
|
||||
json.dump(current, f)
|
||||
write({k: 0 for k in keys})
|
||||
if not inhibitor():
|
||||
subprocess.run(["systemd-run", "--user", "-q", f"--unit={unit}",
|
||||
"--description=Frame Control: keep the Frame awake",
|
||||
"systemd-inhibit", "--what=sleep:idle:handle-suspend-key:handle-power-key",
|
||||
"--who=Frame Control", "--why=Keep the Frame awake while an agent works on it",
|
||||
"--mode=block", "sleep", "infinity"], check=True)
|
||||
elif cmd == "off":
|
||||
if os.path.exists(saved_path): # no backup: leave the timers as they are
|
||||
with open(saved_path) as f:
|
||||
write(json.load(f))
|
||||
os.remove(saved_path)
|
||||
|
||||
print(json.dumps({"timers": read(), "inhibitor": inhibitor()}))
|
||||
EOF
|
||||
Reference in new issue
Block a user