mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +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
|
- 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
|
||||||
|
|||||||
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
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
|
// 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]);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
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
@@ -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",
|
"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
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user