mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +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
+866
-151
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
|
- name: Server tests
|
||||||
run: python -m unittest discover -s tests -v
|
run: python -m unittest discover -s tests -v
|
||||||
- name: App syntax
|
- 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/
|
node_modules/
|
||||||
dist/
|
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
|
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||||
// shows it in a native window. The server does all the work over the `frame`
|
// a free loopback port and shows it in a native window. The server does all the
|
||||||
// SSH alias; this file only hosts it.
|
// work over the `frame` SSH alias; this file only hosts it.
|
||||||
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
|
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
|
||||||
const { execFile, spawn } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
@@ -12,11 +12,15 @@ const path = require("path");
|
|||||||
|
|
||||||
const run = promisify(execFile);
|
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 ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
|
||||||
const SERVER = path.join(ROOT, "ui", "server.py");
|
const SERVER = path.join(ROOT, "ui", "server.py");
|
||||||
const SCRIPTS = path.join(ROOT, "scripts");
|
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 LOG = path.join(LOG_DIR, "server.log");
|
||||||
const BG = "#0d1117";
|
const BG = "#0d1117";
|
||||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||||
@@ -25,15 +29,18 @@ let server = null;
|
|||||||
let url = null;
|
let url = null;
|
||||||
let win = null;
|
let win = null;
|
||||||
let quitting = false;
|
let quitting = false;
|
||||||
|
let python = null;
|
||||||
|
|
||||||
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
|
// 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.
|
// Runs asynchronously so a slow shell profile can't freeze the window.
|
||||||
let cachedPath = null;
|
let cachedPath = null;
|
||||||
async function loginPath() {
|
async function loginPath() {
|
||||||
|
if (IS_WIN) return process.env.PATH || "";
|
||||||
if (cachedPath) return cachedPath;
|
if (cachedPath) return cachedPath;
|
||||||
const shellPath = os.userInfo().shell || process.env.SHELL || "/bin/zsh";
|
const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
|
||||||
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
|
const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
|
||||||
let fromShell = "";
|
let fromShell = "";
|
||||||
try {
|
try {
|
||||||
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
||||||
@@ -46,19 +53,40 @@ async function loginPath() {
|
|||||||
return joined;
|
return joined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
|
||||||
async function findPython(env) {
|
async function findPython(env) {
|
||||||
for (const dir of env.PATH.split(":")) {
|
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
|
||||||
const p = path.join(dir, "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 {
|
try {
|
||||||
fs.accessSync(p, fs.constants.X_OK);
|
fs.accessSync(p, fs.constants.X_OK);
|
||||||
// /usr/bin/python3 is a stub until the Command Line Tools are installed.
|
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
|
||||||
await run(p, ["-c", "import http.server"], { timeout: 10000, env });
|
await run(p, ["-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
|
||||||
|
{ timeout: 10000, env, windowsHide: true });
|
||||||
return p;
|
return p;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return null;
|
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() {
|
function freePort() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const s = net.createServer();
|
const s = net.createServer();
|
||||||
@@ -80,17 +108,19 @@ function ping(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
|
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1",
|
||||||
const python = await findPython(env);
|
PYTHONIOENCODING: "utf-8", FRAME_CONTROL_APP: "1" };
|
||||||
if (!python) {
|
python = await findPython(env);
|
||||||
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
|
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
|
||||||
+ "(xcode-select --install) or Homebrew's python, then reopen the app.");
|
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
|
||||||
}
|
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
const log = fs.openSync(LOG, "a");
|
const log = fs.openSync(LOG, "a");
|
||||||
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
|
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);
|
fs.closeSync(log);
|
||||||
server = child;
|
server = child;
|
||||||
let exited = null;
|
let exited = null;
|
||||||
@@ -112,13 +142,20 @@ async function startServer() {
|
|||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
if (server === child) server = null;
|
if (server === child) server = null;
|
||||||
child.kill("SIGTERM");
|
endServer(child);
|
||||||
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
|
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() {
|
function stopServer() {
|
||||||
// server.py handles SIGTERM by closing its shared SSH connection.
|
if (server) endServer(server);
|
||||||
if (server) server.kill("SIGTERM");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorPage(message) {
|
function errorPage(message) {
|
||||||
@@ -139,12 +176,12 @@ async function restartServer() {
|
|||||||
const old = server;
|
const old = server;
|
||||||
server = null;
|
server = null;
|
||||||
url = null;
|
url = null;
|
||||||
if (old) old.kill("SIGTERM");
|
if (old) endServer(old);
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The page's sticky header becomes the title bar, clear of the traffic lights.
|
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||||
const CHROME_CSS = `
|
const CHROME_CSS = IS_MAC && `
|
||||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
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; }
|
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||||
`;
|
`;
|
||||||
@@ -183,8 +220,8 @@ async function firstRunCheck() {
|
|||||||
type: "info",
|
type: "info",
|
||||||
message: "Connect to your Steam Frame",
|
message: "Connect to your Steam Frame",
|
||||||
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
|
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 "
|
+ "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 Terminal.",
|
+ "headset, creates a key, and asks for that password once in a terminal window.",
|
||||||
buttons: ["Set Up Connection…", "Later"],
|
buttons: ["Set Up Connection…", "Later"],
|
||||||
defaultId: 0, cancelId: 1,
|
defaultId: 0, cancelId: 1,
|
||||||
});
|
});
|
||||||
@@ -195,11 +232,12 @@ function createWindow() {
|
|||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||||
title: "Frame Control", backgroundColor: BG, show: false,
|
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 },
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||||
});
|
});
|
||||||
win.once("ready-to-show", () => win.show());
|
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.
|
// External links open in the default browser; the app never navigates away.
|
||||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||||
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
||||||
@@ -212,36 +250,45 @@ function createWindow() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs in Terminal because ssh-copy-id asks for the Developer Mode password.
|
// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
|
||||||
function runInTerminal(command) {
|
// ui/frame_host.py, which the server uses too: setup and power actions ask for the
|
||||||
const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
// Developer Mode password there.
|
||||||
execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`,
|
async function runInTerminal(argv) {
|
||||||
"-e", "activate", "-e", "end tell"], (err) => {
|
try {
|
||||||
if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err));
|
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, "'\\''")}'`;
|
async function setUpConnection() {
|
||||||
|
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||||
function setUpConnection() {
|
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||||
runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(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() {
|
function buildMenu() {
|
||||||
const template = [
|
const template = [
|
||||||
{ role: "appMenu" },
|
...(IS_MAC ? [{ role: "appMenu" }] : []),
|
||||||
{ role: "fileMenu" },
|
{ role: "fileMenu" },
|
||||||
{ role: "editMenu" },
|
{ role: "editMenu" },
|
||||||
{
|
{
|
||||||
label: "Frame",
|
label: "Frame",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "Set Up Connection…", click: setUpConnection },
|
{ 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" },
|
{ type: "separator" },
|
||||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||||
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
|
{ 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" },
|
{ type: "separator" }, { role: "togglefullscreen" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ role: "windowMenu" },
|
...(IS_MAC ? [{ role: "windowMenu" }] : []),
|
||||||
{
|
{
|
||||||
role: "help",
|
role: "help",
|
||||||
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
||||||
|
|||||||
+54
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
"version": "0.2.0",
|
"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,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||||
"dist": "electron-builder --mac --arm64 --publish never",
|
"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": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
@@ -82,6 +84,56 @@
|
|||||||
"runAsNode": false,
|
"runAsNode": false,
|
||||||
"enableNodeOptionsEnvironmentVariable": false,
|
"enableNodeOptionsEnvironmentVariable": false,
|
||||||
"enableNodeCliInspectArguments": 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}
|
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__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
||||||
@@ -53,18 +55,16 @@ def game_id(shortcut_appid):
|
|||||||
|
|
||||||
|
|
||||||
def aapt2():
|
def aapt2():
|
||||||
found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2'))
|
exe = 'aapt2.exe' if frame_host.WINDOWS else 'aapt2'
|
||||||
+ glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2')
|
found = sorted(f for d in frame_host.android_sdk_dirs() for f in glob.glob(os.path.join(d, 'build-tools', '*', exe)))
|
||||||
+ glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2')))
|
return found[-1] if found else shutil.which('aapt2')
|
||||||
return found[-1] if found else None
|
|
||||||
|
|
||||||
|
|
||||||
def apk_info(path):
|
def apk_info(path):
|
||||||
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
|
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
|
||||||
tool = aapt2()
|
tool = aapt2()
|
||||||
if not tool:
|
if not tool:
|
||||||
raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then '
|
raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
|
||||||
'sdkmanager "build-tools;36.0.0"')
|
|
||||||
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
|
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
|
||||||
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
||||||
if not m:
|
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
|
_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:
|
try:
|
||||||
subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
|
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout)
|
||||||
check=True, capture_output=True, text=True, timeout=timeout)
|
|
||||||
except subprocess.TimeoutExpired:
|
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:
|
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():
|
def _shortcut_ids():
|
||||||
@@ -146,8 +154,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
|||||||
ok = False
|
ok = False
|
||||||
try:
|
try:
|
||||||
ssh(f'mkdir -p {d}')
|
ssh(f'mkdir -p {d}')
|
||||||
_rsync(apk_path, f'{d}/app.apk.part')
|
_copy(apk_path, f'{d}/app.apk.part')
|
||||||
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
|
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||||
icon = ''
|
icon = ''
|
||||||
if info['icon_png']:
|
if info['icon_png']:
|
||||||
ssh(f'cat > {d}/icon.png', input=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 build as catalog_build # noqa: E402
|
||||||
import reports # noqa: E402
|
import reports # noqa: E402
|
||||||
import frame_android # noqa: E402
|
import frame_android # noqa: E402
|
||||||
|
import frame_host # noqa: E402
|
||||||
import frame_compat_db as compat_db # 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'))
|
else os.path.join(CATALOG, 'data', 'cache'))
|
||||||
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|||||||
+11
-6
@@ -1,20 +1,23 @@
|
|||||||
"""Frame Control's compatibility database: a private Lakebed capsule
|
"""Frame Control's compatibility database: a private Lakebed capsule
|
||||||
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
(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,
|
write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
|
||||||
account app-key).
|
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
|
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
|
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}
|
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 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 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')
|
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
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')
|
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
||||||
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
||||||
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
||||||
@@ -32,9 +35,11 @@ def key():
|
|||||||
k = os.environ.get('FRAME_CONTROL_KEY')
|
k = os.environ.get('FRAME_CONTROL_KEY')
|
||||||
if k:
|
if k:
|
||||||
return k
|
return k
|
||||||
|
p = None
|
||||||
|
if frame_host.MAC:
|
||||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
if p.returncode != 0 or not p.stdout.strip():
|
if p is None or p.returncode != 0 or not p.stdout.strip():
|
||||||
raise DBError('No compatibility-database key in the Keychain '
|
raise DBError('No compatibility-database key in the Keychain '
|
||||||
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
||||||
return p.stdout.strip()
|
return p.stdout.strip()
|
||||||
@@ -42,7 +47,7 @@ def key():
|
|||||||
|
|
||||||
def shared():
|
def shared():
|
||||||
"""Whether reports reach the shared database. Without the key (anyone but the
|
"""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:
|
try:
|
||||||
key()
|
key()
|
||||||
return True
|
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):
|
Uses the store's public endpoints (no key, no login):
|
||||||
api/storesearch name search, price in the IP's currency
|
api/storesearch name search, price in the IP's currency
|
||||||
|
|||||||
+19
-9
@@ -384,8 +384,8 @@
|
|||||||
<section id="shots">
|
<section id="shots">
|
||||||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
<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="shotsRefresh">Refresh</button>
|
||||||
<button class="small" id="shotsFolder" title="Open ~/Pictures/SteamFrame">Show in Finder</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 Mac</button>
|
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></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>
|
<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>
|
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
<button class="action small" id="clipSend">Send text</button>
|
<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>
|
||||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||||
</section>
|
</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="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>
|
<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>
|
||||||
<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 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://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>
|
<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 ▴";
|
$("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) {
|
async function api(path, body) {
|
||||||
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
||||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
|
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);
|
if (!text) return toast("Nothing to send", true);
|
||||||
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
|
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 ----
|
// ---- file drop ----
|
||||||
const drop = $("drop");
|
const drop = $("drop");
|
||||||
@@ -1461,7 +1470,7 @@ async function loadReports() {
|
|||||||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||||
$("repHint").textContent = shared
|
$("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 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` : "";
|
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||||
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
||||||
const k = r.rating || r.result;
|
const k = r.rating || r.result;
|
||||||
@@ -1522,6 +1531,7 @@ $("repForm").onsubmit = async e => {
|
|||||||
finally { $("repSave").disabled = false; }
|
finally { $("repSave").disabled = false; }
|
||||||
};
|
};
|
||||||
loadReports();
|
loadReports();
|
||||||
|
api("/api/host").then(applyHostWording).catch(() => {});
|
||||||
|
|
||||||
// ---- Steam screenshots from the headset ----
|
// ---- Steam screenshots from the headset ----
|
||||||
const shots = { list: [], urls: [] };
|
const shots = { list: [], urls: [] };
|
||||||
@@ -1547,14 +1557,14 @@ async function loadShots() {
|
|||||||
} finally { $("shotsRefresh").disabled = false; }
|
} finally { $("shotsRefresh").disabled = false; }
|
||||||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||||
const unsaved = shots.list.filter(s => !s.saved).length;
|
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;
|
$("shotsSaveNew").disabled = !unsaved;
|
||||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
$("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">
|
<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="row"><div class="grow">
|
||||||
<div class="t">${esc(shotApp(s.appid))}</div>
|
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></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></div>`).join("")
|
||||||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||||
// Thumbnails one at a time over the shared SSH connection.
|
// Thumbnails one at a time over the shared SSH connection.
|
||||||
@@ -1606,7 +1616,7 @@ $("shotGrid").onclick = e => {
|
|||||||
};
|
};
|
||||||
$("shotsRefresh").onclick = loadShots;
|
$("shotsRefresh").onclick = loadShots;
|
||||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
$("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 ----
|
// ---- nav highlight follows scroll ----
|
||||||
const spy = new IntersectionObserver(entries => {
|
const spy = new IntersectionObserver(entries => {
|
||||||
|
|||||||
+146
-73
@@ -1,18 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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`
|
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||||
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
|
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)
|
Env: FRAME_ALIAS (default frame)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
import select
|
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
@@ -26,19 +28,23 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
import frame_android
|
# Windows' embedded Python (bundled with the app) doesn't put the script's own
|
||||||
import frame_catalog
|
# folder on sys.path, so add it for the sibling modules below.
|
||||||
import frame_store
|
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
|
HERE = Path(__file__).resolve().parent
|
||||||
SCRIPTS = HERE.parent / "scripts"
|
|
||||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
|
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||||
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
# supports it (not on Windows: there every command connects on its own).
|
||||||
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
|
CONTROL = frame_host.control_path()
|
||||||
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
|
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.
|
# 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.
|
# Android helpers share the multiplexed connection when it's up.
|
||||||
frame_android.SSH_OPTS = SSH[1:]
|
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.
|
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||||
"""
|
"""
|
||||||
global _master
|
global _master
|
||||||
|
if not CONTROL:
|
||||||
|
return
|
||||||
|
|
||||||
def up():
|
def up():
|
||||||
try:
|
try:
|
||||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
|
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",
|
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
if up() or _master.poll() is not None:
|
if up() or _master.poll() is not None:
|
||||||
return
|
return
|
||||||
@@ -119,40 +128,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
return r.stdout
|
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):
|
def strip_ansi(s):
|
||||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||||
|
|
||||||
|
|
||||||
def terminal(command):
|
def terminal(argv):
|
||||||
"""Open Terminal.app running `command` (for anything needing a password)."""
|
"""Open a terminal window running argv (for anything needing a password)."""
|
||||||
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
|
try:
|
||||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
return frame_host.open_terminal(argv)
|
||||||
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
|
except frame_host.HostError as e:
|
||||||
capture_output=True, text=True)
|
raise Failure(str(e), 500)
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
# ---- actions ---------------------------------------------------------------
|
# ---- actions ---------------------------------------------------------------
|
||||||
@@ -368,13 +353,44 @@ def set_volume(body):
|
|||||||
return {"message": "Volume updated"}
|
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):
|
def clipboard(body):
|
||||||
if body.get("fromMac"):
|
if body.get("fromMac") or body.get("fromComputer"):
|
||||||
return {"message": script("paste-to-frame.sh", timeout=30)}
|
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")
|
text = body.get("text")
|
||||||
if not isinstance(text, str) or not text:
|
if not isinstance(text, str) or not text:
|
||||||
raise Failure("nothing to send", 400)
|
raise Failure("nothing to send", 400)
|
||||||
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
|
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
|
||||||
|
|
||||||
|
|
||||||
def flatpak(body):
|
def flatpak(body):
|
||||||
@@ -382,7 +398,11 @@ def flatpak(body):
|
|||||||
if not FLATPAK_ID.match(app):
|
if not FLATPAK_ID.match(app):
|
||||||
raise Failure("bad Flatpak app ID", 400)
|
raise Failure("bad Flatpak app ID", 400)
|
||||||
if action == "install":
|
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":
|
if action == "uninstall":
|
||||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||||
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
||||||
@@ -391,25 +411,25 @@ def flatpak(body):
|
|||||||
|
|
||||||
def open_thing(body):
|
def open_thing(body):
|
||||||
what = body.get("what")
|
what = body.get("what")
|
||||||
alias = shlex.quote(FRAME)
|
try:
|
||||||
if what == "terminal":
|
if what == "terminal":
|
||||||
terminal(f"ssh {alias}")
|
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||||
return {"message": "Opened an SSH session in Terminal"}
|
|
||||||
if what in ("reboot", "poweroff", "suspend"):
|
if what in ("reboot", "poweroff", "suspend"):
|
||||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||||
terminal(f"ssh -t {alias} sudo systemctl {what}")
|
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
|
||||||
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
|
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||||
if what == "steamlink":
|
if what == "steamlink":
|
||||||
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
|
return {"message": frame_host.open_steam_link()}
|
||||||
if what == "rdp":
|
if what == "rdp":
|
||||||
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
|
return {"message": frame_host.open_rdp(FRAME)}
|
||||||
if what == "sftp":
|
if what == "sftp":
|
||||||
terminal(f"sftp {alias}")
|
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
|
||||||
return {"message": "Opened an SFTP session in Terminal"}
|
|
||||||
if what == "shots":
|
if what == "shots":
|
||||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
subprocess.run(["open", str(SHOTS_DIR)])
|
frame_host.open_path(SHOTS_DIR)
|
||||||
return {"message": "Opened ~/Pictures/SteamFrame in Finder"}
|
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)
|
raise Failure("unknown target", 400)
|
||||||
|
|
||||||
|
|
||||||
@@ -438,7 +458,7 @@ def android(body):
|
|||||||
runtime=body.get("runtime") or "instance",
|
runtime=body.get("runtime") or "instance",
|
||||||
label=body.get("label"), source=body.get("source"))
|
label=body.get("label"), source=body.get("source"))
|
||||||
name = r.get("label") or pkg
|
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}
|
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
raise Failure(str(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():
|
def adb_path():
|
||||||
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
|
try:
|
||||||
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
|
return frame_host.adb()
|
||||||
if cand and os.access(cand, os.X_OK):
|
except frame_host.HostError as e:
|
||||||
return cand
|
raise Failure(str(e), 500)
|
||||||
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
|
|
||||||
|
|
||||||
|
|
||||||
def adb(adb_bin, *args, timeout=20):
|
def adb(adb_bin, *args, timeout=20):
|
||||||
@@ -490,7 +509,7 @@ def free_local_port():
|
|||||||
|
|
||||||
|
|
||||||
class AdbTunnel:
|
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
|
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
|
||||||
disconnects adb and kills the ssh process, whatever happened inside.
|
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 ------------------------------------------------------------------
|
# ---- 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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "FrameControl/1"
|
server_version = "FrameControl/1"
|
||||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||||
@@ -788,6 +850,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
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":
|
elif path == "/api/android":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"apps": frame_android.list_apps()})
|
self.send_json({"apps": frame_android.list_apps()})
|
||||||
@@ -869,11 +934,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
old, _stream_proc = _stream_proc, proc
|
old, _stream_proc = _stream_proc, proc
|
||||||
if old and old.poll() is None:
|
if old and old.poll() is None:
|
||||||
old.terminate()
|
old.terminate()
|
||||||
fd = proc.stdout.fileno()
|
chunks = _pipe_reader(proc.stdout)
|
||||||
# Nothing is sent until the first bytes arrive, so a failure to
|
# Nothing is sent until the first bytes arrive, so a failure to
|
||||||
# start still comes back as a JSON error.
|
# start still comes back as a JSON error.
|
||||||
ready, _, _ = select.select([fd], [], [], 20)
|
first = _next_chunk(chunks, 20)
|
||||||
first = os.read(fd, 1 << 16) if ready else b""
|
|
||||||
if not first:
|
if not first:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
proc.wait()
|
proc.wait()
|
||||||
@@ -894,8 +958,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.flush()
|
self.wfile.flush()
|
||||||
# A stalled headset view ends the stream rather than
|
# A stalled headset view ends the stream rather than
|
||||||
# holding this thread (and the page) forever.
|
# holding this thread (and the page) forever.
|
||||||
ready, _, _ = select.select([fd], [], [], STREAM_STALL)
|
chunk = _next_chunk(chunks, STREAM_STALL)
|
||||||
chunk = os.read(fd, 1 << 16) if ready else b""
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
||||||
finally:
|
finally:
|
||||||
@@ -952,7 +1015,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
raise Failure(str(e), 400)
|
raise Failure(str(e), 400)
|
||||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
|
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:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
@@ -960,9 +1023,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
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()
|
args = ap.parse_args()
|
||||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||||
|
if not frame_host.WINDOWS:
|
||||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
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)
|
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||||
try:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
@@ -970,6 +1042,7 @@ def main():
|
|||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
# The master was started with -N, so it stays up until told to exit.
|
# The master was started with -N, so it stays up until told to exit.
|
||||||
|
if CONTROL:
|
||||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
||||||
if _master and _master.poll() is None:
|
if _master and _master.poll() is None:
|
||||||
_master.terminate()
|
_master.terminate()
|
||||||
|
|||||||
Reference in new issue
Block a user