Message of the Day
diff --git a/apps/desktop/extensions/ai-sidebar/newtab.js b/apps/desktop/extensions/ai-sidebar/newtab.js
index 6300f57..3025821 100644
--- a/apps/desktop/extensions/ai-sidebar/newtab.js
+++ b/apps/desktop/extensions/ai-sidebar/newtab.js
@@ -5,6 +5,7 @@ import { fetchMotd } from './motd.js';
import { getToken as btrToken, BTR_BASE } from './bittorrented.js';
import { fetchWithTimeout, storageGet } from './net.js';
import { w3bsUrl } from './w3bs.js';
+import { loadApps, host, DEFAULT_APPS } from './apps.js';
const el = (id) => document.getElementById(id);
@@ -406,6 +407,59 @@ window.addEventListener('message', (e) => {
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePlayer(); });
+// --- Apps: Profullstack's pwamart listings, one tap to install ---
+let installed = new Set();
+let hasHost = false;
+let allApps = [];
+
+function appTile(a) {
+ const on = installed.has(a.slug);
+ const label = on || !hasHost ? 'Open' : 'Install';
+ const icon = /^https:\/\//.test(a.icon)
+ ? `
`
+ : '';
+ return `` +
+ `${icon}${escapeHtml(a.name)}` +
+ `
`;
+}
+
+function drawApps() {
+ const q = el('apps-q').value.trim().toLowerCase();
+ const list = q ? allApps.filter((a) => (a.name + ' ' + a.slug + ' ' + a.summary).toLowerCase().includes(q)) : allApps;
+ el('apps').innerHTML = list.map(appTile).join('') || 'No apps match.
';
+}
+
+async function renderApps() {
+ allApps = await loadApps();
+ if (!allApps.length) return;
+ const r = await host({ cmd: 'list' });
+ hasHost = !!r.ok;
+ installed = new Set(r.ok ? r.installed : []);
+ el('apps-note').hidden = hasHost;
+ el('apps-note').textContent = hasHost ? '' : 'Installing needs the TronBrowser launcher on Linux; here apps open in their own window.';
+ el('apps-sec').hidden = false;
+ drawApps();
+}
+
+el('apps-q').addEventListener('input', drawApps);
+el('apps').addEventListener('click', async (e) => {
+ const b = e.target.closest('button[data-app]');
+ if (!b) return;
+ const slug = b.getAttribute('data-app');
+ const app = allApps.find((a) => a.slug === slug);
+ if (!app) return;
+ if (!hasHost) { chrome.windows.create({ url: app.start_url, type: 'popup', width: 420, height: 820 }); return; }
+ b.disabled = true;
+ const r = await host({ cmd: installed.has(slug) ? 'open' : 'add', slug });
+ if (r.ok && r.installed) installed = new Set(r.installed);
+ drawApps();
+ if (!r.ok) {
+ const again = el('apps').querySelector(`button[data-app="${slug}"]`);
+ if (again) { again.textContent = 'Retry'; again.title = r.error || 'failed'; }
+ }
+});
+
+renderApps();
renderMotd();
renderBtr();
renderFeeds();
diff --git a/apps/desktop/launcher/tron-pwa b/apps/desktop/launcher/tron-pwa
index 2612e93..848e2dd 100755
--- a/apps/desktop/launcher/tron-pwa
+++ b/apps/desktop/launcher/tron-pwa
@@ -36,6 +36,14 @@ shortcut from someone's real Chrome is left exactly as it is.
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
+ tron pwa add ...
+ put pwamart apps in the app drawer in one step: an icon
+ that opens the app in its own TronBrowser window
+ tron pwa remove ... take them out again
+ tron pwa installed slugs added that way
+ tron pwa defaults add CoinPay, LogicSRC and TronBrowser once per profile
+ (the launcher runs this; TRONBROWSER_NO_DEFAULT_APPS=1
+ skips it, and a default you remove stays removed)
`--dry-run` reports what sync/revert would change without writing.
"""
@@ -489,6 +497,9 @@ def cmd_list(apps_dir: str) -> int:
print(f"Web-app shortcuts in {apps_dir}:\n")
for sc in shortcuts:
name = sc.get("Name") or os.path.basename(sc.path)
+ if sc.get(KEY_PWA):
+ print(f" {name}\n added by 'tron pwa add' (tron pwa remove {sc.get(KEY_PWA)})")
+ continue
profile = ours(sc, installed)
if not profile:
udd = sc.user_data_dir() or "no profile recorded, and not installed in ours"
@@ -698,6 +709,348 @@ def cmd_install(target: str, dry_run: bool) -> int:
return 0
+# --- Apps we put in the app drawer ourselves --------------------------------
+#
+# The engine cannot install a web app on our say-so: it has no PWA DevTools
+# domain, `navigator.install` is not there, the command-line --policy switch is
+# ignored, and chrome.management.generateAppForLink only makes a hosted app that
+# opens in a tab. The managed WebAppInstallForceList policy works but needs root
+# and can never be uninstalled. So an "installed" app here is what the desktop
+# (and the PIT ONE / W3BS phone shell) actually launches: a .desktop entry with
+# the app's icon that runs `tron --app=`, a standalone window in the
+# TronBrowser profile. The user removes it like any other app, or with
+# `tron pwa remove`.
+
+# Installed for every new profile, once. Baked in rather than looked up so a
+# first start with no network still gets them; only the icon is fetched.
+DEFAULT_APPS = {
+ "coinpay": {
+ "name": "CoinPay",
+ "start_url": "https://coinpayportal.com/",
+ "icon": "https://coinpayportal.com/icons/icon-512x512.png",
+ },
+ "logicsrc": {
+ "name": "LogicSRC",
+ "start_url": "https://logicsrc.com/",
+ "icon": "https://logicsrc.com/icon.svg",
+ },
+ "tronbrowser": {
+ "name": "TronBrowser",
+ "start_url": "https://tronbrowser.dev/",
+ "icon": "https://tronbrowser.dev/icons/icon-512x512.png",
+ },
+}
+
+KEY_PWA = "X-TronBrowser-Pwa"
+DESKTOP_PREFIX = "tronbrowser-pwa-"
+SLUG_RE = r"[a-z0-9][a-z0-9-]{0,58}[a-z0-9]"
+HOST_NAME = "dev.tronbrowser.pwa"
+ICON_MAX = 2 * 1024 * 1024
+
+
+def data_home() -> str:
+ return os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share")
+
+
+def profile_dir() -> str:
+ return profile_dirs()[0]
+
+
+def desktop_path(slug: str) -> str:
+ return os.path.join(applications_dir(), f"{DESKTOP_PREFIX}{slug}.desktop")
+
+
+def icon_path(slug: str, url: str = "") -> str:
+ """Where an app's icon lives. The suffix follows the source (freedesktop
+ picks the loader by it), so an SVG icon is stored as .svg."""
+ ext = ".svg" if url.split("?")[0].lower().endswith(".svg") else ".png"
+ return os.path.join(data_home(), "tronbrowser", "pwa-icons", f"{slug}{ext}")
+
+
+def valid_slug(slug: str) -> bool:
+ import re
+
+ return bool(re.fullmatch(SLUG_RE, slug or ""))
+
+
+def clean_name(name: str, fallback: str) -> str:
+ # One line, no desktop-entry metacharacters a Name= could smuggle in.
+ name = " ".join(str(name or "").split())[:60]
+ return name or fallback
+
+
+def fetch_icon(url: str, dest: str) -> bool:
+ """Download an https icon, capped in size. False keeps the generic icon."""
+ import urllib.request
+
+ if not (url or "").startswith("https://"):
+ return False
+ try:
+ req = urllib.request.Request(url, headers={"user-agent": "tron-pwa"})
+ # https only (checked above); the URL is the listing's own icon.
+ # threatcrush-disable-next-line py-ssrf-outbound-request
+ with urllib.request.urlopen(req, timeout=10) as res:
+ body = res.read(ICON_MAX + 1)
+ if len(body) > ICON_MAX or not body:
+ return False
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
+ with open(dest, "wb") as f:
+ f.write(body)
+ return True
+ except Exception:
+ return False
+
+
+def resolve_app(slug: str) -> dict:
+ """name/start_url/icon for a slug: the store first, the baked-in defaults offline."""
+ try:
+ import urllib.parse
+
+ app = pwamart_get(f"/apps/{urllib.parse.quote(slug)}")["app"]
+ return {
+ "name": app.get("name") or slug,
+ "start_url": app.get("start_url") or app.get("url") or "",
+ "icon": app.get("icon") or "",
+ }
+ except Exception:
+ if slug in DEFAULT_APPS:
+ return dict(DEFAULT_APPS[slug])
+ raise
+
+
+def write_app(slug: str, app: dict, dry_run: bool = False) -> str:
+ """Write the drawer entry for one app; returns its path."""
+ url = app.get("start_url") or ""
+ if not url.startswith("https://"):
+ raise ValueError(f"refusing a non-https start URL: {url}")
+ if any(c in url for c in " \t\r\n\"'\\`$"):
+ raise ValueError(f"refusing an unusual start URL: {url}")
+ name = clean_name(app.get("name"), slug)
+ path = desktop_path(slug)
+ if dry_run:
+ return path
+ icon = icon_path(slug, app.get("icon") or "")
+ have_icon = fetch_icon(app.get("icon") or "", icon) or os.path.isfile(icon)
+ lines = [
+ "[Desktop Entry]",
+ "Type=Application",
+ "Version=1.0",
+ f"Name={name}",
+ f"Comment={name} in TronBrowser",
+ # A literal % in Exec is written %% (desktop entry spec).
+ "Exec=" + escape_exec([launcher_cli(), f"--app={url}"]).replace("%", "%%"),
+ f"Icon={icon if have_icon else 'tronbrowser'}",
+ "Terminal=false",
+ "Categories=Network;WebBrowser;",
+ f"{KEY_PWA}={slug}",
+ ]
+ os.makedirs(applications_dir(), exist_ok=True)
+ fd, tmp = tempfile.mkstemp(dir=applications_dir(), prefix=".tron-pwa-")
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write("\n".join(lines) + "\n")
+ os.chmod(tmp, 0o644)
+ os.replace(tmp, path)
+ return path
+
+
+def installed_slugs() -> list[str]:
+ try:
+ names = sorted(os.listdir(applications_dir()))
+ except OSError:
+ return []
+ out = []
+ for n in names:
+ if n.startswith(DESKTOP_PREFIX) and n.endswith(".desktop"):
+ slug = n[len(DESKTOP_PREFIX) : -len(".desktop")]
+ if valid_slug(slug):
+ out.append(slug)
+ return out
+
+
+def add_app(slug: str, dry_run: bool = False) -> str:
+ if not valid_slug(slug):
+ raise ValueError("give a pwamart slug")
+ path = write_app(slug, resolve_app(slug), dry_run)
+ if not dry_run:
+ pwamart_count(slug)
+ return path
+
+
+def remove_app(slug: str) -> bool:
+ if not valid_slug(slug):
+ return False
+ gone = False
+ for p in (desktop_path(slug), icon_path(slug), icon_path(slug, ".svg")):
+ try:
+ os.unlink(p)
+ gone = True
+ except OSError:
+ pass
+ return gone
+
+
+def open_app(slug: str) -> None:
+ import shlex
+ import subprocess
+
+ sc = Shortcut.load(desktop_path(slug))
+ if not sc:
+ raise ValueError(f"{slug} is not installed")
+ argv = [a.replace("%%", "%") for a in unescape_exec(sc.get("Exec") or "") if a not in FIELD_CODES]
+ if len(argv) < 2 or not argv[1].startswith("--app=https://"):
+ raise ValueError(f"{slug}: unexpected Exec {shlex.join(argv)}")
+ subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
+
+
+def cmd_add(slugs: list[str], dry_run: bool) -> int:
+ if not slugs:
+ print("tron pwa add: give one or more pwamart slugs", file=sys.stderr)
+ return 2
+ rc = 0
+ for slug in slugs:
+ try:
+ path = add_app(slug, dry_run)
+ print(f"{'would add' if dry_run else 'added'} {slug} -> {path}")
+ except Exception as e:
+ print(f"tron pwa add {slug}: {e}", file=sys.stderr)
+ rc = 1
+ return rc
+
+
+def cmd_remove(slugs: list[str]) -> int:
+ rc = 0
+ for slug in slugs:
+ if remove_app(slug):
+ print(f"removed {slug}")
+ else:
+ print(f"tron pwa remove: {slug} is not installed", file=sys.stderr)
+ rc = 1
+ return rc
+
+
+def defaults_marker() -> str:
+ return os.path.join(profile_dir(), ".tron-pwa-defaults")
+
+
+def cmd_defaults(dry_run: bool) -> int:
+ """Put CoinPay, logicsrc and Tron in the drawer, once per profile.
+
+ The marker records each default that was installed, so one the user removed
+ stays removed, and a default added in a later release still arrives. A
+ failed install is not recorded and is retried on the next start.
+ """
+ if os.environ.get("TRONBROWSER_NO_DEFAULT_APPS") == "1":
+ return 0
+ try:
+ with open(defaults_marker(), encoding="utf-8") as f:
+ done = {l.strip() for l in f if l.strip()}
+ except OSError:
+ done = set()
+ todo = [s for s in DEFAULT_APPS if s not in done]
+ for slug in todo:
+ if dry_run:
+ print(f"would add {slug}")
+ continue
+ try:
+ write_app(slug, dict(DEFAULT_APPS[slug]))
+ pwamart_count(slug)
+ done.add(slug)
+ except Exception as e:
+ print(f"tron pwa defaults {slug}: {e}", file=sys.stderr)
+ if todo and not dry_run:
+ os.makedirs(profile_dir(), exist_ok=True)
+ with open(defaults_marker(), "w", encoding="utf-8") as f:
+ f.write("\n".join(sorted(done)) + "\n")
+ return 0
+
+
+def unpacked_extension_id(path: str) -> str:
+ """The id Chromium gives an extension loaded with --load-extension=:
+ sha256 of the absolute path, first 16 bytes, hex digits mapped to a-p."""
+ import hashlib
+
+ digest = hashlib.sha256(os.path.realpath(path).encode("utf-8")).hexdigest()[:32]
+ return "".join(chr(ord("a") + int(c, 16)) for c in digest)
+
+
+def cmd_register_host(ext_dirs: str) -> int:
+ """Let the new-tab widget reach `tron pwa` through native messaging.
+
+ Chromium reads user-level hosts from /NativeMessagingHosts,
+ so this needs no root and is scoped to the TronBrowser profile. Rewritten on
+ every start because the launcher (and so this script) moves on upgrade.
+ """
+ dirs = [d for d in (ext_dirs or "").split(",") if d and os.path.isfile(os.path.join(d, "manifest.json"))]
+ if not dirs:
+ return 0
+ host_dir = os.path.join(profile_dir(), "NativeMessagingHosts")
+ os.makedirs(host_dir, exist_ok=True)
+ wrapper = os.path.join(host_dir, "tron-pwa-host")
+ script = os.path.abspath(__file__)
+ env_lines = ""
+ for k in ("TRONBROWSER_DATA", "TRONBROWSER_CLI", "PWAMART_URL"):
+ v = os.environ.get(k)
+ if v:
+ env_lines += f"export {k}='{v.replace(chr(39), '')}'\n"
+ with open(wrapper, "w", encoding="utf-8") as f:
+ f.write(f"#!/bin/sh\n{env_lines}exec python3 '{script.replace(chr(39), '')}' host\n")
+ os.chmod(wrapper, 0o755)
+ import json
+
+ manifest = {
+ "name": HOST_NAME,
+ "description": "TronBrowser app installer (tron pwa)",
+ "path": wrapper,
+ "type": "stdio",
+ "allowed_origins": [f"chrome-extension://{unpacked_extension_id(d)}/" for d in dirs],
+ }
+ with open(os.path.join(host_dir, f"{HOST_NAME}.json"), "w", encoding="utf-8") as f:
+ json.dump(manifest, f, indent=2)
+ return 0
+
+
+def cmd_host() -> int:
+ """One native-messaging request in, one reply out (sendNativeMessage)."""
+ import json
+ import struct
+
+ raw = sys.stdin.buffer.read(4)
+ if len(raw) < 4:
+ return 0
+ (n,) = struct.unpack(" 64 * 1024:
+ return 1
+ try:
+ msg = json.loads(sys.stdin.buffer.read(n).decode("utf-8"))
+ except Exception:
+ msg = {}
+ cmd, slug = msg.get("cmd"), str(msg.get("slug") or "")
+ # Our own output must not reach stdout except as the framed reply.
+ real_stdout, sys.stdout = sys.stdout, sys.stderr
+ try:
+ if cmd == "list":
+ reply = {"ok": True, "installed": installed_slugs(), "defaults": list(DEFAULT_APPS)}
+ elif cmd == "add":
+ add_app(slug)
+ reply = {"ok": True, "installed": installed_slugs()}
+ elif cmd == "remove":
+ remove_app(slug)
+ reply = {"ok": True, "installed": installed_slugs()}
+ elif cmd == "open":
+ open_app(slug)
+ reply = {"ok": True}
+ else:
+ reply = {"ok": False, "error": f"unknown command {cmd!r}"}
+ except Exception as e:
+ reply = {"ok": False, "error": str(e)}
+ finally:
+ sys.stdout = real_stdout
+ body = json.dumps(reply).encode("utf-8")
+ sys.stdout.buffer.write(struct.pack(" int:
args = [a for a in argv if a != "--dry-run"]
dry_run = "--dry-run" in argv
@@ -717,7 +1070,20 @@ def main(argv: list[str]) -> int:
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)
+ if cmd == "add":
+ return cmd_add(args[1:], dry_run)
+ if cmd == "remove":
+ return cmd_remove(args[1:])
+ if cmd == "installed":
+ print("\n".join(installed_slugs()))
+ return 0
+ if cmd == "defaults":
+ return cmd_defaults(dry_run)
+ if cmd == "register-host":
+ return cmd_register_host(args[1] if len(args) > 1 else "")
+ if cmd == "host" or (cmd.startswith("chrome-extension://")):
+ return cmd_host()
+ print(f"tron pwa: unknown command '{cmd}'. Try: list, sync, revert, search, install, add, remove, installed, defaults", file=sys.stderr)
return 2
diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser
index ddc4c07..12711bf 100755
--- a/apps/desktop/launcher/tronbrowser
+++ b/apps/desktop/launcher/tronbrowser
@@ -777,6 +777,12 @@ fi
if [ "$TOR" != "1" ] && [ -z "$MAC_APP" ] && [ -f "$DIR/tron-pwa" ]; then
if command -v python3 >/dev/null 2>&1; then
TRONBROWSER_DATA="$DATA" python3 "$DIR/tron-pwa" sync >&2 || true
+ # The new-tab Apps widget installs through `tron pwa` over native
+ # messaging; the host lives in this profile, so no root and no other
+ # browser sees it. Then CoinPay, LogicSRC and TronBrowser go in the app
+ # drawer, once per profile, in the background (it fetches icons).
+ TRONBROWSER_DATA="$DATA" python3 "$DIR/tron-pwa" register-host "$EXT" >&2 || true
+ (TRONBROWSER_DATA="$DATA" python3 "$DIR/tron-pwa" defaults >/dev/null 2>&1 `), and `tron pwa host` is the native
+// messaging host the new-tab Apps widget installs through. Pinned here: entries
+// only ever launch https URLs through the launcher, defaults are installed once
+// per profile and a removed default stays removed, and the host speaks the
+// length-prefixed protocol with nothing else on stdout.
+
+import { spawn, spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
+import { createServer, type Server } from 'node:http';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterAll, beforeAll, 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';
+
+let server: Server;
+let store = '';
+const posts: string[] = [];
+
+beforeAll(async () => {
+ server = createServer((req, res) => {
+ if (req.method === 'POST') posts.push(req.url ?? '');
+ res.setHeader('content-type', 'application/json');
+ if (req.url === '/api/v1/apps/notes') res.end(JSON.stringify({ app: { slug: 'notes', name: 'Notes\nExec=evil', start_url: 'https://notes.example/?pwa', icon: '' } }));
+ else if (req.url === '/api/v1/apps/evil') res.end(JSON.stringify({ app: { slug: 'evil', name: 'Evil', start_url: 'http://evil.example/' } }));
+ else if (req.method === 'POST') res.end('{}');
+ else res.writeHead(404).end('{}');
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ store = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
+});
+afterAll(() => server.close());
+
+function sandbox() {
+ const home = mkdtempSync(join(tmpdir(), 'tron-pwa-apps-'));
+ const data = join(home, '.tronbrowser');
+ return {
+ home,
+ data,
+ apps: join(home, 'share', 'applications'),
+ env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home, XDG_DATA_HOME: join(home, 'share'), TRONBROWSER_DATA: data, TRONBROWSER_CLI: CLI, PWAMART_URL: store },
+ };
+}
+
+function run(env: Record, args: string[], input?: Buffer): Promise<{ stdout: Buffer; stderr: string; status: number }> {
+ return new Promise((resolve) => {
+ const child = spawn('python3', [TRON_PWA, ...args], { env });
+ const out: Buffer[] = [];
+ let stderr = '';
+ child.stdout.on('data', (d) => out.push(d));
+ child.stderr.on('data', (d) => (stderr += d));
+ child.on('close', (status) => resolve({ stdout: Buffer.concat(out), stderr, status: status ?? -1 }));
+ if (input) child.stdin.end(input);
+ else child.stdin.end();
+ });
+}
+
+function frame(msg: unknown): Buffer {
+ const body = Buffer.from(JSON.stringify(msg));
+ const len = Buffer.alloc(4);
+ len.writeUInt32LE(body.length);
+ return Buffer.concat([len, body]);
+}
+
+function unframe(buf: Buffer): Record {
+ const n = buf.readUInt32LE(0);
+ expect(buf.length).toBe(4 + n); // nothing but the one framed reply
+ return JSON.parse(buf.subarray(4).toString());
+}
+
+describe('tron pwa add / remove', () => {
+ it('writes a drawer entry that runs the launcher with the https start URL', async () => {
+ const s = sandbox();
+ const r = await run(s.env, ['add', 'notes']);
+ expect(r.status).toBe(0);
+ const entry = readFileSync(join(s.apps, 'tronbrowser-pwa-notes.desktop'), 'utf8');
+ expect(entry).toContain(`Exec=${CLI} "--app=https://notes.example/?pwa"\n`);
+ expect(entry).toContain('X-TronBrowser-Pwa=notes');
+ // A newline in the listing name cannot add a line to the entry.
+ expect(entry).toContain('Name=Notes Exec=evil\n');
+ expect(entry.match(/^Exec=/gm)).toHaveLength(1);
+ expect(posts).toContain('/api/v1/apps/notes/installs');
+ expect((await run(s.env, ['installed'])).stdout.toString().trim()).toBe('notes');
+ expect((await run(s.env, ['remove', 'notes'])).status).toBe(0);
+ expect(existsSync(join(s.apps, 'tronbrowser-pwa-notes.desktop'))).toBe(false);
+ });
+
+ it('refuses a non-https start URL, a bad slug and an unknown app', async () => {
+ const s = sandbox();
+ for (const bad of ['evil', '../etc', 'nope']) {
+ expect((await run(s.env, ['add', bad])).status).not.toBe(0);
+ }
+ expect(existsSync(s.apps) ? spawnSync('ls', [s.apps]).stdout.toString().trim() : '').toBe('');
+ });
+});
+
+describe('tron pwa defaults', () => {
+ it('adds CoinPay, LogicSRC and TronBrowser once, and a removed one stays removed', async () => {
+ const s = sandbox();
+ expect((await run(s.env, ['defaults'])).status).toBe(0);
+ const names = ['coinpay', 'logicsrc', 'tronbrowser'];
+ for (const n of names) expect(existsSync(join(s.apps, `tronbrowser-pwa-${n}.desktop`))).toBe(true);
+ expect(readFileSync(join(s.apps, 'tronbrowser-pwa-coinpay.desktop'), 'utf8')).toContain(`Exec=${CLI} --app=https://coinpayportal.com/\n`);
+ expect(readFileSync(join(s.data, '.tron-pwa-defaults'), 'utf8').trim().split('\n').sort()).toEqual(names);
+ await run(s.env, ['remove', 'coinpay']);
+ await run(s.env, ['defaults']);
+ expect(existsSync(join(s.apps, 'tronbrowser-pwa-coinpay.desktop'))).toBe(false);
+ });
+
+ it('is skipped with TRONBROWSER_NO_DEFAULT_APPS=1', async () => {
+ const s = sandbox();
+ await run({ ...s.env, TRONBROWSER_NO_DEFAULT_APPS: '1' }, ['defaults']);
+ expect(existsSync(s.apps)).toBe(false);
+ });
+
+ it('leaves drawer entries alone on sync, and list names them', async () => {
+ const s = sandbox();
+ await run({ ...s.env, TRONBROWSER_NO_DEFAULT_APPS: '0' }, ['add', 'notes']);
+ const before = readFileSync(join(s.apps, 'tronbrowser-pwa-notes.desktop'), 'utf8');
+ await run(s.env, ['sync']);
+ expect(readFileSync(join(s.apps, 'tronbrowser-pwa-notes.desktop'), 'utf8')).toBe(before);
+ expect((await run(s.env, ['list'])).stdout.toString()).toContain("added by 'tron pwa add'");
+ });
+});
+
+describe('tron pwa host (native messaging)', () => {
+ it('answers list/add/remove with one framed reply', async () => {
+ const s = sandbox();
+ expect(unframe((await run(s.env, ['host'], frame({ cmd: 'list' }))).stdout)).toMatchObject({ ok: true, installed: [], defaults: ['coinpay', 'logicsrc', 'tronbrowser'] });
+ expect(unframe((await run(s.env, ['host'], frame({ cmd: 'add', slug: 'notes' }))).stdout)).toMatchObject({ ok: true, installed: ['notes'] });
+ expect(unframe((await run(s.env, ['host'], frame({ cmd: 'add', slug: 'evil' }))).stdout)).toMatchObject({ ok: false });
+ expect(unframe((await run(s.env, ['host'], frame({ cmd: 'remove', slug: 'notes' }))).stdout)).toMatchObject({ ok: true, installed: [] });
+ expect(unframe((await run(s.env, ['host'], frame({ cmd: 'rm -rf' }))).stdout)).toMatchObject({ ok: false });
+ });
+
+ it('is what Chromium launches: the origin argument selects host mode', async () => {
+ const s = sandbox();
+ const r = await run(s.env, ['chrome-extension://abc/'], frame({ cmd: 'list' }));
+ expect(unframe(r.stdout)).toMatchObject({ ok: true });
+ });
+
+ it('register-host writes a profile-scoped manifest for the unpacked extension id', async () => {
+ const s = sandbox();
+ const ext = join(s.home, 'ext');
+ mkdirSync(ext);
+ writeFileSync(join(ext, 'manifest.json'), '{}');
+ expect((await run(s.env, ['register-host', `${ext},${join(s.home, 'missing')}`])).status).toBe(0);
+ const m = JSON.parse(readFileSync(join(s.data, 'NativeMessagingHosts', 'dev.tronbrowser.pwa.json'), 'utf8'));
+ const id = [...createHash('sha256').update(realpathSync(ext)).digest('hex').slice(0, 32)].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join('');
+ expect(m).toMatchObject({ name: 'dev.tronbrowser.pwa', type: 'stdio', allowed_origins: [`chrome-extension://${id}/`] });
+ expect(readFileSync(m.path, 'utf8')).toContain(' host\n');
+ });
+});
diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh
index b401541..61e61ae 100755
--- a/apps/web/public/install.sh
+++ b/apps/web/public/install.sh
@@ -137,7 +137,13 @@ Usage:
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
+ tron pwa add ... Put pwamart apps in the app drawer in one step
+ tron pwa remove ...
+ Take them out again (tron pwa installed lists them)
+ tron pwa defaults Add CoinPay, LogicSRC and TronBrowser (done once per
+ profile on first start; TRONBROWSER_NO_DEFAULT_APPS=1
+ skips it)
+ 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