From a7a1862288d9d58674767cc6a3c44f2715ce00e2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 10:12:02 +0000 Subject: [PATCH 1/2] tron pwa search / install: pwamart.com listings as TronBrowser app windows tron pwa install resolves a pwamart.com listing to its start URL and opens it through the launcher with --app, so the window gets the extensions, GPU mode and class every other TronBrowser window has; the icon Install writes is then repointed by sync as before. Only https URLs launch; a dry run prints the command and counts nothing. PWAMART_URL points it at another store. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/launcher/tron-pwa | 84 ++++++++++++++++++++++++++- apps/desktop/test/pwa-install.test.ts | 66 +++++++++++++++++++++ apps/web/public/install.sh | 5 +- 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/test/pwa-install.test.ts diff --git a/apps/desktop/launcher/tron-pwa b/apps/desktop/launcher/tron-pwa index b285e96..f342536 100755 --- a/apps/desktop/launcher/tron-pwa +++ b/apps/desktop/launcher/tron-pwa @@ -31,6 +31,11 @@ shortcut from someone's real Chrome is left exactly as it is. tron pwa [list] show every web-app shortcut and who owns it tron pwa sync point TronBrowser's shortcuts at the launcher tron pwa revert hand them back to the engine binary (used by uninstall) + tron pwa search search pwamart.com, the app store for web apps + tron pwa install + open a pwamart listing (or any URL) as a TronBrowser app + window; Install in its address bar then keeps it, and the + icon it writes is repointed by sync like any other `--dry-run` reports what sync/revert would change without writing. """ @@ -601,6 +606,79 @@ def cmd_revert(apps_dir: str, dry_run: bool) -> int: return 0 +# pwamart.com is the app store for web apps. Its listings carry a start_url, and +# an install counted as "tron" tells publishers where installs come from. +PWAMART_URL = (os.environ.get("PWAMART_URL") or "https://pwamart.com").rstrip("/") + + +def pwamart_get(path: str) -> dict: + import json + import urllib.request + + req = urllib.request.Request(f"{PWAMART_URL}/api/v1{path}", headers={"user-agent": "tron-pwa"}) + with urllib.request.urlopen(req, timeout=15) as res: + return json.loads(res.read().decode("utf-8")) + + +def pwamart_count(slug: str) -> None: + import json + import urllib.request + + try: + req = urllib.request.Request( + f"{PWAMART_URL}/api/v1/apps/{slug}/installs", + data=json.dumps({"method": "tron"}).encode(), + headers={"content-type": "application/json", "user-agent": "tron-pwa"}, + method="POST", + ) + urllib.request.urlopen(req, timeout=5).read() + except Exception: + pass # a missed count must never block an install + + +def cmd_search(query: str) -> int: + import urllib.parse + + try: + r = pwamart_get("/apps?" + urllib.parse.urlencode({"q": query, "limit": 20})) + except Exception as e: + print(f"tron pwa: could not reach {PWAMART_URL}: {e}", file=sys.stderr) + return 1 + for a in r.get("apps", []): + print(f"{a['slug']:<26} {a['name'][:30]:<30} {a.get('category_name', '')}") + print(f"\n{r.get('total', 0)} app(s) on {PWAMART_URL} ยท tron pwa install ") + return 0 + + +def cmd_install(target: str, dry_run: bool) -> int: + import re + import urllib.parse + + if target.startswith("https://"): + url, slug = target, None + elif re.fullmatch(r"[a-z0-9][a-z0-9-]{0,58}[a-z0-9]", target or ""): + try: + app = pwamart_get(f"/apps/{urllib.parse.quote(target)}")["app"] + except Exception as e: + print(f"tron pwa: no app '{target}' on {PWAMART_URL} ({e})", file=sys.stderr) + return 1 + url, slug = app.get("start_url") or app["url"], target + else: + print("tron pwa install: give a pwamart slug or an https:// URL", file=sys.stderr) + return 2 + if not url.startswith("https://"): + print(f"tron pwa: refusing a non-https start URL: {url}", file=sys.stderr) + return 1 + cmd = [launcher_cli(), f"--app={url}"] + if dry_run: + print(" ".join(cmd)) + return 0 + if slug: + pwamart_count(slug) + os.execv(cmd[0], cmd) + return 0 + + def main(argv: list[str]) -> int: args = [a for a in argv if a != "--dry-run"] dry_run = "--dry-run" in argv @@ -616,7 +694,11 @@ def main(argv: list[str]) -> int: return cmd_sync(apps_dir, dry_run) if cmd == "revert": return cmd_revert(apps_dir, dry_run) - print(f"tron pwa: unknown command '{cmd}'. Try: list, sync, revert", file=sys.stderr) + if cmd == "search": + return cmd_search(" ".join(args[1:])) + if cmd == "install": + return cmd_install(args[1] if len(args) > 1 else "", dry_run) + print(f"tron pwa: unknown command '{cmd}'. Try: list, sync, revert, search, install", file=sys.stderr) return 2 diff --git a/apps/desktop/test/pwa-install.test.ts b/apps/desktop/test/pwa-install.test.ts new file mode 100644 index 0000000..3e206ab --- /dev/null +++ b/apps/desktop/test/pwa-install.test.ts @@ -0,0 +1,66 @@ +// `tron pwa install` resolves a pwamart.com listing to its start URL and opens it as +// a TronBrowser app window. Pinned here: it only ever launches https URLs, it goes +// through the launcher (never the engine), and a slug is looked up on the store. + +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const TRON_PWA = join(HERE, '..', 'launcher', 'tron-pwa'); +const CLI = '/opt/tron/bin/tron'; + +const env = (extra: Record = {}) => ({ PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', TRONBROWSER_CLI: CLI, ...extra }); + +function runAsync(args: string[], extra: Record): Promise<{ stdout: string; status: number }> { + return new Promise((resolve) => { + const child = spawn('python3', [TRON_PWA, ...args], { env: env(extra) }); + let stdout = ''; + child.stdout.on('data', (d) => (stdout += d)); + child.on('close', (status) => resolve({ stdout, status: status ?? -1 })); + }); +} + +describe('tron pwa install', () => { + it('opens an https URL as an app window through the launcher', () => { + const r = spawnSync('python3', [TRON_PWA, 'install', 'https://app.example/', '--dry-run'], { encoding: 'utf8', env: env() }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(`${CLI} --app=https://app.example/`); + }); + + it('refuses anything that is not a slug or an https URL', () => { + for (const bad of ['javascript:alert(1)', 'http://app.example/', '../etc/passwd', '']) { + const r = spawnSync('python3', [TRON_PWA, 'install', bad, '--dry-run'], { encoding: 'utf8', env: env() }); + expect(r.status).not.toBe(0); + expect(r.stdout).toBe(''); + } + }); + + it('looks a slug up on pwamart and launches its start_url', async () => { + const seen: string[] = []; + const server = createServer((req, res) => { + seen.push(`${req.method} ${req.url}`); + res.setHeader('content-type', 'application/json'); + if (req.url === '/api/v1/apps/notes') res.end(JSON.stringify({ app: { slug: 'notes', url: 'https://notes.example/', start_url: 'https://notes.example/?pwa' } })); + else if (req.url === '/api/v1/apps/evil') res.end(JSON.stringify({ app: { slug: 'evil', url: 'http://evil.example/', start_url: 'http://evil.example/' } })); + else res.writeHead(404).end('{}'); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const port = (server.address() as { port: number }).port; + try { + const ok = await runAsync(['install', 'notes', '--dry-run'], { PWAMART_URL: `http://127.0.0.1:${port}` }); + expect(ok.status).toBe(0); + expect(ok.stdout.trim()).toBe(`${CLI} --app=https://notes.example/?pwa`); + const evil = await runAsync(['install', 'evil', '--dry-run'], { PWAMART_URL: `http://127.0.0.1:${port}` }); + expect(evil.status).not.toBe(0); + const missing = await runAsync(['install', 'nope', '--dry-run'], { PWAMART_URL: `http://127.0.0.1:${port}` }); + expect(missing.status).not.toBe(0); + // A dry run never counts an install. + expect(seen.some((s) => s.startsWith('POST'))).toBe(false); + } finally { + server.close(); + } + }); +}); diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index eaf5060..707fc62 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -119,7 +119,10 @@ Usage: tron pwa sync Repoint their desktop icons at TronBrowser (use when an installed app dies from its icon but opens fine from the address bar) - tron doctor Check the engine, locks, databases and disk + tron pwa search Search pwamart.com, the app store for web apps + tron pwa install + Open a pwamart app (or any https URL) as an app window + tron doctor Check the engine, locks, databases and disk (use when "Something went wrong when opening your profile" appears; --json for machine output) tron repair Fix what doctor found, with the browser closed From 6372da9caaa9fcf16ca4f2db2640aad450d2703d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 10:16:04 +0000 Subject: [PATCH 2/2] tron pwa: only an https (or loopback) PWAMART_URL is used A hostile or mistyped PWAMART_URL could otherwise point the helper at internal URLs. The two store requests carry a ThreatCrush suppression naming why. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/launcher/tron-pwa | 21 ++++++++++++++++++++- apps/desktop/test/pwa-install.test.ts | 8 ++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/desktop/launcher/tron-pwa b/apps/desktop/launcher/tron-pwa index f342536..2612e93 100755 --- a/apps/desktop/launcher/tron-pwa +++ b/apps/desktop/launcher/tron-pwa @@ -608,7 +608,22 @@ def cmd_revert(apps_dir: str, dry_run: bool) -> int: # pwamart.com is the app store for web apps. Its listings carry a start_url, and # an install counted as "tron" tells publishers where installs come from. -PWAMART_URL = (os.environ.get("PWAMART_URL") or "https://pwamart.com").rstrip("/") +def _store_url() -> str: + """The store base. https only, except plain http on loopback (the tests' + stand-in store): a mistyped or hostile PWAMART_URL must not turn this helper + into something that fetches arbitrary internal URLs.""" + from urllib.parse import urlsplit + + raw = (os.environ.get("PWAMART_URL") or "https://pwamart.com").rstrip("/") + u = urlsplit(raw) + loopback = u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1") + if (u.scheme != "https" and not loopback) or not u.hostname or u.username or u.password: + print(f"tron pwa: ignoring PWAMART_URL={raw!r} (https only); using https://pwamart.com", file=sys.stderr) + return "https://pwamart.com" + return raw + + +PWAMART_URL = _store_url() def pwamart_get(path: str) -> dict: @@ -616,6 +631,8 @@ def pwamart_get(path: str) -> dict: import urllib.request req = urllib.request.Request(f"{PWAMART_URL}/api/v1{path}", headers={"user-agent": "tron-pwa"}) + # The base is the pwamart store, validated by _store_url (https, or loopback in tests). + # threatcrush-disable-next-line py-ssrf-outbound-request with urllib.request.urlopen(req, timeout=15) as res: return json.loads(res.read().decode("utf-8")) @@ -631,6 +648,8 @@ def pwamart_count(slug: str) -> None: headers={"content-type": "application/json", "user-agent": "tron-pwa"}, method="POST", ) + # Same validated store base as pwamart_get. + # threatcrush-disable-next-line py-ssrf-outbound-request urllib.request.urlopen(req, timeout=5).read() except Exception: pass # a missed count must never block an install diff --git a/apps/desktop/test/pwa-install.test.ts b/apps/desktop/test/pwa-install.test.ts index 3e206ab..f1d337c 100644 --- a/apps/desktop/test/pwa-install.test.ts +++ b/apps/desktop/test/pwa-install.test.ts @@ -38,6 +38,14 @@ describe('tron pwa install', () => { } }); + it('ignores a PWAMART_URL that is not https (or loopback http)', () => { + for (const bad of ['http://169.254.169.254', 'file:///etc', 'https://user:pw@store.example']) { + const r = spawnSync('python3', [TRON_PWA, 'install', 'https://app.example/', '--dry-run'], { encoding: 'utf8', env: env({ PWAMART_URL: bad }) }); + expect(r.stderr).toContain('ignoring PWAMART_URL'); + expect(r.stdout.trim()).toBe(`${CLI} --app=https://app.example/`); + } + }); + it('looks a slug up on pwamart and launches its start_url', async () => { const seen: string[] = []; const server = createServer((req, res) => {