diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js index 3a53e2f..1a52028 100644 --- a/app/build/fetch-deps.js +++ b/app/build/fetch-deps.js @@ -1,7 +1,7 @@ // 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/-/{python,tools}, which package.json copies into the app's -// resources. Everything is pinned by version and SHA-256. +// 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"); @@ -29,6 +29,11 @@ const TOOLS = { 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", @@ -79,7 +84,7 @@ async function fetch(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}`; + 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; @@ -107,6 +112,7 @@ async function fetch(os, arch) { 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}`); } diff --git a/app/main.js b/app/main.js index 1bfdc48..5df9f53 100644 --- a/app/main.js +++ b/app/main.js @@ -17,7 +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 +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") diff --git a/docs/frame-control.md b/docs/frame-control.md index 69374f6..c97cb8e 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -72,7 +72,9 @@ 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/`, 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. +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 diff --git a/ui/frame_host.py b/ui/frame_host.py index d3acb7c..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 @@ -108,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/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):