mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 00:00:21 +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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/python-win/
|
||||
@@ -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); });
|
||||
+91
-44
@@ -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: <resources>/{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") }],
|
||||
|
||||
+54
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
+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