From f6e77cd98c23b5cfe05a1a834fb6272a026f316b Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:47:00 +1000 Subject: [PATCH] WIP: run Frame Control on Linux and Windows Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitattributes | 8 ++ .github/workflows/checks.yml | 24 +++- .github/workflows/release.yml | 58 ++++++++ app/.gitignore | 1 + app/build/fetch-python.js | 48 +++++++ app/main.js | 135 ++++++++++++------ app/package.json | 56 +++++++- ui/frame_android.py | 36 +++-- ui/frame_catalog.py | 5 +- ui/frame_compat_db.py | 21 +-- ui/frame_connect.py | 127 +++++++++++++++++ ui/frame_host.py | 253 ++++++++++++++++++++++++++++++++++ ui/frame_store.py | 2 +- ui/index.html | 28 ++-- ui/server.py | 245 ++++++++++++++++++++------------ 15 files changed, 881 insertions(+), 166 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/release.yml create mode 100644 app/build/fetch-python.js create mode 100644 ui/frame_connect.py create mode 100644 ui/frame_host.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6a5ef04 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Scripts run on the Frame (Linux) and on macOS/Linux: keep LF even in Windows checkouts. +*.sh text eol=lf +*.py text eol=lf +*.js text eol=lf +*.html text eol=lf +*.json text eol=lf +*.md text eol=lf +*.bat text eol=crlf diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 574db9c..882eb6c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -31,4 +31,26 @@ jobs: - name: Server tests run: python -m unittest discover -s tests -v - name: App syntax - run: node --check app/main.js && node --check app/build/make-icon.js + run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-python.js + + # The server runs on each desktop OS the app ships for. Windows uses the same + # Python version the app bundles (app/build/fetch-python.js). + server-tests: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + python: "3.12" + - os: macos-latest + python: "3.12" + - os: ubuntu-latest + python: "3.13" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Server tests + run: python -m unittest discover -s tests -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d82562a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: release + +# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches +# the installers to that tag's GitHub release (created as a draft if missing). +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + script: dist + files: app/dist/*.dmg app/dist/*.zip + - os: windows-latest + script: dist:win + files: app/dist/*.exe app/dist/*.zip + - os: ubuntu-latest + script: dist:linux + files: app/dist/*.AppImage app/dist/*.deb + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Build + working-directory: app + shell: bash + run: npm ci && npm run ${{ matrix.script }} + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + - name: Upload to the release + if: startsWith(github.ref, 'refs/tags/') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF_NAME}" + gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes "" + gh release upload "$tag" ${{ matrix.files }} --clobber + - uses: actions/upload-artifact@v4 + with: + name: frame-control-${{ matrix.os }} + path: | + app/dist/*.dmg + app/dist/*.exe + app/dist/*.zip + app/dist/*.AppImage + app/dist/*.deb + if-no-files-found: ignore diff --git a/app/.gitignore b/app/.gitignore index b947077..5417301 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +build/python-win/ diff --git a/app/build/fetch-python.js b/app/build/fetch-python.js new file mode 100644 index 0000000..ffc28e1 --- /dev/null +++ b/app/build/fetch-python.js @@ -0,0 +1,48 @@ +// Downloads the official Windows embeddable Python into build/python-win, which +// the Windows build bundles as resources/python (so Windows users need no Python). +// Pinned by version and SHA-256. Run: node build/fetch-python.js +const crypto = require("crypto"); +const fs = require("fs"); +const https = require("https"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const VERSION = "3.12.10"; +const SHA256 = "4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3"; +const URL = `https://www.python.org/ftp/python/${VERSION}/python-${VERSION}-embed-amd64.zip`; +const OUT = path.join(__dirname, "python-win"); + +function get(url) { + return new Promise((resolve, reject) => { + https.get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + return resolve(get(res.headers.location)); + } + if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`)); + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve(Buffer.concat(chunks))); + }).on("error", reject); + }); +} + +(async () => { + if (fs.existsSync(path.join(OUT, "python.exe")) && fs.readFileSync(path.join(OUT, ".version"), "utf8") === VERSION) { + console.log(`Python ${VERSION} already in ${OUT}`); + return; + } + const zip = await get(URL); + const sum = crypto.createHash("sha256").update(zip).digest("hex"); + if (sum !== SHA256) throw new Error(`checksum mismatch for ${URL}: ${sum}`); + fs.rmSync(OUT, { recursive: true, force: true }); + fs.mkdirSync(OUT, { recursive: true }); + const file = path.join(OUT, "python.zip"); + fs.writeFileSync(file, zip); + // bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip. + try { execFileSync("tar", ["-xf", file, "-C", OUT]); } + catch { execFileSync("unzip", ["-q", "-o", file, "-d", OUT]); } + fs.rmSync(file); + fs.writeFileSync(path.join(OUT, ".version"), VERSION); + console.log(`Python ${VERSION} -> ${OUT}`); +})().catch((e) => { console.error(e.message); process.exit(1); }); diff --git a/app/main.js b/app/main.js index 3675180..d9099b9 100644 --- a/app/main.js +++ b/app/main.js @@ -1,6 +1,6 @@ -// Frame Control as a Mac app: starts ui/server.py on a free loopback port and -// shows it in a native window. The server does all the work over the `frame` -// SSH alias; this file only hosts it. +// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on +// a free loopback port and shows it in a native window. The server does all the +// work over the `frame` SSH alias; this file only hosts it. const { app, BrowserWindow, Menu, dialog, shell } = require("electron"); const { execFile, spawn } = require("child_process"); const { promisify } = require("util"); @@ -12,11 +12,15 @@ const path = require("path"); const run = promisify(execFile); -// Packaged: Contents/Resources/{ui,scripts}. Dev: the repo checkout. +const IS_MAC = process.platform === "darwin"; +const IS_WIN = process.platform === "win32"; + +// Packaged: /{ui,scripts,python}. Dev: the repo checkout. const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, ".."); const SERVER = path.join(ROOT, "ui", "server.py"); const SCRIPTS = path.join(ROOT, "scripts"); -const LOG_DIR = path.join(os.homedir(), "Library", "Logs", "Frame Control"); +const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control") + : path.join(app.getPath("userData"), "logs"); const LOG = path.join(LOG_DIR, "server.log"); const BG = "#0d1117"; const FRAME = process.env.FRAME_ALIAS || "frame"; @@ -25,15 +29,18 @@ let server = null; let url = null; let win = null; let quitting = false; +let python = null; // Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses -// Homebrew's python3, rsync and adb. Take PATH from the login shell instead. +// Homebrew's python3, rsync and adb (desktop launchers on Linux can be as bare). +// Take PATH from the login shell instead. Windows has no login shell to ask. // Runs asynchronously so a slow shell profile can't freeze the window. let cachedPath = null; async function loginPath() { + if (IS_WIN) return process.env.PATH || ""; if (cachedPath) return cachedPath; - const shellPath = os.userInfo().shell || process.env.SHELL || "/bin/zsh"; - const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")]; + const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh"); + const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : []; let fromShell = ""; try { const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'], @@ -46,19 +53,40 @@ async function loginPath() { return joined; } +// The Windows build bundles Python; elsewhere use the system's python3 (3.8+). async function findPython(env) { - for (const dir of env.PATH.split(":")) { - const p = path.join(dir, "python3"); + const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"]; + const candidates = []; + if (IS_WIN) candidates.push(path.join(ROOT, "python", "python.exe")); + for (const dir of env.PATH.split(path.delimiter)) { + // The WindowsApps "python.exe" is a stub that opens the Microsoft Store. + if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue; + for (const name of names) candidates.push(path.join(dir, name)); + } + for (const p of candidates) { try { fs.accessSync(p, fs.constants.X_OK); - // /usr/bin/python3 is a stub until the Command Line Tools are installed. - await run(p, ["-c", "import http.server"], { timeout: 10000, env }); + // /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed. + await run(p, ["-c", "import http.server, sys; assert sys.version_info >= (3, 8)"], + { timeout: 10000, env, windowsHide: true }); return p; } catch {} } return null; } +async function hasSsh(env) { + try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; } +} + +const PYTHON_HELP = IS_MAC + ? "Install the Xcode Command Line Tools (xcode-select --install) or Homebrew's python, then reopen the app." + : IS_WIN ? "The bundled Python is missing; reinstall Frame Control." + : "Install Python 3.8 or later from your distribution (e.g. sudo apt install python3), then reopen the app."; +const SSH_HELP = IS_WIN + ? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client." + : "Install the OpenSSH client (e.g. sudo apt install openssh-client)."; + function freePort() { return new Promise((resolve, reject) => { const s = net.createServer(); @@ -80,17 +108,19 @@ function ping(target) { } async function startServer() { - const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" }; - const python = await findPython(env); - if (!python) { - throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools " - + "(xcode-select --install) or Homebrew's python, then reopen the app."); - } + const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1", + PYTHONIOENCODING: "utf-8", FRAME_CONTROL_APP: "1" }; + python = await findPython(env); + if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`); + if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`); const port = await freePort(); fs.mkdirSync(LOG_DIR, { recursive: true }); const log = fs.openSync(LOG, "a"); fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`); - const child = spawn(python, [SERVER, "--port", String(port)], { env, stdio: ["ignore", log, log] }); + // stdin stays open while the app runs; the server exits cleanly when it closes. + const child = spawn(python, [SERVER, "--port", String(port), "--exit-on-eof"], + { env, stdio: ["pipe", log, log], windowsHide: true }); + child.stdin.on("error", () => {}); fs.closeSync(log); server = child; let exited = null; @@ -112,13 +142,20 @@ async function startServer() { await new Promise((r) => setTimeout(r, 100)); } if (server === child) server = null; - child.kill("SIGTERM"); + endServer(child); throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`); } +// Closing stdin lets server.py close its SSH connections and exit (the only clean +// way on Windows); SIGTERM does the same elsewhere. +function endServer(child) { + try { child.stdin.end(); } catch {} + if (!IS_WIN) child.kill("SIGTERM"); + setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref(); +} + function stopServer() { - // server.py handles SIGTERM by closing its shared SSH connection. - if (server) server.kill("SIGTERM"); + if (server) endServer(server); } function errorPage(message) { @@ -139,12 +176,12 @@ async function restartServer() { const old = server; server = null; url = null; - if (old) old.kill("SIGTERM"); + if (old) endServer(old); await load(); } -// The page's sticky header becomes the title bar, clear of the traffic lights. -const CHROME_CSS = ` +// On macOS the page's sticky header becomes the title bar, clear of the traffic lights. +const CHROME_CSS = IS_MAC && ` header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; } header a, header button, header input, header .chip { -webkit-app-region: no-drag; } `; @@ -183,8 +220,8 @@ async function firstRunCheck() { type: "info", message: "Connect to your Steam Frame", detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → ` - + "Enable Developer Mode, then Developer → Set User Password. Then run the setup script: it finds the " - + "headset, creates a key, and asks for that password once in Terminal.", + + "Enable Developer Mode, then Developer → Set User Password. Then run the setup: it finds the " + + "headset, creates a key, and asks for that password once in a terminal window.", buttons: ["Set Up Connection…", "Later"], defaultId: 0, cancelId: 1, }); @@ -195,11 +232,12 @@ function createWindow() { win = new BrowserWindow({ width: 1400, height: 950, minWidth: 760, minHeight: 560, title: "Frame Control", backgroundColor: BG, show: false, - titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 }, + ...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } } + : { icon: path.join(__dirname, "build", "icon.png") }), webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, }); win.once("ready-to-show", () => win.show()); - win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS)); + if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS)); // External links open in the default browser; the app never navigates away. win.webContents.setWindowOpenHandler(({ url: target }) => { if (/^https?:\/\//.test(target)) shell.openExternal(target); @@ -212,36 +250,45 @@ function createWindow() { load(); } -// Runs in Terminal because ssh-copy-id asks for the Developer Mode password. -function runInTerminal(command) { - const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`, - "-e", "activate", "-e", "end tell"], (err) => { - if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err)); - }); +// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via +// ui/frame_host.py, which the server uses too: setup and power actions ask for the +// Developer Mode password there. +async function runInTerminal(argv) { + try { + const env = { ...process.env, PATH: await loginPath() }; + const py = python || await findPython(env); + if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`); + await run(py, [path.join(ROOT, "ui", "frame_host.py"), "terminal", "--", ...argv], + { env, timeout: 15000, windowsHide: true }); + } catch (err) { + dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim()); + } } -const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`; - -function setUpConnection() { - runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(path.join(SCRIPTS, "connect.sh"))}`); +async function setUpConnection() { + const alias = `FRAME_ALIAS=${FRAME}`; + if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]); + const py = python || await findPython({ ...process.env, PATH: await loginPath() }); + const setup = [py || "python3", path.join(ROOT, "ui", "frame_connect.py")]; + // A new console inherits our environment on Windows; Linux terminals may not. + runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]); } function buildMenu() { const template = [ - { role: "appMenu" }, + ...(IS_MAC ? [{ role: "appMenu" }] : []), { role: "fileMenu" }, { role: "editMenu" }, { label: "Frame", submenu: [ { label: "Set Up Connection…", click: setUpConnection }, - { label: "Open SSH in Terminal", click: () => runInTerminal(`ssh ${sh(FRAME)}`) }, + { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) }, { type: "separator" }, { label: "Open in Browser", click: () => url && shell.openExternal(url) }, { label: "Restart Server", click: () => win ? restartServer() : createWindow() }, { label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) }, - { label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }, + ...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]), ], }, { @@ -253,7 +300,7 @@ function buildMenu() { { type: "separator" }, { role: "togglefullscreen" }, ], }, - { role: "windowMenu" }, + ...(IS_MAC ? [{ role: "windowMenu" }] : []), { role: "help", submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }], diff --git a/app/package.json b/app/package.json index 7a6f460..ee78fbd 100644 --- a/app/package.json +++ b/app/package.json @@ -2,7 +2,7 @@ "name": "frame-control", "productName": "Frame Control", "version": "0.2.0", - "description": "Mac app for managing a Valve Steam Frame over SSH", + "description": "Desktop app for managing a Valve Steam Frame over SSH", "private": true, "main": "main.js", "license": "MIT", @@ -10,7 +10,9 @@ "start": "env -u ELECTRON_RUN_AS_NODE electron .", "icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js", "dist": "electron-builder --mac --arm64 --publish never", - "dist:dir": "electron-builder --mac --arm64 --dir" + "dist:dir": "electron-builder --mac --arm64 --dir", + "dist:linux": "electron-builder --linux --x64 --arm64 --publish never", + "dist:win": "node build/fetch-python.js && electron-builder --win --x64 --publish never" }, "devDependencies": { "electron": "^44.4.5", @@ -82,6 +84,56 @@ "runAsNode": false, "enableNodeOptionsEnvironmentVariable": false, "enableNodeCliInspectArguments": false + }, + "linux": { + "target": [ + "AppImage", + "deb" + ], + "category": "Utility", + "icon": "build/icon.png", + "executableName": "frame-control", + "synopsis": "Manage a Valve Steam Frame over SSH", + "artifactName": "Frame-Control-${version}-${arch}.${ext}", + "desktop": { + "entry": { + "StartupWMClass": "frame-control" + } + } + }, + "deb": { + "depends": [ + "python3", + "openssh-client" + ] + }, + "win": { + "target": [ + "nsis", + "zip" + ], + "icon": "build/icon.png", + "artifactName": "Frame-Control-${version}-win-${arch}.${ext}", + "extraResources": [ + { + "from": "build/python-win", + "to": "python", + "filter": [ + "**/*" + ] + } + ] + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "artifactName": "Frame-Control-Setup-${version}.${ext}" } + }, + "homepage": "https://github.com/saphid/steam-frame", + "author": { + "name": "saphid", + "email": "4596216+saphid@users.noreply.github.com" } } diff --git a/ui/frame_android.py b/ui/frame_android.py index 62f898b..2537d66 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -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']) diff --git a/ui/frame_catalog.py b/ui/frame_catalog.py index 4638a61..28c0161 100644 --- a/ui/frame_catalog.py +++ b/ui/frame_catalog.py @@ -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() diff --git a/ui/frame_compat_db.py b/ui/frame_compat_db.py index 3259e42..66ef861 100644 --- a/ui/frame_compat_db.py +++ b/ui/frame_compat_db.py @@ -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 diff --git a/ui/frame_connect.py b/ui/frame_connect.py new file mode 100644 index 0000000..959ae25 --- /dev/null +++ b/ui/frame_connect.py @@ -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:])) diff --git a/ui/frame_host.py b/ui/frame_host.py new file mode 100644 index 0000000..321b550 --- /dev/null +++ b/ui/frame_host.py @@ -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:]) diff --git a/ui/frame_store.py b/ui/frame_store.py index 8373623..8b554ad 100644 --- a/ui/frame_store.py +++ b/ui/frame_store.py @@ -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 diff --git a/ui/index.html b/ui/index.html index eb5ec42..1e5f53e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -384,8 +384,8 @@

Screenshots

- - + +
Loading…
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
@@ -465,7 +465,7 @@
- +
Clipboard needs the desktop panel open in the headset.
@@ -522,7 +522,7 @@ -
Sleep, Restart and Shut down open Terminal for the Developer Mode password.
+
Sleep, Restart and Shut down open a terminal window for the Developer Mode password.