Merge remote-tracking branch 'origin/main' into theatre-media

# Conflicts:
#	docs/testing.md
#	ui/server.py
This commit is contained in:
saphid committed 2026-09-29 10:08:45 +10:00
commit b0d6039eec
54 files changed
+4597 -56

No files matched your search

+100
View File
@@ -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)
+74
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# Publish a tested draft release so running copies of Frame Control offer it
# (docs/releasing.md). Checks every installer is attached with a SHA-256
# digest first, since the app's updater refuses assets without one, then
# attaches update.json, the manifest the updater reads.
# Usage: scripts/publish-release.sh v0.4.0
set -eu
tag="${1:?usage: $0 vX.Y.Z}"
repo=saphid/frame-control
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
missing=""
for name in $expected; do
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
d=json.load(sys.stdin); n=sys.argv[1]
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
case "$digest" in
sha256:*) echo "ok $name" ;;
absent) echo "MISSING $name"; missing=1 ;;
*) echo "NO HASH $name"; missing=1 ;;
esac
done
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
# update.json: what running copies read (app/updater.js), from github.com's
# latest/download link rather than the rate-limited REST API.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
d=json.load(sys.stdin)
names=set(sys.argv[1].split())
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
echo "ok update.json"
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
echo "published $tag; running copies will offer it at their next check"