mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 00:00:21 +02:00
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:
1 parent
6fd35a0a78
commit
1a0e54d8bd
16 files changed
+559
-150
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<details>
|
||||
<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`),
|
||||
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`.
|
||||
</details>
|
||||
|
||||
## Set up the headset
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/python-win/
|
||||
build/deps/
|
||||
@@ -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); });
|
||||
@@ -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
@@ -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: <resources>/{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]);
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+21
-16
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
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
|
||||
|
||||
+15
-12
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
+217
@@ -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
@@ -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:
|
||||
|
||||
+8
-1
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user