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) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 16:42:49 +10:00
1 parent 6fd35a0a78
commit 1a0e54d8bd
16 files changed
+559 -150

No files matched your search

+3 -3
View File
@@ -31,10 +31,10 @@ jobs:
- name: Server tests - name: Server tests
run: python -m unittest discover -s tests -v run: python -m unittest discover -s tests -v
- name: App syntax - name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && 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 # The server runs on each desktop OS the app ships for, on the Python version
# Python version the app bundles (app/build/fetch-python.js). # the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one.
server-tests: server-tests:
strategy: strategy:
fail-fast: false fail-fast: false
+8 -9
View File
@@ -92,14 +92,14 @@ already ships. [How each feature works](docs/frame-control.md).
| | Download | Needs | | | 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`) | | **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: Python is bundled, and SSH is built into Windows | | **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) | `python3` and `ssh` (most desktops have both) | | **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) | same | | **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 The app brings its own Python and `adb`; SSH is built into macOS and Windows.
([macOS](https://formulae.brew.sh/formula/android-platform-tools) `brew install android-platform-tools` · Google doesn't publish `adb` for arm64 Linux, so that build uses your
Windows `winget install Google.PlatformTools` · Linux `sudo apt install adb`). distribution's. If you already have `adb`, the app uses yours.
<details> <details>
<summary><b>macOS: the app isn't notarized</b></summary> <summary><b>macOS: the app isn't notarized</b></summary>
@@ -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`), If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
or run it with `--appimage-extract-and-run`. Sending the clipboard needs or run it with `--appimage-extract-and-run`.
`wl-clipboard` (Wayland) or `xclip` (X11).
</details> </details>
## Set up the headset ## Set up the headset
+1 -1
View File
@@ -1,3 +1,3 @@
node_modules/ node_modules/
dist/ dist/
build/python-win/ build/deps/
+118
View File
@@ -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/<os>-<arch>/{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 <mac|win|linux> <arch>...");
for (const arch of archs) await fetch(os, arch);
})().catch((e) => { console.error(e.message); process.exit(1); });
-49
View File
@@ -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); });
+25 -15
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on // 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 // 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. // 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 { execFile, spawn } = require("child_process");
const { promisify } = require("util"); const { promisify } = require("util");
const fs = require("fs"); const fs = require("fs");
@@ -17,6 +17,7 @@ const IS_WIN = process.platform === "win32";
// Packaged: <resources>/{ui,scripts,python}. Dev: the repo checkout. // Packaged: <resources>/{ui,scripts,python}. Dev: the repo checkout.
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, ".."); const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
const TOOLS = path.join(ROOT, "tools"); // bundled adb
const SERVER = path.join(ROOT, "ui", "server.py"); const SERVER = path.join(ROOT, "ui", "server.py");
const SCRIPTS = path.join(ROOT, "scripts"); const SCRIPTS = path.join(ROOT, "scripts");
const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control") 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+). // 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) { async function findPython(env) {
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"]; const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
const candidates = []; // The packaged app bundles Python (app/build/fetch-deps.js); a checkout uses PATH.
if (IS_WIN) candidates.push(path.join(ROOT, "python", "python.exe")); const candidates = [path.join(ROOT, "python", ...(IS_WIN ? ["python.exe"] : ["bin", "python3"]))];
for (const dir of env.PATH.split(path.delimiter)) { for (const dir of env.PATH.split(path.delimiter)) {
// The WindowsApps "python.exe" is a stub that opens the Microsoft Store. // The WindowsApps "python.exe" is a stub that opens the Microsoft Store.
if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue; if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue;
@@ -67,7 +74,7 @@ async function findPython(env) {
try { try {
fs.accessSync(p, fs.constants.X_OK); fs.accessSync(p, fs.constants.X_OK);
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed. // /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
await run(p, ["-c", "import http.server, 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 }); { timeout: 10000, env, windowsHide: true });
return p; return p;
} catch {} } catch {}
@@ -79,10 +86,8 @@ async function hasSsh(env) {
try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; } try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; }
} }
const PYTHON_HELP = IS_MAC const PYTHON_HELP = app.isPackaged ? "The bundled Python is missing; reinstall Frame Control."
? "Install the Xcode Command Line Tools (xcode-select --install) or Homebrew's python, then reopen the app." : "Install Python 3.8 or later, then reopen the app.";
: IS_WIN ? "The bundled Python is missing; reinstall Frame Control."
: "Install Python 3.8 or later from your distribution (e.g. sudo apt install python3), then reopen the app.";
const SSH_HELP = IS_WIN const SSH_HELP = IS_WIN
? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client." ? "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)."; : "Install the OpenSSH client (e.g. sudo apt install openssh-client).";
@@ -108,8 +113,8 @@ function ping(target) {
} }
async function startServer() { async function startServer() {
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1", const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" }; ...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env); python = await findPython(env);
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`); 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}`); 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"); const log = fs.openSync(LOG, "a");
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`); fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
// stdin stays open while the app runs; the server exits cleanly when it closes. // 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, [...PY_FLAGS, SERVER, "--port", String(port), "--exit-on-eof"],
const child = spawn(python, ["-X", "utf8", SERVER, "--port", String(port), "--exit-on-eof"],
{ env, stdio: ["pipe", log, log], windowsHide: true }); { env, stdio: ["pipe", log, log], windowsHide: true });
child.stdin.on("error", () => {}); child.stdin.on("error", () => {});
fs.closeSync(log); fs.closeSync(log);
@@ -229,13 +233,19 @@ async function firstRunCheck() {
if (response === 0) setUpConnection(); 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() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560, width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false, title: "Frame Control", backgroundColor: BG, show: false,
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } } ...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
: { icon: path.join(__dirname, "build", "icon.png") }), : { 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()); win.once("ready-to-show", () => win.show());
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS)); if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
@@ -259,7 +269,7 @@ async function runInTerminal(argv) {
const env = { ...process.env, PATH: await loginPath() }; const env = { ...process.env, PATH: await loginPath() };
const py = python || await findPython(env); const py = python || await findPython(env);
if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`); 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 }); { env, timeout: 15000, windowsHide: true });
} catch (err) { } catch (err) {
dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim()); 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}`; const alias = `FRAME_ALIAS=${FRAME}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]); if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() }); 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. // A new console inherits our environment on Windows; Linux terminals may not.
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]); runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "frame-control", "name": "frame-control",
"version": "0.3.0", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frame-control", "name": "frame-control",
"version": "0.3.0", "version": "0.3.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
+21 -16
View File
@@ -1,7 +1,7 @@
{ {
"name": "frame-control", "name": "frame-control",
"productName": "Frame Control", "productName": "Frame Control",
"version": "0.3.0", "version": "0.3.1",
"description": "Desktop app for managing a Valve Steam Frame over SSH", "description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
@@ -9,10 +9,10 @@
"scripts": { "scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .", "start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js", "icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "electron-builder --mac --arm64 --publish never", "dist": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "electron-builder --mac --arm64 --dir", "dist:dir": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "electron-builder --linux --x64 --arm64 --publish never", "dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-python.js && electron-builder --win --x64 --publish never" "dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
}, },
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
@@ -27,6 +27,7 @@
}, },
"files": [ "files": [
"main.js", "main.js",
"preload.js",
"package.json", "package.json",
"build/icon.png" "build/icon.png"
], ],
@@ -62,6 +63,20 @@
"pins.json", "pins.json",
"site/apps.js" "site/apps.js"
] ]
},
{
"from": "build/deps/${os}-${arch}/python",
"to": "python",
"filter": [
"**/*"
]
},
{
"from": "build/deps/${os}-${arch}/tools",
"to": "tools",
"filter": [
"**/*"
]
} }
], ],
"mac": { "mac": {
@@ -105,7 +120,6 @@
}, },
"deb": { "deb": {
"depends": [ "depends": [
"python3",
"openssh-client" "openssh-client"
] ]
}, },
@@ -115,16 +129,7 @@
"zip" "zip"
], ],
"icon": "build/icon.png", "icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}", "artifactName": "Frame-Control-win-${arch}.${ext}"
"extraResources": [
{
"from": "build/python-win",
"to": "python",
"filter": [
"**/*"
]
}
]
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
+7
View File
@@ -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"),
});
+2 -1
View File
@@ -18,7 +18,8 @@ python3 ui/frame_android.py list|launch|stop|remove|probe <package>
Each APK becomes its own app, the way T3 Code is set up (see the instance Each APK becomes its own app, the way T3 Code is set up (see the instance
section below), instead of going into Lepton Development: 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. API > 30 or have no `arm64-v8a` build are refused.
2. The APK, `frame/android/lepton-app.sh` (as `launch.sh`), `instance.id`, 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 `meta.json`, the icon and the `lepton-show-flatscreen` marker go to
+15 -12
View File
@@ -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 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 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 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 for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
`aapt2` for reading APK files. `adb`, or yours if you have one.
- **Android display**: pick a running Lepton instance (by the app in it) and set - **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 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 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 `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 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 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 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 with a non-local `Host` header, and any `/api/` request without a custom
@@ -95,8 +98,8 @@ separately.
## Per-platform notes ## Per-platform notes
**macOS.** The app reads `PATH` from your login shell, so Homebrew's `rsync`, **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 and `adb` are used when you launch it from Finder. Set Up Connection runs
`scripts/connect.sh` in Terminal. The log is at `scripts/connect.sh` in Terminal. The log is at
`~/Library/Logs/Frame Control/server.log`. The build is ad-hoc signed and not `~/Library/Logs/Frame Control/server.log`. The build is ad-hoc signed and not
notarized: a downloaded copy is quarantined until you run 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 time you use them, macOS asks to allow local network access (for SSH) and
control of Terminal (for SSH and power actions). 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 (Settings → System → Optional features, if it's been removed). Set Up
Connection runs `ui/frame_connect.py` in a console window. Copies use `scp` 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 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 warns on first run: choose **More info → Run anyway**. The log is at
`%APPDATA%\Frame Control\logs\server.log`. `%APPDATA%\Frame Control\logs\server.log`.
**Linux.** Needs `python3` (3.8 or later) and `ssh`, which most desktops **Linux.** Needs `ssh`, which most desktops have; the `.deb` pulls it in.
have. The AppImage runs anywhere; the `.deb` pulls both in on Debian and The arm64 build also needs your distribution's `adb` for Android apps, because
Ubuntu. Set Up Connection runs `ui/frame_connect.py` in your terminal emulator Google publishes no arm64 Linux platform-tools. Set Up Connection runs
(GNOME Terminal, Konsole, xterm and others). Sending the clipboard needs `ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
`wl-clipboard` (Wayland) or `xclip` (X11). The log is at and others). The log is at
`~/.config/Frame Control/logs/server.log`. `~/.config/Frame Control/logs/server.log`.
## Building ## Building
@@ -125,7 +128,7 @@ cd app
npm install npm install
npm start # run from the checkout without packaging npm start # run from the checkout without packaging
npm run dist # macOS: dist/*.dmg and .zip (Apple Silicon) 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 npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
``` ```
+121
View File
@@ -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('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
data += b'\0' * (-len(data) % 4)
start = 28 + 4 * len(strings)
body = struct.pack(f'<{len(strings)}I', *offsets) + data
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
def manifest(package, label_ref, version_ref, min_sdk):
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>."""
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
'application', package]
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
def element(name, attrs):
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
for aname, raw, dtype, value in attrs:
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
none = 0xffffffff
chunks = (pool(strings) + resmap
+ element(5, [(4, 8, frame_apk.T_STRING, 8), (2, none, frame_apk.T_REF, version_ref)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]))
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
def resources(values):
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
pkg_body = b''
for (tid, lang, density), entries in values.items():
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
count = max(entries) + 1
offsets, data = [], b''
for i in range(count):
if i in entries:
offsets.append(len(data))
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
else:
offsets.append(0xffffffff)
header = 20 + 64
estart = header + 4 * count
body = struct.pack(f'<{count}I', *offsets) + data
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
pkg = pkg_header + pkg_body
table = pool(strings, utf8=True) + pkg
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
def apk(files):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
for name, data in files.items():
z.writestr(name, data)
return buf.getvalue()
class ApkInfo(unittest.TestCase):
def read(self, data):
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, 'app.apk')
with open(p, 'wb') as f:
f.write(data)
return frame_apk.apk_info(p)
def test_resolves_references(self):
# string type 1: label (entry 0), version (entry 1); mipmap type 2: icon at three densities.
arsc = resources({(1, 'fr', 0): {0: 0}, (1, '', 0): {0: 1, 1: 2},
(2, '', 160): {0: 3}, (2, '', 640): {0: 4}, (2, '', 0xfffe): {0: 5}})
info = self.read(apk({
'AndroidManifest.xml': manifest('com.example.demo', 0x7f010000, 0x7f010001, 26),
'resources.arsc': arsc,
'res/icon_lo.png': b'lo', 'res/icon_hi.png': b'hi', 'res/icon.xml': b'<xml/>',
'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'<manifest/>'})):
with self.assertRaises(frame_apk.ApkError):
self.read(data)
if __name__ == '__main__':
unittest.main()
+5 -37
View File
@@ -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} 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 import frame_host
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -56,45 +57,12 @@ def game_id(shortcut_appid):
return (int(shortcut_appid) << 32) | 0x02000000 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): def apk_info(path):
"""Package, label, version, native ABIs and the best PNG icon inside the APK.""" """Package, label, version, native ABIs and the best PNG icon inside the APK."""
tool = aapt2()
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: try:
z = zipfile.ZipFile(path) return frame_apk.apk_info(path)
except (zipfile.BadZipFile, OSError) as e: except frame_apk.ApkError as e:
raise FrameError(f'not a readable APK: {e}') raise FrameError(f'{os.path.basename(path)}: {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
def check_installable(info): def check_installable(info):
+217
View File
@@ -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('<HHIIIII', buf, off)
utf8 = flags & 0x100
offsets = struct.unpack_from(f'<{count}I', buf, off + hsize)
base = off + start
out = []
for o in offsets:
p = base + o
if utf8:
for _ in range(2): # UTF-16 length, then UTF-8 byte length
n = buf[p]
if n & 0x80:
n = ((n & 0x7f) << 8) | buf[p + 1]
p += 2
else:
p += 1
out.append(buf[p:p + n].decode('utf-8', 'replace'))
else:
n, = struct.unpack_from('<H', buf, p)
p += 2
if n & 0x8000:
n = ((n & 0x7fff) << 16) | struct.unpack_from('<H', buf, p)[0]
p += 2
out.append(buf[p:p + 2 * n].decode('utf-16-le', 'replace'))
return out
def _chunks(buf, off, end):
while off + 8 <= end:
ctype, hsize, size = struct.unpack_from('<HHI', buf, off)
if size < 8 or off + size > 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('<H', data, 0)[0] != 0x0003:
raise ApkError('AndroidManifest.xml is not binary XML')
strings, resmap, out = [], [], []
for ctype, hsize, off, size in _chunks(data, 8, len(data)):
if ctype == 0x0001:
strings = _string_pool(data, off)
elif ctype == 0x0180:
resmap = struct.unpack_from(f'<{(size - hsize) // 4}I', data, off + hsize)
elif ctype == 0x0102:
name, astart, asize, count = struct.unpack_from('<4xIHHH', data, off + hsize)
attrs = {}
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)
out.append((strings[name] if name < len(strings) else '', attrs))
return out
class Resources:
"""Just enough of resources.arsc to resolve a reference to its values."""
def __init__(self, data):
self.entries = {} # resid -> [(language, density, type, data)]
self.strings = []
if len(data) < 12 or struct.unpack_from('<H', data, 0)[0] != 0x0002:
return
hsize, = struct.unpack_from('<H', data, 2)
for ctype, _, off, size in _chunks(data, hsize, len(data)):
if ctype == 0x0001 and not self.strings:
self.strings = _string_pool(data, off)
elif ctype == 0x0200:
self._package(data, off, size)
def _package(self, data, off, size):
pid, = struct.unpack_from('<I', data, off + 8)
phsize, = struct.unpack_from('<H', data, off + 2)
for ctype, thsize, t, tsize in _chunks(data, off + phsize, off + size):
if ctype != 0x0201:
continue
tid, flags, count, estart = struct.unpack_from('<BB2xII', data, t + 8)
cfg = t + 20
language = data[cfg + 8:cfg + 10].rstrip(b'\0').decode('latin-1')
density, = struct.unpack_from('<H', data, cfg + 14)
if flags & 0x01: # sparse: (entry index, offset / 4) pairs
pairs = [struct.unpack_from('<HH', data, t + thsize + 4 * i) for i in range(count)]
offsets = [(i, o * 4) for i, o in pairs]
elif flags & 0x02: # 16-bit offsets / 4
offsets = [(i, o * 4) for i, o in enumerate(struct.unpack_from(f'<{count}H', data, t + thsize))
if o != 0xffff]
else:
offsets = [(i, o) for i, o in enumerate(struct.unpack_from(f'<{count}I', data, t + thsize))
if o != 0xffffffff]
for index, o in offsets:
e = t + estart + o
esize, eflags = struct.unpack_from('<HH', data, e)
if eflags & 0x08: # compact entry: type in the flags' high byte
dtype, value = eflags >> 8, struct.unpack_from('<I', data, e + 4)[0]
elif eflags & 0x01: # bag (style, plural...): not a plain value
continue
else:
dtype, value = struct.unpack_from('<3xBI', data, e + esize)
resid = (pid << 24) | (tid << 16) | index
self.entries.setdefault(resid, []).append((language, density, dtype, value))
def values(self, resid, depth=0):
"""[(language, density, type, data)] with references followed."""
out = []
for lang, dens, dtype, value in self.entries.get(resid, []):
if dtype == T_REF and depth < 5:
out += [(lang or l2, dens or d2, t2, v2) for l2, d2, t2, v2 in self.values(value, depth + 1)]
else:
out.append((lang, dens, dtype, value))
return out
def string(self, dtype, value):
return self.strings[value] if dtype == T_STRING and value < len(self.strings) else None
def _text(attr, res):
"""An attribute's string value, preferring the default then English resource."""
if not attr:
return None
dtype, value, raw = attr
if raw is not None:
return raw
if dtype in (T_INT_DEC, T_INT_HEX):
return str(value)
if dtype == T_REF:
vals = [(lang, res.string(t, v)) for lang, _, t, v in res.values(value)]
vals = [(lang, s) for lang, s in vals if s is not None]
for want in ('', 'en'):
for lang, s in vals:
if lang == want:
return s
return vals[0][1] if vals else None
return None
def _icons(attr, res):
"""Icon file paths, largest density first."""
if not attr or attr[0] != T_REF:
return []
vals = [(0 if d in (0xfffe, 0xffff) else d, res.string(t, v)) for _, d, t, v in res.values(attr[1])]
return [s for _, s in sorted(vals, key=lambda x: -x[0]) if s]
def apk_info(path):
"""Package, label, version, min_sdk, abis and the best PNG icon inside the APK."""
try:
z = zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError) as e:
raise ApkError(f'not a readable APK: {e}')
with z:
names = set(z.namelist())
if 'AndroidManifest.xml' not in names:
raise ApkError('not an APK: no AndroidManifest.xml')
try:
elements = manifest_elements(z.read('AndroidManifest.xml'))
res = Resources(z.read('resources.arsc') if 'resources.arsc' in names else b'')
except (struct.error, IndexError, zipfile.BadZipFile) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
for tag, attrs in elements:
tags.setdefault(tag, attrs)
manifest, app, sdk = tags.get('manifest', {}), tags.get('application', {}), tags.get('uses-sdk', {})
package = _text(manifest.get('package'), res)
if not package:
raise ApkError('the APK manifest has no package name')
min_sdk = sdk.get('minSdkVersion')
info = {
'package': package,
'version': _text(manifest.get('versionName'), res) or '',
'label': _text(app.get('label'), res) or package,
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 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)
+6 -4
View File
@@ -74,15 +74,12 @@ def install_hint(tool):
"adb": {"mac": "brew install android-platform-tools", "adb": {"mac": "brew install android-platform-tools",
"win": "winget install Google.PlatformTools", "win": "winget install Google.PlatformTools",
"linux": "install your distribution's adb package (e.g. sudo apt install adb)"}, "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"] return hints[tool]["mac" if MAC else "win" if WINDOWS else "linux"]
def android_sdk_dirs(): 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")] dirs = [os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")]
if MAC: if MAC:
dirs += ["~/Library/Android/sdk", "/opt/homebrew/share/android-commandlinetools", 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()] extra = [os.path.join(d, "platform-tools", exe) for d in android_sdk_dirs()]
if MAC: if MAC:
extra += ["/opt/homebrew/bin/adb", str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"] 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") env = os.environ.get("ADB")
found = (env if env and os.access(env, os.X_OK) else None) or which("adb", *extra) found = (env if env and os.access(env, os.X_OK) else None) or which("adb", *extra)
if not found: if not found:
+8 -1
View File
@@ -1069,7 +1069,14 @@ $("clipSend").onclick = () => {
if (!text) return toast("Nothing to send", true); if (!text) return toast("Nothing to send", true);
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend")); act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
}; };
$("clipMac").onclick = () => act($("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 ---- // ---- file drop ----
const drop = $("drop"); const drop = $("drop");