Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion apps/desktop/launcher/tron-pwa
Original file line number Diff line number Diff line change
Expand Up @@ -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 <q> search pwamart.com, the app store for web apps
tron pwa install <slug|https-url>
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.
"""
Expand Down Expand Up @@ -601,6 +606,98 @@ 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.
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:
import json
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"))


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",
)
# Same validated store base as pwamart_get.
# threatcrush-disable-next-line py-ssrf-outbound-request
urllib.request.urlopen(req, timeout=5).read()
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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 <slug>")
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
Expand All @@ -616,7 +713,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


Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/test/pwa-install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// `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<string, string> = {}) => ({ PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', TRONBROWSER_CLI: CLI, ...extra });

function runAsync(args: string[], extra: Record<string, string>): 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('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) => {
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<void>((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();
}
});
});
5 changes: 4 additions & 1 deletion apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <q> Search pwamart.com, the app store for web apps
tron pwa install <slug|url>
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
Expand Down
Loading