From 1a0e54d8bd4e6e2cfa0c2d9a98aa02d095ed0ddb Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:42:49 +1000 Subject: [PATCH 1/4] Bundle Python and adb so the app needs nothing installed - app/build/fetch-deps.js downloads a standalone Python 3.12 (python-build-standalone) for every build and adb from Google's platform-tools, pinned by SHA-256, and prunes what the server never uses. It replaces the Windows-only embeddable Python. - The app runs the bundled Python with -I -u -B -X utf8, so a PYTHONHOME or PYTHONPATH meant for another Python can't break it and nothing is written inside the signed macOS bundle. - An adb you already have still goes first, so two adb versions don't keep restarting each other's server. arm64 Linux has no official platform-tools and keeps using the system adb. - ui/frame_apk.py reads APK badging (package, label, version, min SDK, ABIs, icon) from the binary manifest and resources.arsc, replacing aapt2. It matches aapt2 on nine F-Droid APKs and finds launcher icons the old path missed with adaptive icons. - The app reads the computer's clipboard through Electron, so Linux no longer needs wl-clipboard or xclip. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/checks.yml | 6 +- README.md | 17 ++- app/.gitignore | 2 +- app/build/fetch-deps.js | 118 +++++++++++++++++++ app/build/fetch-python.js | 49 -------- app/main.js | 40 ++++--- app/package-lock.json | 4 +- app/package.json | 37 +++--- app/preload.js | 7 ++ docs/apks.md | 3 +- docs/frame-control.md | 27 +++-- tests/test_frame_apk.py | 121 +++++++++++++++++++ ui/frame_android.py | 42 +------ ui/frame_apk.py | 217 +++++++++++++++++++++++++++++++++++ ui/frame_host.py | 10 +- ui/index.html | 9 +- 16 files changed, 559 insertions(+), 150 deletions(-) create mode 100644 app/build/fetch-deps.js delete mode 100644 app/build/fetch-python.js create mode 100644 app/preload.js create mode 100644 tests/test_frame_apk.py create mode 100644 ui/frame_apk.py 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..3a53e2f --- /dev/null +++ b/app/build/fetch-deps.js @@ -0,0 +1,118 @@ +// Downloads what the app bundles so users install nothing else: a standalone +// Python (python-build-standalone) and adb (Android platform-tools). 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"]], +}; + +// 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"] + .flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]), +]; + +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 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, "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}`; + 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 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 }); + } + 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..1bfdc48 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 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,19 @@ async function firstRunCheck() { if (response === 0) setUpConnection(); } +ipcMain.handle("clipboard:read", (e) => { + if (!win || e.sender !== win.webContents || !url || new URL(e.senderFrame.url).origin !== new URL(url).origin) 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 +269,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 +280,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..69374f6 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,10 @@ 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. +`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 +98,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,18 +107,18 @@ 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 +**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`. ## Building @@ -125,7 +128,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..315b4c0 --- /dev/null +++ b/tests/test_frame_apk.py @@ -0,0 +1,121 @@ +"""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('.""" + strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk', + 'application', package] + 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_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..9c7b5bc --- /dev/null +++ b/ui/frame_apk.py @@ -0,0 +1,217 @@ +"""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, + } + for icon in _icons(app.get('icon'), res): + 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 + + +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..d3acb7c 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -74,15 +74,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 +96,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: 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"); From 770f26c703f2b8b3c208cb4122f58d5e0353c678 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:12:32 +1000 Subject: [PATCH 2/4] Bundle a CA list so HTTPS works from Python on fresh Windows The bundled Python only trusts roots already in the Windows certificate store, which Windows fills lazily, so on a new install Steam store search, F-Droid downloads and the compat DB failed with CERTIFICATE_VERIFY_FAILED. fetch-deps.js now also bundles curl's pinned copy of Mozilla's CA list, and the server adds it to the default HTTPS context on top of the system certificates (before any urlopen, since urllib keeps the context it first builds). Co-Authored-By: Claude Opus 5.5 (1M context) --- app/build/fetch-deps.js | 14 ++++++++++---- app/main.js | 2 +- docs/frame-control.md | 4 +++- ui/frame_host.py | 22 ++++++++++++++++++++++ ui/server.py | 6 ++++-- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js index 3a53e2f..1a52028 100644 --- a/app/build/fetch-deps.js +++ b/app/build/fetch-deps.js @@ -1,7 +1,7 @@ // Downloads what the app bundles so users install nothing else: a standalone -// Python (python-build-standalone) and adb (Android platform-tools). Each goes in -// build/deps/-/{python,tools}, which package.json copies into the app's -// resources. Everything is pinned by version and SHA-256. +// 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"); @@ -29,6 +29,11 @@ const TOOLS = { 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", @@ -79,7 +84,7 @@ async function fetch(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}`; + 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; @@ -107,6 +112,7 @@ async function fetch(os, arch) { 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}`); } diff --git a/app/main.js b/app/main.js index 1bfdc48..5df9f53 100644 --- a/app/main.js +++ b/app/main.js @@ -17,7 +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 +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") diff --git a/docs/frame-control.md b/docs/frame-control.md index 69374f6..c97cb8e 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -72,7 +72,9 @@ 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/`, 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. +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 diff --git a/ui/frame_host.py b/ui/frame_host.py index d3acb7c..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 @@ -108,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/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): From d145537d9ac02434a39166d7994cf02e6ba18ac1 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:44:40 +1000 Subject: [PATCH 3/4] Harden the APK reader and build downloads after review - frame_apk: android: attributes win over same-named attributes in other namespaces; string attributes that keep only a typed value (no raw string) still resolve; a failed icon read leaves the icon out instead of failing the install. - The clipboard IPC origin check can't throw on odd frame URLs. - fetch-deps.js: 60 s download timeout, at most 5 redirects, a SystemRoot fallback for tar.exe, and prunes pydoc_data, venv and the static libpython. - Docs keep the clipboard-tool note for running the UI in a browser. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/build/fetch-deps.js | 19 +++++++++++++------ app/main.js | 5 ++++- docs/frame-control.md | 4 +++- tests/test_frame_apk.py | 28 +++++++++++++++++++++++----- ui/frame_apk.py | 34 ++++++++++++++++++++++------------ 5 files changed, 65 insertions(+), 25 deletions(-) diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js index 1a52028..89690ba 100644 --- a/app/build/fetch-deps.js +++ b/app/build/fetch-deps.js @@ -39,22 +39,23 @@ 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"] + ...["test", "idlelib", "tkinter", "turtledemo", "ensurepip", "lib2to3", "site-packages/pip", "pydoc_data", "venv"] .flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]), ]; -function get(url) { +function get(url, redirects = 5) { return new Promise((resolve, reject) => { - https.get(url, (res) => { + https.get(url, { timeout: 60000 }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); - return resolve(get(res.headers.location)); + if (!redirects) return reject(new Error(`${url}: too many redirects`)); + return resolve(get(new URL(res.headers.location, url).href, 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("error", reject); + }).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject); }); } @@ -66,7 +67,7 @@ async function download(url, sha256, file) { } // 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, "System32", "tar.exe") : "tar"; +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 }); @@ -97,6 +98,12 @@ async function fetch(os, arch) { 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); diff --git a/app/main.js b/app/main.js index 5df9f53..37e71f9 100644 --- a/app/main.js +++ b/app/main.js @@ -234,7 +234,10 @@ async function firstRunCheck() { } ipcMain.handle("clipboard:read", (e) => { - if (!win || e.sender !== win.webContents || !url || new URL(e.senderFrame.url).origin !== new URL(url).origin) return ""; + 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(); }); diff --git a/docs/frame-control.md b/docs/frame-control.md index c97cb8e..43f37a1 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -121,7 +121,9 @@ 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`. +`~/.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 diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py index 315b4c0..f9c008b 100644 --- a/tests/test_frame_apk.py +++ b/tests/test_frame_apk.py @@ -27,10 +27,14 @@ def pool(strings, utf8=False): return struct.pack('.""" +def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False): + """. + + 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] + 'application', package, 'junk', 'label'] # the second 'label' has no android id resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c) resmap = struct.pack(''})): with self.assertRaises(frame_apk.ApkError): diff --git a/ui/frame_apk.py b/ui/frame_apk.py index 9c7b5bc..6ad8536 100644 --- a/ui/frame_apk.py +++ b/ui/frame_apk.py @@ -71,9 +71,14 @@ def manifest_elements(data): for i in range(count): a = off + hsize + astart + i * asize aname, raw, dtype, value = struct.unpack_from('<4xII3xBI', data, a) - key = ATTR.get(resmap[aname]) if aname < len(resmap) else None - key = key or (strings[aname] if aname < len(strings) else '') - attrs[key] = (dtype, value, strings[raw] if raw < len(strings) else None) + raw = strings[raw] if raw < len(strings) else None + if raw is None and dtype == T_STRING and value < len(strings): + raw = strings[value] # some repackers keep only the typed value + android = ATTR.get(resmap[aname]) if aname < len(resmap) else None + if android: # android: attributes win over same-named ones in other namespaces + attrs[android] = (dtype, value, raw) + else: + attrs.setdefault(strings[aname] if aname < len(strings) else '', (dtype, value, raw)) out.append((strings[name] if name < len(strings) else '', attrs)) return out @@ -197,18 +202,23 @@ def apk_info(path): 'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None, 'icon_png': None, } - for icon in _icons(app.get('icon'), res): - 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]) + try: + info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res)) + except (zipfile.BadZipFile, RuntimeError, OSError): + pass # a missing icon shouldn't stop the install 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:]: From 047862606117d839c4c46bdf8d4f47b1149e99bb Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:55:41 +1000 Subject: [PATCH 4/4] Reject bad redirects cleanly and ignore any icon read error Co-Authored-By: Claude Opus 5.5 (1M context) --- app/build/fetch-deps.js | 5 ++++- ui/frame_apk.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js index 89690ba..8460b06 100644 --- a/app/build/fetch-deps.js +++ b/app/build/fetch-deps.js @@ -49,7 +49,10 @@ function get(url, redirects = 5) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); if (!redirects) return reject(new Error(`${url}: too many redirects`)); - return resolve(get(new URL(res.headers.location, url).href, redirects - 1)); + 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 = []; diff --git a/ui/frame_apk.py b/ui/frame_apk.py index 6ad8536..53fd58a 100644 --- a/ui/frame_apk.py +++ b/ui/frame_apk.py @@ -204,8 +204,8 @@ def apk_info(path): } try: info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res)) - except (zipfile.BadZipFile, RuntimeError, OSError): - pass # a missing icon shouldn't stop the install + except Exception: # noqa: BLE001 - any unreadable icon just means no icon + pass return info