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