From d145537d9ac02434a39166d7994cf02e6ba18ac1 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:44:40 +1000 Subject: [PATCH 1/2] Harden the APK reader and build downloads after review - frame_apk: android: attributes win over same-named attributes in other namespaces; string attributes that keep only a typed value (no raw string) still resolve; a failed icon read leaves the icon out instead of failing the install. - The clipboard IPC origin check can't throw on odd frame URLs. - fetch-deps.js: 60 s download timeout, at most 5 redirects, a SystemRoot fallback for tar.exe, and prunes pydoc_data, venv and the static libpython. - Docs keep the clipboard-tool note for running the UI in a browser. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/build/fetch-deps.js | 19 +++++++++++++------ app/main.js | 5 ++++- docs/frame-control.md | 4 +++- tests/test_frame_apk.py | 28 +++++++++++++++++++++++----- ui/frame_apk.py | 34 ++++++++++++++++++++++------------ 5 files changed, 65 insertions(+), 25 deletions(-) diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js index 1a52028..89690ba 100644 --- a/app/build/fetch-deps.js +++ b/app/build/fetch-deps.js @@ -39,22 +39,23 @@ const PRUNE = [ "include", "share", "Scripts", "libs", "tcl", "lib/pkgconfig", "lib/itcl4", "lib/tcl8", "lib/tcl8.6", "lib/tk8.6", "lib/thread2.8", "bin/idle3", "bin/idle3.12", "bin/pip", "bin/pip3", "bin/pip3.12", "bin/pydoc3", "bin/pydoc3.12", "bin/2to3", "bin/2to3-3.12", "bin/python3-config", "bin/python3.12-config", - ...["test", "idlelib", "tkinter", "turtledemo", "ensurepip", "lib2to3", "site-packages/pip"] + ...["test", "idlelib", "tkinter", "turtledemo", "ensurepip", "lib2to3", "site-packages/pip", "pydoc_data", "venv"] .flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]), ]; -function get(url) { +function get(url, redirects = 5) { return new Promise((resolve, reject) => { - https.get(url, (res) => { + https.get(url, { timeout: 60000 }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); - return resolve(get(res.headers.location)); + if (!redirects) return reject(new Error(`${url}: too many redirects`)); + return resolve(get(new URL(res.headers.location, url).href, redirects - 1)); } if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`)); const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => resolve(Buffer.concat(chunks))); - }).on("error", reject); + }).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject); }); } @@ -66,7 +67,7 @@ async function download(url, sha256, file) { } // Windows' own bsdtar: Git's GNU tar, often first on PATH, reads C:\ as a remote host. -const TAR = process.platform === "win32" ? path.join(process.env.SystemRoot, "System32", "tar.exe") : "tar"; +const TAR = process.platform === "win32" ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe") : "tar"; function extract(file, dir) { fs.mkdirSync(dir, { recursive: true }); @@ -97,6 +98,12 @@ async function fetch(os, arch) { await download(PY_URL(triple), pySha, tgz); extract(tgz, out); // unpacks to python/ for (const p of PRUNE) fs.rmSync(path.join(out, "python", p), { recursive: true, force: true }); + const stdlib = path.join(out, "python", "lib", "python3.12"); // macOS and Linux: drop the static libpython + if (fs.existsSync(stdlib)) { + for (const d of fs.readdirSync(stdlib)) { + if (d.startsWith("config-3.12")) fs.rmSync(path.join(stdlib, d), { recursive: true, force: true }); + } + } const tools = path.join(out, "tools"); fs.mkdirSync(tools); diff --git a/app/main.js b/app/main.js index 5df9f53..37e71f9 100644 --- a/app/main.js +++ b/app/main.js @@ -234,7 +234,10 @@ async function firstRunCheck() { } ipcMain.handle("clipboard:read", (e) => { - if (!win || e.sender !== win.webContents || !url || new URL(e.senderFrame.url).origin !== new URL(url).origin) return ""; + if (!win || e.sender !== win.webContents || !url) return ""; + try { + if (new URL(e.senderFrame.url).origin !== new URL(url).origin) return ""; + } catch { return ""; } return clipboard.readText(); }); diff --git a/docs/frame-control.md b/docs/frame-control.md index c97cb8e..43f37a1 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -121,7 +121,9 @@ The arm64 build also needs your distribution's `adb` for Android apps, because Google publishes no arm64 Linux platform-tools. Set Up Connection runs `ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm and others). The log is at -`~/.config/Frame Control/logs/server.log`. +`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser +instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or +`xclip` (X11). ## Building diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py index 315b4c0..f9c008b 100644 --- a/tests/test_frame_apk.py +++ b/tests/test_frame_apk.py @@ -27,10 +27,14 @@ def pool(strings, utf8=False): return struct.pack('.""" +def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False): + """. + + package_raw=False drops the package's raw string (as some repackers do); + foreign_label adds a non-android `label` attribute after android:label. + """ strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk', - 'application', package] + 'application', package, 'junk', 'label'] # the second 'label' has no android id resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c) resmap = struct.pack(''})): with self.assertRaises(frame_apk.ApkError): diff --git a/ui/frame_apk.py b/ui/frame_apk.py index 9c7b5bc..6ad8536 100644 --- a/ui/frame_apk.py +++ b/ui/frame_apk.py @@ -71,9 +71,14 @@ def manifest_elements(data): for i in range(count): a = off + hsize + astart + i * asize aname, raw, dtype, value = struct.unpack_from('<4xII3xBI', data, a) - key = ATTR.get(resmap[aname]) if aname < len(resmap) else None - key = key or (strings[aname] if aname < len(strings) else '') - attrs[key] = (dtype, value, strings[raw] if raw < len(strings) else None) + raw = strings[raw] if raw < len(strings) else None + if raw is None and dtype == T_STRING and value < len(strings): + raw = strings[value] # some repackers keep only the typed value + android = ATTR.get(resmap[aname]) if aname < len(resmap) else None + if android: # android: attributes win over same-named ones in other namespaces + attrs[android] = (dtype, value, raw) + else: + attrs.setdefault(strings[aname] if aname < len(strings) else '', (dtype, value, raw)) out.append((strings[name] if name < len(strings) else '', attrs)) return out @@ -197,18 +202,23 @@ def apk_info(path): 'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None, 'icon_png': None, } - for icon in _icons(app.get('icon'), res): - if icon.endswith('.png') and icon in names: - info['icon_png'] = z.read(icon) - break - else: # adaptive icons are XML; fall back to the largest launcher PNG - pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n), - key=lambda n: z.getinfo(n).file_size) - if pngs: - info['icon_png'] = z.read(pngs[-1]) + try: + info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res)) + except (zipfile.BadZipFile, RuntimeError, OSError): + pass # a missing icon shouldn't stop the install return info +def _icon_png(z, names, icons): + for icon in icons: + if icon.endswith('.png') and icon in names: + return z.read(icon) + # Adaptive icons are XML; fall back to the largest launcher PNG. + pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n), + key=lambda n: z.getinfo(n).file_size) + return z.read(pngs[-1]) if pngs else None + + if __name__ == '__main__': import sys for p in sys.argv[1:]: From 047862606117d839c4c46bdf8d4f47b1149e99bb Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:55:41 +1000 Subject: [PATCH 2/2] Reject bad redirects cleanly and ignore any icon read error Co-Authored-By: Claude Opus 5.5 (1M context) --- app/build/fetch-deps.js | 5 ++++- ui/frame_apk.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js index 89690ba..8460b06 100644 --- a/app/build/fetch-deps.js +++ b/app/build/fetch-deps.js @@ -49,7 +49,10 @@ function get(url, redirects = 5) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); if (!redirects) return reject(new Error(`${url}: too many redirects`)); - return resolve(get(new URL(res.headers.location, url).href, redirects - 1)); + let next; + try { next = new URL(res.headers.location, url).href; } + catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); } + return resolve(get(next, redirects - 1)); } if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`)); const chunks = []; diff --git a/ui/frame_apk.py b/ui/frame_apk.py index 6ad8536..53fd58a 100644 --- a/ui/frame_apk.py +++ b/ui/frame_apk.py @@ -204,8 +204,8 @@ def apk_info(path): } try: info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res)) - except (zipfile.BadZipFile, RuntimeError, OSError): - pass # a missing icon shouldn't stop the install + except Exception: # noqa: BLE001 - any unreadable icon just means no icon + pass return info