diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 882eb6c..4f6738f 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -31,10 +31,10 @@ 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 && node --check app/build/fetch-python.js
+ run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.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).
+ # The server runs on each desktop OS the app ships for, on the Python version
+ # the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one.
server-tests:
strategy:
fail-fast: false
diff --git a/README.md b/README.md
index d532d50..573e1ae 100644
--- a/README.md
+++ b/README.md
@@ -92,14 +92,14 @@ already ships. [How each feature works](docs/frame-control.md).
| | Download | Needs |
|---|---|---|
-| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Python 3 (`xcode-select --install`) |
-| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra: Python is bundled, and SSH is built into Windows |
-| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `python3` and `ssh` (most desktops have both) |
-| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | same |
+| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
+| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
+| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
+| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
-Optional: `adb` for Android apps
-([macOS](https://formulae.brew.sh/formula/android-platform-tools) `brew install android-platform-tools` ·
-Windows `winget install Google.PlatformTools` · Linux `sudo apt install adb`).
+The app brings its own Python and `adb`; SSH is built into macOS and Windows.
+Google doesn't publish `adb` for arm64 Linux, so that build uses your
+distribution's. If you already have `adb`, the app uses yours.
macOS: the app isn't notarized
@@ -132,8 +132,7 @@ chmod +x Frame-Control-linux-*.AppImage && ./Frame-Control-linux-*.AppImage
```
If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
-or run it with `--appimage-extract-and-run`. Sending the clipboard needs
-`wl-clipboard` (Wayland) or `xclip` (X11).
+or run it with `--appimage-extract-and-run`.
## Set up the headset
diff --git a/app/.gitignore b/app/.gitignore
index 5417301..76ed5f4 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,3 +1,3 @@
node_modules/
dist/
-build/python-win/
+build/deps/
diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js
new file mode 100644
index 0000000..8460b06
--- /dev/null
+++ b/app/build/fetch-deps.js
@@ -0,0 +1,134 @@
+// Downloads what the app bundles so users install nothing else: a standalone
+// Python (python-build-standalone), adb (Android platform-tools) and a CA
+// bundle. Each goes in build/deps/-/{python,tools}, which package.json
+// copies into the app's resources. Everything is pinned by version and SHA-256.
+// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
+const crypto = require("crypto");
+const fs = require("fs");
+const https = require("https");
+const path = require("path");
+const { execFileSync } = require("child_process");
+
+const PY = "3.12.14+20260924";
+const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
+ + `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
+const PYTHON = {
+ "mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
+ "win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
+ "linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
+ "linux-arm64": ["aarch64-unknown-linux-gnu", "c8499b61252c433280f134df954464d19811527b31cb920c35fc6967c1222e35"],
+};
+
+// Google publishes no arm64 Linux platform-tools; there the app uses the system adb.
+const PT = "37.0.1";
+const PT_URL = (os) => `https://dl.google.com/android/repository/platform-tools_r${PT}-${os}.zip`;
+const TOOLS = {
+ mac: ["darwin", "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d", ["adb"]],
+ win: ["win", "45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99",
+ ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll", "libwinpthread-1.dll"]],
+ linux: ["linux", "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1", ["adb"]],
+};
+
+// Mozilla's CA list, as curl publishes it: Python on Windows only trusts roots
+// already in the Windows store (see frame_host.trust_bundled_cas).
+const CA = "2026-09-25";
+const CA_SHA256 = "a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505";
+
+// Parts of Python the server never imports (GUI, tests, packaging, headers).
+const PRUNE = [
+ "include", "share", "Scripts", "libs", "tcl", "lib/pkgconfig", "lib/itcl4", "lib/tcl8", "lib/tcl8.6",
+ "lib/tk8.6", "lib/thread2.8", "bin/idle3", "bin/idle3.12", "bin/pip", "bin/pip3", "bin/pip3.12",
+ "bin/pydoc3", "bin/pydoc3.12", "bin/2to3", "bin/2to3-3.12", "bin/python3-config", "bin/python3.12-config",
+ ...["test", "idlelib", "tkinter", "turtledemo", "ensurepip", "lib2to3", "site-packages/pip", "pydoc_data", "venv"]
+ .flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]),
+];
+
+function get(url, redirects = 5) {
+ return new Promise((resolve, reject) => {
+ https.get(url, { timeout: 60000 }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ res.resume();
+ if (!redirects) return reject(new Error(`${url}: too many redirects`));
+ let next;
+ try { next = new URL(res.headers.location, url).href; }
+ catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
+ return resolve(get(next, redirects - 1));
+ }
+ 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("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
+ });
+}
+
+async function download(url, sha256, file) {
+ const data = await get(url);
+ const sum = crypto.createHash("sha256").update(data).digest("hex");
+ if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
+ fs.writeFileSync(file, data);
+}
+
+// Windows' own bsdtar: Git's GNU tar, often first on PATH, reads C:\ as a remote host.
+const TAR = process.platform === "win32" ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe") : "tar";
+
+function extract(file, dir) {
+ fs.mkdirSync(dir, { recursive: true });
+ // bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip.
+ try { execFileSync(TAR, ["-xf", file, "-C", dir]); }
+ catch (e) {
+ if (!file.endsWith(".zip")) throw e;
+ execFileSync("unzip", ["-q", "-o", file, "-d", dir]);
+ }
+ fs.rmSync(file);
+}
+
+async function fetch(os, arch) {
+ const key = `${os}-${arch}`;
+ if (!PYTHON[key]) throw new Error(`no bundle for ${key}`);
+ const out = path.join(__dirname, "deps", key);
+ const stamp = path.join(out, ".version");
+ const version = `python ${PY}, platform-tools ${PT}, CA ${CA}`;
+ if (fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === version) {
+ console.log(`${key}: already fetched (${version})`);
+ return;
+ }
+ fs.rmSync(out, { recursive: true, force: true });
+ fs.mkdirSync(out, { recursive: true });
+
+ const [triple, pySha] = PYTHON[key];
+ const tgz = path.join(out, "python.tar.gz");
+ await download(PY_URL(triple), pySha, tgz);
+ extract(tgz, out); // unpacks to python/
+ for (const p of PRUNE) fs.rmSync(path.join(out, "python", p), { recursive: true, force: true });
+ const stdlib = path.join(out, "python", "lib", "python3.12"); // macOS and Linux: drop the static libpython
+ if (fs.existsSync(stdlib)) {
+ for (const d of fs.readdirSync(stdlib)) {
+ if (d.startsWith("config-3.12")) fs.rmSync(path.join(stdlib, d), { recursive: true, force: true });
+ }
+ }
+
+ const tools = path.join(out, "tools");
+ fs.mkdirSync(tools);
+ if (!(os === "linux" && arch === "arm64")) {
+ const [name, ptSha, keep] = TOOLS[os];
+ const zip = path.join(out, "pt.zip");
+ const tmp = path.join(out, "pt");
+ await download(PT_URL(name), ptSha, zip);
+ extract(zip, tmp);
+ for (const f of [...keep, "NOTICE.txt", "source.properties"]) {
+ fs.copyFileSync(path.join(tmp, "platform-tools", f), path.join(tools, f));
+ }
+ if (os !== "win") fs.chmodSync(path.join(tools, "adb"), 0o755);
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ await download(`https://curl.se/ca/cacert-${CA}.pem`, CA_SHA256, path.join(tools, "cacert.pem"));
+ fs.writeFileSync(stamp, version);
+ console.log(`${key}: ${version} -> ${out}`);
+}
+
+(async () => {
+ const [os, ...archs] = process.argv.slice(2);
+ if (!os || !archs.length) throw new Error("usage: node build/fetch-deps.js ...");
+ for (const arch of archs) await fetch(os, arch);
+})().catch((e) => { console.error(e.message); process.exit(1); });
diff --git a/app/build/fetch-python.js b/app/build/fetch-python.js
deleted file mode 100644
index d883b9a..0000000
--- a/app/build/fetch-python.js
+++ /dev/null
@@ -1,49 +0,0 @@
-// 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 () => {
- const stamp = path.join(OUT, ".version");
- if (fs.existsSync(path.join(OUT, "python.exe")) && fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === VERSION) {
- console.log(`Python ${VERSION} already in ${OUT}`);
- return;
- }
- const zip = await get(URL);
- const sum = crypto.createHash("sha256").update(zip).digest("hex");
- if (sum !== SHA256) throw new Error(`checksum mismatch for ${URL}: ${sum}`);
- fs.rmSync(OUT, { recursive: true, force: true });
- fs.mkdirSync(OUT, { recursive: true });
- const file = path.join(OUT, "python.zip");
- fs.writeFileSync(file, zip);
- // bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip.
- try { execFileSync("tar", ["-xf", file, "-C", OUT]); }
- catch { execFileSync("unzip", ["-q", "-o", file, "-d", OUT]); }
- fs.rmSync(file);
- fs.writeFileSync(path.join(OUT, ".version"), VERSION);
- console.log(`Python ${VERSION} -> ${OUT}`);
-})().catch((e) => { console.error(e.message); process.exit(1); });
diff --git a/app/main.js b/app/main.js
index 4f5a522..37e71f9 100644
--- a/app/main.js
+++ b/app/main.js
@@ -1,7 +1,7 @@
// 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 { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -17,6 +17,7 @@ const IS_WIN = process.platform === "win32";
// Packaged: /{ui,scripts,python}. Dev: the repo checkout.
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
+const TOOLS = path.join(ROOT, "tools"); // bundled adb and CA certificates
const SERVER = path.join(ROOT, "ui", "server.py");
const SCRIPTS = path.join(ROOT, "scripts");
const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control")
@@ -54,10 +55,16 @@ async function loginPath() {
}
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
+// -I ignores PYTHON* variables and user site-packages, so a PYTHONHOME or
+// PYTHONPATH set for another Python can't break the bundled one. That makes these
+// flags stand in for PYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE (no __pycache__
+// inside the signed app) and PYTHONUTF8.
+const PY_FLAGS = ["-I", "-u", "-B", "-X", "utf8"];
+
async function findPython(env) {
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
- const candidates = [];
- if (IS_WIN) candidates.push(path.join(ROOT, "python", "python.exe"));
+ // The packaged app bundles Python (app/build/fetch-deps.js); a checkout uses PATH.
+ const candidates = [path.join(ROOT, "python", ...(IS_WIN ? ["python.exe"] : ["bin", "python3"]))];
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;
@@ -67,7 +74,7 @@ async function findPython(env) {
try {
fs.accessSync(p, fs.constants.X_OK);
// /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)"],
+ await run(p, [...PY_FLAGS, "-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
{ timeout: 10000, env, windowsHide: true });
return p;
} catch {}
@@ -79,10 +86,8 @@ 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 PYTHON_HELP = app.isPackaged ? "The bundled Python is missing; reinstall Frame Control."
+ : "Install Python 3.8 or later, 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).";
@@ -108,8 +113,8 @@ function ping(target) {
}
async function startServer() {
- const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1",
- PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" };
+ const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
+ ...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
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}`);
@@ -118,8 +123,7 @@ async function startServer() {
const log = fs.openSync(LOG, "a");
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
// stdin stays open while the app runs; the server exits cleanly when it closes.
- // -X utf8: the bundled Windows Python ignores PYTHON* variables (isolated mode).
- const child = spawn(python, ["-X", "utf8", SERVER, "--port", String(port), "--exit-on-eof"],
+ const child = spawn(python, [...PY_FLAGS, SERVER, "--port", String(port), "--exit-on-eof"],
{ env, stdio: ["pipe", log, log], windowsHide: true });
child.stdin.on("error", () => {});
fs.closeSync(log);
@@ -229,13 +233,22 @@ async function firstRunCheck() {
if (response === 0) setUpConnection();
}
+ipcMain.handle("clipboard:read", (e) => {
+ if (!win || e.sender !== win.webContents || !url) return "";
+ try {
+ if (new URL(e.senderFrame.url).origin !== new URL(url).origin) return "";
+ } catch { return ""; }
+ return clipboard.readText();
+});
+
function createWindow() {
win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false,
...(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,
+ preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => win.show());
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
@@ -259,7 +272,7 @@ async function runInTerminal(argv) {
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],
+ await run(py, [...PY_FLAGS, 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());
@@ -270,7 +283,7 @@ 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")];
+ const setup = [py || "python3", ...PY_FLAGS, 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]);
}
diff --git a/app/package-lock.json b/app/package-lock.json
index 117de10..f8f2d77 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frame-control",
- "version": "0.3.0",
+ "version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
- "version": "0.3.0",
+ "version": "0.3.1",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
diff --git a/app/package.json b/app/package.json
index d7586b9..944d660 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
- "version": "0.3.0",
+ "version": "0.3.1",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -9,10 +9,10 @@
"scripts": {
"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:linux": "electron-builder --linux --x64 --arm64 --publish never",
- "dist:win": "node build/fetch-python.js && electron-builder --win --x64 --publish never"
+ "dist": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
+ "dist:dir": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
+ "dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
+ "dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -27,6 +27,7 @@
},
"files": [
"main.js",
+ "preload.js",
"package.json",
"build/icon.png"
],
@@ -62,6 +63,20 @@
"pins.json",
"site/apps.js"
]
+ },
+ {
+ "from": "build/deps/${os}-${arch}/python",
+ "to": "python",
+ "filter": [
+ "**/*"
+ ]
+ },
+ {
+ "from": "build/deps/${os}-${arch}/tools",
+ "to": "tools",
+ "filter": [
+ "**/*"
+ ]
}
],
"mac": {
@@ -105,7 +120,6 @@
},
"deb": {
"depends": [
- "python3",
"openssh-client"
]
},
@@ -115,16 +129,7 @@
"zip"
],
"icon": "build/icon.png",
- "artifactName": "Frame-Control-win-${arch}.${ext}",
- "extraResources": [
- {
- "from": "build/python-win",
- "to": "python",
- "filter": [
- "**/*"
- ]
- }
- ]
+ "artifactName": "Frame-Control-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
diff --git a/app/preload.js b/app/preload.js
new file mode 100644
index 0000000..b921279
--- /dev/null
+++ b/app/preload.js
@@ -0,0 +1,7 @@
+// Lets the page read this computer's clipboard through Electron, so sending it
+// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard.
+const { contextBridge, ipcRenderer } = require("electron");
+
+contextBridge.exposeInMainWorld("frameApp", {
+ readClipboard: () => ipcRenderer.invoke("clipboard:read"),
+});
diff --git a/docs/apks.md b/docs/apks.md
index 3a0af5f..050e58f 100644
--- a/docs/apks.md
+++ b/docs/apks.md
@@ -18,7 +18,8 @@ python3 ui/frame_android.py list|launch|stop|remove|probe
Each APK becomes its own app, the way T3 Code is set up (see the instance
section below), instead of going into Lepton Development:
-1. `aapt2` reads the package, label, version, ABIs and icon. APKs that need
+1. `ui/frame_apk.py` reads the package, label, version, ABIs and icon
+ (a stdlib parser of the binary manifest and resource table, so no Android SDK). APKs that need
API > 30 or have no `arm64-v8a` build are refused.
2. The APK, `frame/android/lepton-app.sh` (as `launch.sh`), `instance.id`,
`meta.json`, the icon and the `lepton-show-flatscreen` marker go to
diff --git a/docs/frame-control.md b/docs/frame-control.md
index e612947..43f37a1 100644
--- a/docs/frame-control.md
+++ b/docs/frame-control.md
@@ -48,8 +48,8 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
installed app). Your reports are saved on your computer and change the verdicts
you see. They aren't uploaded anywhere: the shared database is maintainer-only
- for now (see [compat-db/README.md](../compat-db/README.md)). Needs `adb`, and
- `aapt2` for reading APK files.
+ for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
+ `adb`, or yours if you have one.
- **Android display**: pick a running Lepton instance (by the app in it) and set
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
@@ -70,7 +70,12 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
and shows it in its own window; the server stops when you quit the app. The
app bundles `ui/`, `scripts/`, `frame/android/` and the rated catalogue from
-`apk-catalog/`, and on Windows an embedded Python too.
+`apk-catalog/`, plus a standalone Python
+([python-build-standalone](https://github.com/astral-sh/python-build-standalone))
+and `adb` from Google's platform-tools, so there's nothing else to install. It
+also bundles curl's copy of Mozilla's CA list, because Python on Windows only
+trusts root certificates already in the Windows store.
+`app/build/fetch-deps.js` downloads both, pinned by SHA-256.
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
with a non-local `Host` header, and any `/api/` request without a custom
@@ -95,8 +100,8 @@ separately.
## Per-platform notes
-**macOS.** The app reads `PATH` from your login shell, so Homebrew's `rsync`,
-`adb` and Python work when you launch it from Finder. Set Up Connection runs
+**macOS.** The app reads `PATH` from your login shell, so Homebrew's `rsync`
+and `adb` are used when you launch it from Finder. Set Up Connection runs
`scripts/connect.sh` in Terminal. The log is at
`~/Library/Logs/Frame Control/server.log`. The build is ad-hoc signed and not
notarized: a downloaded copy is quarantined until you run
@@ -104,19 +109,21 @@ notarized: a downloaded copy is quarantined until you run
time you use them, macOS asks to allow local network access (for SSH) and
control of Terminal (for SSH and power actions).
-**Windows.** Python is bundled; `ssh` is Windows' built-in OpenSSH client
+**Windows.** `ssh` is Windows' built-in OpenSSH client
(Settings → System → Optional features, if it's been removed). Set Up
Connection runs `ui/frame_connect.py` in a console window. Copies use `scp`
because Windows has no `rsync`. The installer isn't code-signed, so SmartScreen
warns on first run: choose **More info → Run anyway**. The log is at
`%APPDATA%\Frame Control\logs\server.log`.
-**Linux.** Needs `python3` (3.8 or later) and `ssh`, which most desktops
-have. The AppImage runs anywhere; the `.deb` pulls both in on Debian and
-Ubuntu. Set Up Connection runs `ui/frame_connect.py` in your terminal emulator
-(GNOME Terminal, Konsole, xterm and others). Sending the clipboard needs
-`wl-clipboard` (Wayland) or `xclip` (X11). The log is at
-`~/.config/Frame Control/logs/server.log`.
+**Linux.** Needs `ssh`, which most desktops have; the `.deb` pulls it in.
+The arm64 build also needs your distribution's `adb` for Android apps, because
+Google publishes no arm64 Linux platform-tools. Set Up Connection runs
+`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
+and others). The log is at
+`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
+instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
+`xclip` (X11).
## Building
@@ -125,7 +132,7 @@ cd app
npm install
npm start # run from the checkout without packaging
npm run dist # macOS: dist/*.dmg and .zip (Apple Silicon)
-npm run dist:win # Windows: installer and .zip (fetches the embedded Python first)
+npm run dist:win # Windows: installer and .zip
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
```
diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py
new file mode 100644
index 0000000..f9c008b
--- /dev/null
+++ b/tests/test_frame_apk.py
@@ -0,0 +1,139 @@
+"""frame_apk against a small APK built here: binary manifest plus resource table."""
+import io
+import os
+import struct
+import sys
+import tempfile
+import unittest
+import zipfile
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
+import frame_apk # noqa: E402
+
+
+def pool(strings, utf8=False):
+ """A ResStringPool chunk."""
+ data, offsets = b'', []
+ for s in strings:
+ offsets.append(len(data))
+ if utf8:
+ b = s.encode()
+ data += bytes([len(s), len(b)]) + b + b'\0'
+ else:
+ data += struct.pack('.
+
+ package_raw=False drops the package's raw string (as some repackers do);
+ foreign_label adds a non-android `label` attribute after android:label.
+ """
+ strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
+ 'application', package, 'junk', 'label'] # the second 'label' has no android id
+ resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
+ resmap = struct.pack('',
+ 'lib/arm64-v8a/libx.so': b'', 'lib/x86_64/libx.so': b'',
+ }))
+ self.assertEqual(info['package'], 'com.example.demo')
+ self.assertEqual(info['label'], 'App label') # the default, not French
+ self.assertEqual(info['version'], '2.1')
+ self.assertEqual(info['min_sdk'], 26)
+ self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
+ self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
+
+ def test_missing_label_falls_back_to_package(self):
+ info = self.read(apk({'AndroidManifest.xml': manifest('com.example.bare', 0x7f010000, 0x7f010001, 21)}))
+ self.assertEqual(info['label'], 'com.example.bare')
+ self.assertEqual(info['version'], '')
+ self.assertEqual(info['abis'], [])
+
+ def test_repacked_manifest(self):
+ # Package kept only as a typed value; a foreign `label` mustn't beat android:label.
+ arsc = resources({(1, '', 0): {0: 1, 1: 2}})
+ info = self.read(apk({
+ 'AndroidManifest.xml': manifest('com.example.repacked', 0x7f010000, 0x7f010001, 24,
+ package_raw=False, foreign_label=True),
+ 'resources.arsc': arsc,
+ }))
+ self.assertEqual(info['package'], 'com.example.repacked')
+ self.assertEqual(info['label'], 'App label')
+ self.assertIsNone(info['icon_png'])
+
+ def test_rejects_non_apks(self):
+ for data in (b'not a zip', apk({'classes.dex': b''}), apk({'AndroidManifest.xml': b''})):
+ with self.assertRaises(frame_apk.ApkError):
+ self.read(data)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/ui/frame_android.py b/ui/frame_android.py
index 2536129..3bff853 100644
--- a/ui/frame_android.py
+++ b/ui/frame_android.py
@@ -8,8 +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, shutil, subprocess, sys, threading, time, zipfile, zlib
+import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
+import frame_apk
import frame_host
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -56,45 +57,12 @@ def game_id(shortcut_appid):
return (int(shortcut_appid) << 32) | 0x02000000
-def aapt2():
- 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(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
- out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, stdin=subprocess.DEVNULL, text=True).stdout
- m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
- if not m:
- raise FrameError(f'not a readable APK: {os.path.basename(path)}')
- label = re.search(r"application-label(?:-en(?:-US)?)?:'([^']*)'", out) or \
- re.search(r"application: label='([^']*)'", out)
- icons = re.findall(r"application-icon-(\d+):'([^']+)'", out)
- abis = re.search(r"native-code: (.*)", out)
- sdk = re.search(r"(?:minSdkVersion|sdkVersion):'(\d+)'", out)
- info = {'package': m[1], 'version': m[2], 'label': (label[1] if label else '') or m[1],
- 'abis': re.findall(r"'([^']+)'", abis[1]) if abis else [],
- 'min_sdk': int(sdk[1]) if sdk else None, 'icon_png': None}
try:
- z = zipfile.ZipFile(path)
- except (zipfile.BadZipFile, OSError) as e:
- raise FrameError(f'not a readable APK: {e}')
- with z:
- names = set(z.namelist())
- for _, icon in sorted(icons, key=lambda d: -int(d[0])):
- if icon.endswith('.png') and icon in names:
- info['icon_png'] = z.read(icon)
- break
- else: # adaptive icons are XML; fall back to the largest launcher PNG
- pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
- key=lambda n: z.getinfo(n).file_size)
- if pngs:
- info['icon_png'] = z.read(pngs[-1])
- return info
+ return frame_apk.apk_info(path)
+ except frame_apk.ApkError as e:
+ raise FrameError(f'{os.path.basename(path)}: {e}')
def check_installable(info):
diff --git a/ui/frame_apk.py b/ui/frame_apk.py
new file mode 100644
index 0000000..53fd58a
--- /dev/null
+++ b/ui/frame_apk.py
@@ -0,0 +1,227 @@
+"""Read an APK's package, label, version, SDK level, ABIs and icon, stdlib only.
+
+Replaces `aapt2 dump badging`, so installing APKs needs no Android SDK. It
+parses the binary AndroidManifest.xml and, for values the manifest points at
+(the label, version name and icon are often @string or @mipmap references),
+the resource table in resources.arsc.
+"""
+import struct
+import zipfile
+
+# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
+ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
+ 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'}
+T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
+
+
+class ApkError(Exception):
+ pass
+
+
+def _string_pool(buf, off):
+ """Strings of the ResStringPool chunk at off."""
+ _, hsize, _, count, _, flags, start = struct.unpack_from(' end:
+ break
+ yield ctype, hsize, off, size
+ off += size
+
+
+def manifest_elements(data):
+ """[(tag, {attr: (type, data, raw string or None)})] for each start tag."""
+ if len(data) < 8 or struct.unpack_from(' [(language, density, type, data)]
+ self.strings = []
+ if len(data) < 12 or struct.unpack_from('> 8, struct.unpack_from('= 2}),
+ 'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None,
+ 'icon_png': None,
+ }
+ try:
+ info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res))
+ except Exception: # noqa: BLE001 - any unreadable icon just means no icon
+ pass
+ return info
+
+
+def _icon_png(z, names, icons):
+ for icon in icons:
+ if icon.endswith('.png') and icon in names:
+ return z.read(icon)
+ # Adaptive icons are XML; fall back to the largest launcher PNG.
+ pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
+ key=lambda n: z.getinfo(n).file_size)
+ return z.read(pngs[-1]) if pngs else None
+
+
+if __name__ == '__main__':
+ import sys
+ for p in sys.argv[1:]:
+ i = apk_info(p)
+ i['icon_png'] = len(i['icon_png'] or b'')
+ print(p, i)
diff --git a/ui/frame_host.py b/ui/frame_host.py
index 3833e06..a60b82c 100644
--- a/ui/frame_host.py
+++ b/ui/frame_host.py
@@ -8,6 +8,7 @@ CLI (used by the Electron app, so terminal handling lives in one place):
import os
import shlex
import shutil
+import ssl
import subprocess
import sys
from pathlib import Path
@@ -74,15 +75,12 @@ def install_hint(tool):
"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."""
+ """Where the Android SDK usually lives, for adb."""
dirs = [os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")]
if MAC:
dirs += ["~/Library/Android/sdk", "/opt/homebrew/share/android-commandlinetools",
@@ -99,6 +97,11 @@ def 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"]
+ # The app bundles adb as a last resort: an adb you already use goes first, so
+ # two different adb versions don't keep restarting each other's server.
+ tools = os.environ.get("FRAME_CONTROL_TOOLS")
+ if tools:
+ extra.append(os.path.join(tools, exe))
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:
@@ -106,6 +109,27 @@ def adb():
return found
+def trust_bundled_cas():
+ """Trust the app's CA bundle for HTTPS as well as the system's certificates.
+
+ Python on Windows only sees the root certificates already in the Windows
+ store, and a fresh install fetches those lazily, so Steam and F-Droid can
+ fail with CERTIFICATE_VERIFY_FAILED. The app bundles curl's copy of Mozilla's
+ CA list (app/build/fetch-deps.js); outside the app this does nothing. Call it
+ before the first urlopen: urllib keeps the HTTPS context it builds then.
+ """
+ tools = os.environ.get("FRAME_CONTROL_TOOLS")
+ cafile = os.path.join(tools, "cacert.pem") if tools else None
+ if not cafile or not os.path.isfile(cafile):
+ return
+
+ def context(*args, **kwargs):
+ ctx = ssl.create_default_context(*args, **kwargs)
+ ctx.load_verify_locations(cafile)
+ return ctx
+ ssl._create_default_https_context = context # urllib's default for HTTPS
+
+
def open_path(path):
"""Show a folder or file in the file manager."""
path = str(path)
diff --git a/ui/index.html b/ui/index.html
index b242cf3..320088c 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -1069,7 +1069,14 @@ $("clipSend").onclick = () => {
if (!text) return toast("Nothing to send", true);
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
};
-$("clipMac").onclick = () => act($("clipMac").textContent, () => api("/api/clipboard", { fromComputer: true }), $("clipMac"));
+// In the app, Electron reads the clipboard; in a browser, the server does.
+async function sendComputerClipboard() {
+ if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
+ const text = await window.frameApp.readClipboard();
+ if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
+ return api("/api/clipboard", { text });
+}
+$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
// ---- file drop ----
const drop = $("drop");
diff --git a/ui/server.py b/ui/server.py
index 28a8f5f..60bbcef 100755
--- a/ui/server.py
+++ b/ui/server.py
@@ -28,8 +28,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
-# 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.
+# The app runs Python with -I, which leaves the script's own folder off
+# sys.path, so add it for the sibling modules below.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_android # noqa: E402
@@ -37,6 +37,8 @@ import frame_catalog # noqa: E402
import frame_host # noqa: E402
import frame_store # noqa: E402
+frame_host.trust_bundled_cas()
+
HERE = Path(__file__).resolve().parent
FRAME = os.environ.get("FRAME_ALIAS", "frame")
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):