mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
WIP: run Frame Control on Linux and Windows
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6d73912f8c
commit
f6e77cd98c
15 files changed
+881
-166
No files matched your search
+22
-14
@@ -8,7 +8,9 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
||||
"""
|
||||
import glob, json, os, re, shlex, subprocess, sys, threading, time, zipfile, zlib
|
||||
import glob, json, os, re, shlex, shutil, subprocess, sys, threading, time, zipfile, zlib
|
||||
|
||||
import frame_host
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
||||
@@ -53,18 +55,16 @@ def game_id(shortcut_appid):
|
||||
|
||||
|
||||
def aapt2():
|
||||
found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2'))
|
||||
+ glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2')
|
||||
+ glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2')))
|
||||
return found[-1] if found else None
|
||||
exe = 'aapt2.exe' if frame_host.WINDOWS else 'aapt2'
|
||||
found = sorted(f for d in frame_host.android_sdk_dirs() for f in glob.glob(os.path.join(d, 'build-tools', '*', exe)))
|
||||
return found[-1] if found else shutil.which('aapt2')
|
||||
|
||||
|
||||
def apk_info(path):
|
||||
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
|
||||
tool = aapt2()
|
||||
if not tool:
|
||||
raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then '
|
||||
'sdkmanager "build-tools;36.0.0"')
|
||||
raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
|
||||
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
|
||||
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
||||
if not m:
|
||||
@@ -105,14 +105,22 @@ def check_installable(info):
|
||||
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
|
||||
|
||||
|
||||
def _rsync(src, dest, *extra, timeout=600):
|
||||
def _copy(src, dest, executable=False, timeout=600):
|
||||
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
|
||||
name = os.path.basename(src)
|
||||
if shutil.which('rsync'):
|
||||
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
|
||||
'-e', shlex.join(['ssh', *SSH_OPTS]), src, f'{FRAME}:{dest}']
|
||||
else:
|
||||
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||
try:
|
||||
subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
|
||||
check=True, capture_output=True, text=True, timeout=timeout)
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'copying {os.path.basename(src)} to the Frame timed out')
|
||||
raise FrameError(f'copying {name} to the Frame timed out')
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise FrameError(f'copying {os.path.basename(src)} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
|
||||
raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
|
||||
if executable and not shutil.which('rsync'):
|
||||
ssh(f'chmod u+x {shlex.quote(dest)}')
|
||||
|
||||
|
||||
def _shortcut_ids():
|
||||
@@ -146,8 +154,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
ok = False
|
||||
try:
|
||||
ssh(f'mkdir -p {d}')
|
||||
_rsync(apk_path, f'{d}/app.apk.part')
|
||||
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
|
||||
_copy(apk_path, f'{d}/app.apk.part')
|
||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||
icon = ''
|
||||
if info['icon_png']:
|
||||
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
|
||||
|
||||
+4
-1
@@ -10,9 +10,12 @@ sys.path.insert(0, CATALOG)
|
||||
import build as catalog_build # noqa: E402
|
||||
import reports # noqa: E402
|
||||
import frame_android # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_compat_db as compat_db # noqa: E402
|
||||
|
||||
CACHE = (os.path.expanduser('~/Library/Caches/Frame Control/apk') if '.app/Contents/Resources' in CATALOG
|
||||
# Inside the installed app the catalogue folder is read-only, so downloads go to
|
||||
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
|
||||
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
|
||||
else os.path.join(CATALOG, 'data', 'cache'))
|
||||
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||
_lock = threading.Lock()
|
||||
|
||||
+13
-8
@@ -1,20 +1,23 @@
|
||||
"""Frame Control's compatibility database: a private Lakebed capsule
|
||||
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
||||
write, using a key kept in the macOS Keychain (service frame-control-compat-db,
|
||||
account app-key).
|
||||
write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
|
||||
frame-control-compat-db, account app-key). Without one (anyone but the
|
||||
maintainer), reports stay local.
|
||||
|
||||
New reports go to a local outbox first and are sent from there, so nothing is
|
||||
lost offline. A mirror of every report is kept for offline reads. Both live in
|
||||
~/Library/Application Support/Frame Control/compat-db/. Python stdlib only.
|
||||
frame_host.data_dir('compat-db'). Python stdlib only.
|
||||
|
||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
||||
(import restores a backup; reports already in the database are skipped.)
|
||||
"""
|
||||
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
||||
|
||||
import frame_host
|
||||
|
||||
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
||||
STATE = os.path.expanduser('~/Library/Application Support/Frame Control/compat-db')
|
||||
STATE = str(frame_host.data_dir('compat-db'))
|
||||
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
||||
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
||||
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
||||
@@ -32,9 +35,11 @@ def key():
|
||||
k = os.environ.get('FRAME_CONTROL_KEY')
|
||||
if k:
|
||||
return k
|
||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0 or not p.stdout.strip():
|
||||
p = None
|
||||
if frame_host.MAC:
|
||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||
capture_output=True, text=True)
|
||||
if p is None or p.returncode != 0 or not p.stdout.strip():
|
||||
raise DBError('No compatibility-database key in the Keychain '
|
||||
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
||||
return p.stdout.strip()
|
||||
@@ -42,7 +47,7 @@ def key():
|
||||
|
||||
def shared():
|
||||
"""Whether reports reach the shared database. Without the key (anyone but the
|
||||
maintainer), reports stay in this Mac's outbox and ratings come from the catalogue."""
|
||||
maintainer), reports stay in this computer's outbox and ratings come from the catalogue."""
|
||||
try:
|
||||
key()
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Connect this computer to the Steam Frame: find it, create a key, add a `Host frame`
|
||||
alias to ~/.ssh/config and copy the key over, asking for the Developer Mode
|
||||
password once. The Linux and Windows twin of scripts/connect.sh (which the Mac
|
||||
app uses); same config block, so either can re-run over the other. Idempotent.
|
||||
|
||||
Usage: python3 ui/frame_connect.py [HOST_OR_IP]
|
||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||
SSH_DIR = Path.home() / ".ssh"
|
||||
KEY = SSH_DIR / "id_ed25519_frame"
|
||||
CONFIG = SSH_DIR / "config"
|
||||
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
|
||||
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
|
||||
|
||||
# Appends the key from stdin unless it's already there. base64 keeps it intact
|
||||
# through Windows' command-line quoting.
|
||||
ADD_KEY = """umask 077
|
||||
mkdir -p ~/.ssh
|
||||
k=$(cat)
|
||||
grep -qxF "$k" ~/.ssh/authorized_keys 2>/dev/null || printf '%s\\n' "$k" >> ~/.ssh/authorized_keys
|
||||
"""
|
||||
ADD_KEY_CMD = 'sh -c "$(echo %s | base64 -d)"' % base64.b64encode(ADD_KEY.encode()).decode()
|
||||
|
||||
|
||||
def say(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def port_open(host):
|
||||
try:
|
||||
with socket.create_connection((host, 22), timeout=3):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def pick_host(arg):
|
||||
for host in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
|
||||
if port_open(host):
|
||||
return host
|
||||
say(f" - {host}: not resolvable or port 22 closed")
|
||||
return None
|
||||
|
||||
|
||||
def write_config(host):
|
||||
"""Replace our managed block and put it first: ssh uses the first value it sees per
|
||||
option. The trailing "Host *" returns the rest of the file to global scope."""
|
||||
SSH_DIR.mkdir(mode=0o700, exist_ok=True)
|
||||
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
||||
kept, skip = [], False
|
||||
for line in old.splitlines():
|
||||
if line == BEGIN:
|
||||
skip = True
|
||||
elif line == END:
|
||||
skip = False
|
||||
elif not skip:
|
||||
kept.append(line)
|
||||
block = [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", f" User {FRAME_USER}",
|
||||
" IdentityFile ~/.ssh/id_ed25519_frame", " IdentitiesOnly yes",
|
||||
" ServerAliveInterval 30", "Host *", END]
|
||||
CONFIG.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
CONFIG.chmod(0o600)
|
||||
|
||||
|
||||
def key_login_works():
|
||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", FRAME_ALIAS, "true"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def main(argv):
|
||||
if argv and argv[0] in ("-h", "--help"):
|
||||
sys.exit(__doc__)
|
||||
say("==> Looking for the Steam Frame")
|
||||
host = pick_host(argv[0] if argv else None)
|
||||
while not host:
|
||||
say("Could not reach the Frame on port 22.")
|
||||
say("Check: Developer Mode on and a user password set; same network; no client isolation.")
|
||||
try:
|
||||
typed = input("Type the Frame's IP address (Quick Settings shows it), or press Enter to quit: ").strip()
|
||||
except EOFError:
|
||||
typed = ""
|
||||
if not typed:
|
||||
return 1
|
||||
host = pick_host(typed)
|
||||
say(f" found: {host}")
|
||||
|
||||
say("==> SSH key")
|
||||
SSH_DIR.mkdir(mode=0o700, exist_ok=True)
|
||||
if KEY.exists():
|
||||
say(f" exists: {KEY}")
|
||||
else:
|
||||
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C",
|
||||
f"{platform.node() or 'computer'}->steam-frame", "-f", str(KEY)], check=True)
|
||||
say(f" created {KEY}")
|
||||
|
||||
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
|
||||
write_config(host)
|
||||
|
||||
say("==> Checking key login")
|
||||
if key_login_works():
|
||||
say(" key login already works")
|
||||
else:
|
||||
say(" copying the key: enter the Developer Mode password when asked")
|
||||
pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip()
|
||||
r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no",
|
||||
f"{FRAME_USER}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True)
|
||||
if r.returncode != 0 or not key_login_works():
|
||||
say("Key login still isn't working. Check the password and run this again.")
|
||||
return 1
|
||||
say(" key login OK")
|
||||
say(f"\nDone. Frame Control can reach the Frame now. In a terminal: ssh {FRAME_ALIAS}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,253 @@
|
||||
"""What differs between the computers Frame Control runs on: macOS, Linux, Windows.
|
||||
|
||||
Everything here runs on your computer, not the Frame. Python stdlib only.
|
||||
|
||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MAC = sys.platform == "darwin"
|
||||
WINDOWS = os.name == "nt"
|
||||
LINUX = not MAC and not WINDOWS
|
||||
NAME = "macOS" if MAC else "Windows" if WINDOWS else "Linux"
|
||||
FILE_MANAGER = "Finder" if MAC else "File Explorer" if WINDOWS else "your file manager"
|
||||
|
||||
# Windows' OpenSSH client can't share one connection between commands
|
||||
# (no ControlMaster), so there each command opens its own.
|
||||
MUX = not WINDOWS
|
||||
|
||||
# Popen() keyword arguments that detach a child from our console and signals.
|
||||
DETACHED = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if WINDOWS
|
||||
else {"start_new_session": True})
|
||||
|
||||
|
||||
class HostError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def data_dir(*parts):
|
||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME."""
|
||||
if MAC:
|
||||
base = Path.home() / "Library" / "Application Support" / "Frame Control"
|
||||
elif WINDOWS:
|
||||
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "frame-control"
|
||||
return base.joinpath(*parts)
|
||||
|
||||
|
||||
def cache_dir(*parts):
|
||||
if MAC:
|
||||
base = Path.home() / "Library" / "Caches" / "Frame Control"
|
||||
elif WINDOWS:
|
||||
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "Frame Control" / "Cache"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "frame-control"
|
||||
return base.joinpath(*parts)
|
||||
|
||||
|
||||
def control_path():
|
||||
"""ssh ControlPath for the shared connection, or None where it isn't supported.
|
||||
|
||||
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||
"""
|
||||
return f"/tmp/frame-ui-{os.getuid()}-%C" if MUX else None
|
||||
|
||||
|
||||
def which(name, *extra):
|
||||
"""First executable among PATH and the extra candidate paths."""
|
||||
for cand in (shutil.which(name), *extra):
|
||||
if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def install_hint(tool):
|
||||
"""How to get a missing command-line tool on this computer."""
|
||||
hints = {
|
||||
"adb": {"mac": "brew install android-platform-tools",
|
||||
"win": "winget install Google.PlatformTools",
|
||||
"linux": "install your distribution's adb package (e.g. sudo apt install adb)"},
|
||||
"aapt2": {"mac": 'brew install --cask android-commandlinetools, then sdkmanager "build-tools;36.0.0"',
|
||||
"win": 'install Android Studio\'s command-line tools, then sdkmanager "build-tools;36.0.0"',
|
||||
"linux": 'install Android\'s command-line tools, then sdkmanager "build-tools;36.0.0"'},
|
||||
}
|
||||
return hints[tool]["mac" if MAC else "win" if WINDOWS else "linux"]
|
||||
|
||||
|
||||
def android_sdk_dirs():
|
||||
"""Where the Android SDK usually lives, for adb and aapt2."""
|
||||
dirs = [os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")]
|
||||
if MAC:
|
||||
dirs += ["~/Library/Android/sdk", "/opt/homebrew/share/android-commandlinetools",
|
||||
"~/.homebrew/share/android-commandlinetools"]
|
||||
elif WINDOWS:
|
||||
dirs += [os.path.join(os.environ.get("LOCALAPPDATA", ""), "Android", "Sdk")]
|
||||
else:
|
||||
dirs += ["~/Android/Sdk", "/usr/lib/android-sdk"]
|
||||
return [os.path.expanduser(d) for d in dirs if d]
|
||||
|
||||
|
||||
def adb():
|
||||
exe = "adb.exe" if WINDOWS else "adb"
|
||||
extra = [os.path.join(d, "platform-tools", exe) for d in android_sdk_dirs()]
|
||||
if MAC:
|
||||
extra += ["/opt/homebrew/bin/adb", str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"]
|
||||
env = os.environ.get("ADB")
|
||||
found = (env if env and os.access(env, os.X_OK) else None) or which("adb", *extra)
|
||||
if not found:
|
||||
raise HostError(f"adb isn't installed on this computer: {install_hint('adb')}")
|
||||
return found
|
||||
|
||||
|
||||
def open_path(path):
|
||||
"""Show a folder or file in the file manager."""
|
||||
path = str(path)
|
||||
if WINDOWS:
|
||||
os.startfile(path) # noqa: pylint only on Windows
|
||||
return
|
||||
opener = "open" if MAC else which("xdg-open")
|
||||
if not opener:
|
||||
raise HostError("xdg-open isn't installed, so the folder can't be opened")
|
||||
subprocess.Popen([opener, path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
open_url = open_path # the same openers hand URLs to the default browser
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
def open_terminal(argv, title="Frame Control"):
|
||||
"""Run argv in a new terminal window, for anything that asks for a password.
|
||||
|
||||
The window stays open after the command ends, so its output can be read.
|
||||
"""
|
||||
argv = [str(a) for a in argv]
|
||||
if MAC:
|
||||
command = shlex.join(argv).replace("\\", "\\\\").replace('"', '\\"')
|
||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
||||
"-e", f'do script "{command}"', "-e", "activate", "-e", "end tell"],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
# Usually macOS Automation consent for Terminal was denied.
|
||||
raise HostError(f"Couldn't open Terminal: {r.stderr.strip()}")
|
||||
return "Terminal"
|
||||
if WINDOWS:
|
||||
# `start` gives the command its own console window; cmd /k keeps it open.
|
||||
# One hand-built command line: quoting it twice through list2cmdline would
|
||||
# produce backslash-escaped quotes, which cmd doesn't understand.
|
||||
inner = subprocess.list2cmdline(argv)
|
||||
subprocess.Popen(f'cmd.exe /c start "{title}" cmd.exe /k "{inner}"', **DETACHED)
|
||||
return "a terminal window"
|
||||
script = f'{shlex.join(argv)}; echo; read -r -p "Press Enter to close. " _'
|
||||
for name, flag in (("x-terminal-emulator", "-e"), ("gnome-terminal", "--"), ("konsole", "-e"),
|
||||
("xfce4-terminal", "-x"), ("kitty", None), ("alacritty", "-e"),
|
||||
("foot", None), ("xterm", "-e")):
|
||||
exe = which(name)
|
||||
if exe:
|
||||
_spawn([exe, *([flag] if flag else []), "bash", "-c", script])
|
||||
return name
|
||||
raise HostError("No terminal program found (tried gnome-terminal, konsole, xterm and others)")
|
||||
|
||||
|
||||
def clipboard_text():
|
||||
"""The text on this computer's clipboard."""
|
||||
if MAC:
|
||||
cmds = [["pbpaste"]]
|
||||
elif WINDOWS:
|
||||
cmds = [["powershell.exe", "-NoProfile", "-Command",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard -Raw"]]
|
||||
else:
|
||||
cmds = [["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"],
|
||||
["xsel", "--clipboard", "--output"]]
|
||||
for cmd in cmds:
|
||||
if not shutil.which(cmd[0]):
|
||||
continue
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
text = r.stdout.decode("utf-8", errors="replace")
|
||||
return text[:-2] if WINDOWS and text.endswith("\r\n") else text
|
||||
if LINUX:
|
||||
raise HostError("Can't read the clipboard: install wl-clipboard (Wayland) or xclip (X11)")
|
||||
raise HostError("Can't read the clipboard")
|
||||
|
||||
|
||||
def ssh_hostname(alias):
|
||||
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return alias
|
||||
for line in out.splitlines():
|
||||
if line.startswith("hostname "):
|
||||
return line.split(None, 1)[1].strip()
|
||||
return alias
|
||||
|
||||
|
||||
# Apps the UI can hand off to, per platform: (installed-check, launch argv) pairs,
|
||||
# and where to get the app when none is installed.
|
||||
def open_steam_link():
|
||||
if MAC:
|
||||
if subprocess.run(["open", "-a", "Steam Link"], capture_output=True).returncode == 0:
|
||||
return "Opened Steam Link"
|
||||
elif WINDOWS:
|
||||
for base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles")):
|
||||
exe = base and os.path.join(base, "Steam Link", "SteamLink.exe")
|
||||
if exe and os.path.isfile(exe):
|
||||
_spawn([exe])
|
||||
return "Opened Steam Link"
|
||||
else:
|
||||
if which("steamlink"):
|
||||
_spawn([which("steamlink")])
|
||||
return "Opened Steam Link"
|
||||
if which("flatpak") and subprocess.run(["flatpak", "info", "com.valvesoftware.SteamLink"],
|
||||
capture_output=True).returncode == 0:
|
||||
_spawn(["flatpak", "run", "com.valvesoftware.SteamLink"])
|
||||
return "Opened Steam Link"
|
||||
open_url("https://store.steampowered.com/remoteplay")
|
||||
return "Steam Link isn't installed; opened its download page"
|
||||
|
||||
|
||||
def open_rdp(alias):
|
||||
"""Remote desktop to the Frame's xrdp (user steamos)."""
|
||||
host = ssh_hostname(alias)
|
||||
if MAC:
|
||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||
return "Opened Windows App"
|
||||
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||
return "Windows App isn't installed; opened its App Store page"
|
||||
if WINDOWS:
|
||||
_spawn(["mstsc.exe", f"/v:{host}"])
|
||||
return f"Opened Remote Desktop to {host}"
|
||||
if which("remmina"):
|
||||
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
||||
return f"Opened Remmina to {host}"
|
||||
for name in ("xfreerdp3", "xfreerdp"):
|
||||
if which(name):
|
||||
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
||||
return f"Opened FreeRDP to {host}"
|
||||
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) >= 3 and argv[0] == "terminal" and argv[1] == "--":
|
||||
try:
|
||||
print(f"Opened {open_terminal(argv[2:])}")
|
||||
except HostError as e:
|
||||
sys.exit(str(e))
|
||||
return
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
"""Mac side: search the Steam store and look up each result's Steam Frame rating.
|
||||
"""Computer side: search the Steam store and look up each result's Steam Frame rating.
|
||||
|
||||
Uses the store's public endpoints (no key, no login):
|
||||
api/storesearch name search, price in the IP's currency
|
||||
|
||||
+19
-9
@@ -384,8 +384,8 @@
|
||||
<section id="shots">
|
||||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="shotsRefresh">Refresh</button>
|
||||
<button class="small" id="shotsFolder" title="Open ~/Pictures/SteamFrame">Show in Finder</button>
|
||||
<button class="action small" id="shotsSaveNew" disabled>Save new to Mac</button>
|
||||
<button class="small" id="shotsFolder" title="Open the SteamFrame folder in your Pictures">Show folder</button>
|
||||
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||
</div>
|
||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
||||
@@ -465,7 +465,7 @@
|
||||
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="action small" id="clipSend">Send text</button>
|
||||
<button class="small" id="clipMac">Send Mac clipboard</button>
|
||||
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||||
</div>
|
||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||
</section>
|
||||
@@ -522,7 +522,7 @@
|
||||
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
|
||||
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
|
||||
</div>
|
||||
<div class="hint">Sleep, Restart and Shut down open Terminal for the Developer Mode password.</div>
|
||||
<div class="hint">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||
<div class="links">
|
||||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||||
@@ -613,6 +613,15 @@ $("bottombar").onclick = () => {
|
||||
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
|
||||
};
|
||||
|
||||
// Wording for the computer the app runs on (Mac or PC, Finder or File Explorer).
|
||||
const HOST = { computer: "computer" };
|
||||
function applyHostWording(h) {
|
||||
Object.assign(HOST, h);
|
||||
$("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||
$("shotsSaveNew").textContent = `Save new to ${h.computer}`;
|
||||
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
|
||||
@@ -1060,7 +1069,7 @@ $("clipSend").onclick = () => {
|
||||
if (!text) return toast("Nothing to send", true);
|
||||
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
|
||||
};
|
||||
$("clipMac").onclick = () => act("Send Mac clipboard", () => api("/api/clipboard", { fromMac: true }), $("clipMac"));
|
||||
$("clipMac").onclick = () => act($("clipMac").textContent, () => api("/api/clipboard", { fromComputer: true }), $("clipMac"));
|
||||
|
||||
// ---- file drop ----
|
||||
const drop = $("drop");
|
||||
@@ -1461,7 +1470,7 @@ async function loadReports() {
|
||||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||
$("repHint").textContent = shared
|
||||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||
: "Reports are saved on this Mac and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||||
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
||||
const k = r.rating || r.result;
|
||||
@@ -1522,6 +1531,7 @@ $("repForm").onsubmit = async e => {
|
||||
finally { $("repSave").disabled = false; }
|
||||
};
|
||||
loadReports();
|
||||
api("/api/host").then(applyHostWording).catch(() => {});
|
||||
|
||||
// ---- Steam screenshots from the headset ----
|
||||
const shots = { list: [], urls: [] };
|
||||
@@ -1547,14 +1557,14 @@ async function loadShots() {
|
||||
} finally { $("shotsRefresh").disabled = false; }
|
||||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||
const unsaved = shots.list.filter(s => !s.saved).length;
|
||||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this Mac` : "") : "";
|
||||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||||
$("shotsSaveNew").disabled = !unsaved;
|
||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
||||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
||||
<div class="row"><div class="grow">
|
||||
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
||||
${s.saved ? `<span class="tag">On Mac</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||
${s.saved ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||
</div></div>`).join("")
|
||||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||
// Thumbnails one at a time over the shared SSH connection.
|
||||
@@ -1606,7 +1616,7 @@ $("shotGrid").onclick = e => {
|
||||
};
|
||||
$("shotsRefresh").onclick = loadShots;
|
||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||
$("shotsFolder").onclick = e => act("Show in Finder", () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||
|
||||
// ---- nav highlight follows scroll ----
|
||||
const spy = new IntersectionObserver(entries => {
|
||||
|
||||
+159
-86
@@ -1,18 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame Control: a small local web UI for managing the Steam Frame from the Mac.
|
||||
"""Frame Control: a small local web UI for managing the Steam Frame from a computer.
|
||||
|
||||
Stdlib only. Listens on 127.0.0.1 and talks to the headset through the `frame`
|
||||
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
|
||||
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
|
||||
up by scripts/connect.sh or ui/frame_connect.py.
|
||||
|
||||
Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
|
||||
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||
Env: FRAME_ALIAS (default frame)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
@@ -26,19 +28,23 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import frame_android
|
||||
import frame_catalog
|
||||
import frame_store
|
||||
# Windows' embedded Python (bundled with the app) doesn't put the script's own
|
||||
# folder on sys.path, so add it for the sibling modules below.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import frame_android # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SCRIPTS = HERE.parent / "scripts"
|
||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
|
||||
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||
# supports it (not on Windows: there every command connects on its own).
|
||||
CONTROL = frame_host.control_path()
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
||||
# Commands use the master when it's up and connect directly when it isn't.
|
||||
SSH = [*MUX, "-o", "ControlMaster=no", "-o", "ConnectTimeout=5"]
|
||||
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||
|
||||
# Android helpers share the multiplexed connection when it's up.
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
@@ -82,6 +88,9 @@ def ensure_master():
|
||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||
"""
|
||||
global _master
|
||||
if not CONTROL:
|
||||
return
|
||||
|
||||
def up():
|
||||
try:
|
||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
|
||||
@@ -97,7 +106,7 @@ def ensure_master():
|
||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
||||
for _ in range(60):
|
||||
if up() or _master.poll() is not None:
|
||||
return
|
||||
@@ -119,40 +128,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
return r.stdout
|
||||
|
||||
|
||||
def script(name, *args, stdin=None, timeout=900):
|
||||
"""Run one of ../scripts and return its combined output."""
|
||||
try:
|
||||
r = subprocess.run([str(SCRIPTS / name), *args], input=stdin, text=True, timeout=timeout,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
env={**os.environ, "FRAME_ALIAS": FRAME})
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"{name} timed out")
|
||||
out = strip_ansi(r.stdout).strip()
|
||||
if r.returncode != 0:
|
||||
raise Failure(out or f"{name} exited {r.returncode}")
|
||||
return out
|
||||
|
||||
|
||||
def strip_ansi(s):
|
||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||
|
||||
|
||||
def terminal(command):
|
||||
"""Open Terminal.app running `command` (for anything needing a password)."""
|
||||
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
|
||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
||||
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
# Usually macOS Automation consent for Terminal was denied.
|
||||
raise Failure(f"Couldn't open Terminal: {r.stderr.strip()}", 500)
|
||||
|
||||
|
||||
def open_app(name, fallback_url):
|
||||
if subprocess.run(["open", "-a", name], capture_output=True).returncode == 0:
|
||||
return f"Opened {name}"
|
||||
subprocess.run(["open", fallback_url])
|
||||
return f"{name} isn't installed; opened its download page"
|
||||
def terminal(argv):
|
||||
"""Open a terminal window running argv (for anything needing a password)."""
|
||||
try:
|
||||
return frame_host.open_terminal(argv)
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
|
||||
|
||||
# ---- actions ---------------------------------------------------------------
|
||||
@@ -368,13 +353,44 @@ def set_volume(body):
|
||||
return {"message": "Volume updated"}
|
||||
|
||||
|
||||
# Runs on the Frame, clipboard text on stdin. Verified 2026-09-25 (SteamOS 0.3.0
|
||||
# vr, build 20260922): the headset desktop is a nested Plasma Wayland session
|
||||
# inside gamescope with its own D-Bus bus, and wl-copy/xclip are not installed.
|
||||
# Klipper (org.kde.klipper, served by plasmashell) is reachable with qdbus6, so
|
||||
# borrow plasmashell's bus address. Same as scripts/paste-to-frame.sh.
|
||||
PASTE = r"""set -u
|
||||
text=$(cat; printf x); text=${text%x}
|
||||
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
|
||||
if [ -z "$pid" ]; then
|
||||
echo "plasmashell is not running: open the desktop in the headset first." >&2
|
||||
exit 2
|
||||
fi
|
||||
bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
|
||||
if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
|
||||
org.kde.klipper.klipper.setClipboardContents "$text" >/dev/null; then
|
||||
echo "copied via Klipper (${#text} chars)"
|
||||
else
|
||||
echo "Klipper call failed (bus: ${bus:-none})" >&2
|
||||
exit 2
|
||||
fi
|
||||
"""
|
||||
# base64 keeps the script intact through every local shell's quoting rules.
|
||||
PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
|
||||
|
||||
|
||||
def clipboard(body):
|
||||
if body.get("fromMac"):
|
||||
return {"message": script("paste-to-frame.sh", timeout=30)}
|
||||
text = body.get("text")
|
||||
if not isinstance(text, str) or not text:
|
||||
raise Failure("nothing to send", 400)
|
||||
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
|
||||
if body.get("fromMac") or body.get("fromComputer"):
|
||||
try:
|
||||
text = frame_host.clipboard_text()
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
if not text:
|
||||
raise Failure("The clipboard is empty (or holds something other than text)", 400)
|
||||
else:
|
||||
text = body.get("text")
|
||||
if not isinstance(text, str) or not text:
|
||||
raise Failure("nothing to send", 400)
|
||||
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
|
||||
|
||||
|
||||
def flatpak(body):
|
||||
@@ -382,7 +398,11 @@ def flatpak(body):
|
||||
if not FLATPAK_ID.match(app):
|
||||
raise Failure("bad Flatpak app ID", 400)
|
||||
if action == "install":
|
||||
return {"message": script("install-apps.sh", app)}
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900)
|
||||
return {"message": f"Installed {app}"}
|
||||
if action == "uninstall":
|
||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
||||
@@ -391,25 +411,25 @@ def flatpak(body):
|
||||
|
||||
def open_thing(body):
|
||||
what = body.get("what")
|
||||
alias = shlex.quote(FRAME)
|
||||
if what == "terminal":
|
||||
terminal(f"ssh {alias}")
|
||||
return {"message": "Opened an SSH session in Terminal"}
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||
terminal(f"ssh -t {alias} sudo systemctl {what}")
|
||||
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
|
||||
if what == "steamlink":
|
||||
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
|
||||
if what == "rdp":
|
||||
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
|
||||
if what == "sftp":
|
||||
terminal(f"sftp {alias}")
|
||||
return {"message": "Opened an SFTP session in Terminal"}
|
||||
if what == "shots":
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["open", str(SHOTS_DIR)])
|
||||
return {"message": "Opened ~/Pictures/SteamFrame in Finder"}
|
||||
try:
|
||||
if what == "terminal":
|
||||
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
|
||||
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||
if what == "steamlink":
|
||||
return {"message": frame_host.open_steam_link()}
|
||||
if what == "rdp":
|
||||
return {"message": frame_host.open_rdp(FRAME)}
|
||||
if what == "sftp":
|
||||
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
|
||||
if what == "shots":
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frame_host.open_path(SHOTS_DIR)
|
||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
raise Failure("unknown target", 400)
|
||||
|
||||
|
||||
@@ -438,7 +458,7 @@ def android(body):
|
||||
runtime=body.get("runtime") or "instance",
|
||||
label=body.get("label"), source=body.get("source"))
|
||||
name = r.get("label") or pkg
|
||||
where = "" if frame_catalog.compat_db.shared() else " on this Mac"
|
||||
where = "" if frame_catalog.compat_db.shared() else " on this computer"
|
||||
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
@@ -464,11 +484,10 @@ _live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the
|
||||
|
||||
|
||||
def adb_path():
|
||||
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
|
||||
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
|
||||
if cand and os.access(cand, os.X_OK):
|
||||
return cand
|
||||
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
|
||||
try:
|
||||
return frame_host.adb()
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
|
||||
|
||||
def adb(adb_bin, *args, timeout=20):
|
||||
@@ -490,7 +509,7 @@ def free_local_port():
|
||||
|
||||
|
||||
class AdbTunnel:
|
||||
"""SSH forwards from Mac loopback to Frame ADB ports, plus adb connections.
|
||||
"""SSH forwards from local loopback to Frame ADB ports, plus adb connections.
|
||||
|
||||
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
|
||||
disconnects adb and kills the ssh process, whatever happened inside.
|
||||
@@ -742,6 +761,49 @@ POST = {"/api/android/display": android_display, "/api/android": android,"/api/l
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
|
||||
def _pipe_reader(pipe):
|
||||
"""Chunks from a pipe via a thread; select() can't wait on pipes on Windows."""
|
||||
chunks = queue.Queue(maxsize=64)
|
||||
|
||||
def pump():
|
||||
try:
|
||||
while True:
|
||||
chunk = pipe.read1(1 << 16) if hasattr(pipe, "read1") else os.read(pipe.fileno(), 1 << 16)
|
||||
chunks.put(chunk)
|
||||
if not chunk:
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
chunks.put(b"")
|
||||
|
||||
threading.Thread(target=pump, daemon=True).start()
|
||||
return chunks
|
||||
|
||||
|
||||
def _next_chunk(chunks, timeout):
|
||||
"""The next chunk, or b"" at end of stream or after `timeout` seconds of silence."""
|
||||
try:
|
||||
return chunks.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return b""
|
||||
|
||||
|
||||
def push_file(path, dest="Downloads/"):
|
||||
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
|
||||
name = Path(path).name
|
||||
try:
|
||||
if shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
|
||||
cmd = ["rsync", "-a", "-e", shlex.join(SSH), str(path), f"{FRAME}:{shlex.quote(dest)}"]
|
||||
else:
|
||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=3600)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Copying {name} timed out")
|
||||
if r.returncode != 0:
|
||||
raise Failure(strip_ansi(r.stderr or r.stdout).strip() or f"copy exited {r.returncode}")
|
||||
return f"Sent {name} to ~/{dest}"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "FrameControl/1"
|
||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||
@@ -788,6 +850,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/host":
|
||||
self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||
elif path == "/api/android":
|
||||
ensure_master()
|
||||
self.send_json({"apps": frame_android.list_apps()})
|
||||
@@ -869,11 +934,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
old, _stream_proc = _stream_proc, proc
|
||||
if old and old.poll() is None:
|
||||
old.terminate()
|
||||
fd = proc.stdout.fileno()
|
||||
chunks = _pipe_reader(proc.stdout)
|
||||
# Nothing is sent until the first bytes arrive, so a failure to
|
||||
# start still comes back as a JSON error.
|
||||
ready, _, _ = select.select([fd], [], [], 20)
|
||||
first = os.read(fd, 1 << 16) if ready else b""
|
||||
first = _next_chunk(chunks, 20)
|
||||
if not first:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
@@ -894,8 +958,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.flush()
|
||||
# A stalled headset view ends the stream rather than
|
||||
# holding this thread (and the page) forever.
|
||||
ready, _, _ = select.select([fd], [], [], STREAM_STALL)
|
||||
chunk = os.read(fd, 1 << 16) if ready else b""
|
||||
chunk = _next_chunk(chunks, STREAM_STALL)
|
||||
except OSError:
|
||||
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
||||
finally:
|
||||
@@ -952,7 +1015,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e), 400)
|
||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
|
||||
return {"message": script("push.sh", str(dest))}
|
||||
return {"message": push_file(dest)}
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
@@ -960,9 +1023,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
||||
ap.add_argument("--exit-on-eof", action="store_true",
|
||||
help="stop cleanly when stdin closes (the app closes it on quit; "
|
||||
"Windows has no SIGTERM to catch)")
|
||||
args = ap.parse_args()
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if args.exit_on_eof:
|
||||
def watch_stdin():
|
||||
sys.stdin.buffer.read()
|
||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
@@ -970,7 +1042,8 @@ def main():
|
||||
pass
|
||||
finally:
|
||||
# The master was started with -N, so it stays up until told to exit.
|
||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
||||
if CONTROL:
|
||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
||||
if _master and _master.poll() is None:
|
||||
_master.terminate()
|
||||
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||
|
||||
Reference in new issue
Block a user