Skip to content

feat(cli): tinyjs wrap <url> — one-line site wrappers - #20

Merged
tarwin merged 14 commits into
tarwin:mainfrom
slabbdev:feat/wrap
Oct 2, 2026
Merged

tarwin merged 14 commits into
tarwin:mainfrom
slabbdev:feat/wrap

Conversation

@slabbdev

@slabbdev slabbdev commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Scaffolds a wrapper project from any site: the "url" config makes the site the main window, the per-origin API gate ("wrapper" preset: exact host, *.subdomains, registrable domain — strangers deny-by-default) keeps the third-party origin away from the machine, popups: "window", downloads: "ask", and a logging-only backend. No frontend.

  • icon: apple-touch-icon > link icon > /favicon.ico fallback; ICO containers get their inner PNG extracted (sips refuses an ICO named icon.png)
  • title fetched from the page (44-char word-boundary ellipsis)
  • --name, --ua, optional [dir] argument

Tested on macOS 26: Hacker News + GitHub wrapped; tinyjs dev boots and traces the full NAV pipeline through the wrapper backend; tinyjs build produces codesigned .apps carrying TinyjsUrl/TinyjsPopups/TinyjsDownloads (verified in the plists). The Windows/Linux legs consume the shipped browser-affordances unchanged — not exercised on hardware here.

A consumer example (TinyJS Studio — a desktop UI driving new/wrap/dev/build) is at https://github.com/slabbdev/tinyjsapp-studio

Scaffolds a wrapper project from any site: fetched title + best
advertised icon (ICO containers get their inner PNG extracted),
tinyjs.json with url + per-origin wrapper preset (host, subdomains,
registrable domain — strangers deny-by-default), popups 'window',
downloads 'ask', and a logging-only backend. --name / --ua / [dir].
Built .apps carry TinyjsUrl and run with no frontend.
A wrapped http://127.0.0.1:8123 stamps that exact origin — a hardcoded
https:// key never matched, silently gating nothing for http sites.
Origin key is now base.origin; wildcard subdomain keys stay https-only.
--menubar: activation accessory + generated backend sets hideOnClose,
mounts a tray toggle and surfaces the window on bare tray click.
--top: setAlwaysOnTop at init. Both are tinyjs APIs that already ship;
the flags only compose them.
The generated backend's onNavigate policy hook answers 'external' for
matches (exact host or subdomain, case-insensitive); everything else
stays in-app — Nativefier's most-missed feature, on tinyjs's existing
policy hook.
API_ALWAYS gains win.close/minimize/zoom/startDrag — a wrapped page
redirecting to a stranger origin (google.fr→consent.google.com,
slabb.dev→buymeacoffee.com, measured live) must keep its frameless
strip working; worst case for a hostile page is annoyance, never data.
wrap grows --force (regenerate a tinyjs project in place — the Studio's
edit flow) and persists menubar/top in tinyjs.json studio{} for form
restore; the menubar tray is icon-only.
--panel (with --menubar) generates a tray-click toggle that anchors a
360x520 frameless panel under the tray icon (app.tray.position +
setPosition), always-on-top, and dismisses on outside click via
onWindowState focus tracking. Implies frameless.
The wrapped site's own icon (fetched or picked) becomes the tray icon —
template:false keeps favicon colors instead of macOS monochrome — with
the globe symbol as fallback when no icon.png exists.
Fetched favicons are full-bleed and render oversized in the dock next
to system apps. On macOS the icon is redrawn centered at ~82% of a
transparent 1024 canvas via a small JXA + Cocoa helper.
@slabbdev

Copy link
Copy Markdown
Contributor Author

Update — the wrap command grew into its final shape:

  • --menubar / --panel (tray toggle, or a dropdown panel anchored under the icon via app.tray.position), --top, --force (regenerate in place — the edit flow), --external a.com,b.com (onNavigate answers 'external')
  • tray uses the project's own icon.png in color, padded to the Apple grid (new native/pad-icon.jxa) so dock icons stop rendering oversized
  • API-gate origin keys match the real origin (protocol + port — a wrapped http://127.0.0.1:8123 never matched a hardcoded https:// key)
  • persists studio{} generator options for edit flows
  • includes one runtime tweak: window chrome verbs (win.close/minimize/zoom/startDrag) join API_ALWAYS — a wrapped page that redirects to a stranger origin must keep its frameless strip working (measured: google.fr→consent.google.com denied win.close)

Verified on macOS 26: menubar/panel/top/external wraps built and run; icons padded; no-dots variants generate clean runtime-boolean injects.

@tarwin

tarwin commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Thanks @slabbdev , wrap is a great addition and the probing (title, icon, ICO unwrapping) is nicely done. I went through it carefully and there are a few things to sort out before I'd be happy merging.

I was going to fix myself but thought it would be better to have a second pair of eyes on it.

Probably should change?

1. The origin gate can open up to a whole public suffix

The registrable domain is taken as the last two labels, and https://*.<domain> gets the wrapper preset. That's wrong for multi-part TLDs and hosting suffixes:

bbc.co.uk        -> https://*.co.uk       (every UK company)
sam.github.io    -> https://*.github.io   (every GitHub Pages site)
myapp.vercel.app -> https://*.vercel.app

The code comment says the cost of getting it wrong is "only strangers get no API, the fail-closed side". Here it's the other way round: anyone who can publish on that suffix gets store.*, dialog.*, shell.open, win.*, and so on. Wrapping a github.io or vercel.app project is possibly common.

  • Default to the exact origin only. Wildcards are opt-in. Maybe we send people through an interactive CLI for this?
  • Use the PSL to work out the registrable domain. Either vendor a copy (it's around 230 KB, or trim it to a compact set) or fetch it at wrap time from publicsuffix.org and fall back to exact-only if that fails. If the host is itself a public suffix, or sits directly under one as sam.github.io does, don't offer a wildcard at all.
  • When stdin is a TTY, confirm interactively. Show what would be allowed and let the user choose:
    API access for this app:
      [1] https://news.ycombinator.com only (recommended)
      [2] + subdomains:  https://*.ycombinator.com
    
  • Add flags for non-interactive use: --origins exact|subdomains (plus an explicit --origins https://a.com,https://*.b.com), and --yes. With no TTY and no flag, use exact.
  • Treat IP hosts as exact only. Right now 192.168.1.10 produces https://*.1.10 and an id of 10.1.168.192.wrap.
  • Make the summary line print the gate that was actually written. It currently prints *.${domain} even for http origins, which get no wildcard.

2. The API_ALWAYS change affects every app

Adding win.close/minimize/zoom/startDrag to API_ALWAYS changes gate policy for every tinyjs app, not just wrappers:

  • "api": "none" no longer means none, and "origins present = deny-by-default for strangers" stops being true.
  • win.close takes a page-supplied id (wid ?? m?.window ?? 'main'), and 'main' calls app.quit(). So any stranger frame (an ad iframe, a redirect target) can quit the app or close other windows by id, bypassing setHideOnClose. On Linux, origin attribution doesn't tell frames apart (Linux: origin attribution is frame-blind — a hostile subframe inherits the top frame's api gate #18), so this includes subframes.

The problem you hit is real, but it can be handled entirely in the generated config with no runtime change. Under compileApiGate, an origin that matches no key falls through to the top-level lists (base ?? strangerGate). So the generator can write:

"api": {
  "disable": ["*"],
  "enable": ["win.minimize", "win.zoom", "win.startDrag", "win.close"],
  "origins": { "https://news.ycombinator.com": "wrapper" }
}

3. --force should only replace the files it generates

At the moment --force recursively deletes the whole directory whenever it contains a tinyjs.json, including the user's edits to src/main.js, any files they've added, and .git. Have it overwrite only tinyjs.json, src/main.js and icon.png, and leave everything else alone. If you want to keep the "fully regenerate" behaviour, it'd be good to warn when src/main.js has changed since it was generated (e.g. store a hash in a comment and compare).

4. Remove the Studio-specific bits

cfg.studio persists a third-party app's form state into the core manifest, and the comments refer to "the Studio's edit flow" and fields "merged by the Studio". tinyjs.json should only contain things the runtime or CLI reads. If Studio needs to restore its form, it can keep a file (e.g. .tinyjs-studio.json) that it owns. Remove the studio key and the Studio references from the comments.

Likely should change

5. The menubar tray icon has to work on every platform, dev and built

The generated init runs tjs.stat('icon.png') relative to the cwd. A built .app launched from Finder has cwd /, so it always falls back to sf:globe, and sf: is macOS-only. The runtime already knows where the app's icon is (TINYJS_ICON for dev and for Windows/Linux builds, and the bundle's icon on macOS). Linux's tray even falls back to it for sf:/emoji:/missing icons. I'd rather see a small runtime addition: app.tray.set({ icon: 'app' }) resolves to the app's own icon on every OS, in both dev and built apps. The generator then emits exactly that, with no stat and no cwd guessing. I'm happy to do a full linux+windows verify after this.

6. Write a real PNG on every platform

JPEG and WebP bytes are currently written to icon.png unchanged. The macOS padding pass happens to re-encode them, but on Windows embed-icon (GDI+) doesn't read WebP, and on Linux gdk-pixbuf usually lacks the WebP loader, so the build either warns or loses the icon. wrap should always write a real PNG. The padding also belongs on all platforms, not just macOS, since the tray and dock icons look oversized everywhere. Options:

  • Keep JXA on macOS, and on Windows/Linux convert and pad through the launcher. The Windows launcher already has GDI+ PNG encoding for --embed-icon, and Linux has gdk-pixbuf, so a --pad-icon <in> <out> mode would fit alongside it.
  • If a platform can't decode what it fetched, fall back to the template icon rather than writing bytes that aren't a PNG.

Also: pad-icon.jxa draws the whole source into a square rect, so it stretches non-square images. Preserve the aspect ratio and center it.

7. Timeouts and size caps on the probe fetches

The page fetch and both icon fetches have no timeout or body limit, so a slow or hostile site can hang wrap or make it read a huge body. Something like a 10 s timeout on the page and 5 s per icon, cap the page at around 2 MB and icons at around 5 MB, and on timeout carry on "wrapped blind" as it does today when a fetch fails.

Smaller things

  • --panel without --menubar is silently ignored. We should at least message peopel to let them know.
  • usage: and the tinyjs help text don't list --menubar, --panel, --top, --external, --force.
  • The usage text says --name <title>, but --name sets the app name. The title always comes from the page. Either rename the flag in the help text or add a --title.
  • The flags only accept --flag value. --name=Foo silently does nothing. The .replace(/^=/, '') looks like it was meant to handle that but doesn't.

Happy to help with any of this.

@slabbdev

slabbdev commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks Tarwin — that's a proper review, and every point lands.
Agreed on all of it.

Plan of attack, in order: the quick wins first (--force scope with a generation hash, dropping cfg.studio for a Studio-owned file, probe timeouts + caps, and the smaller things), then the API_ALWAYS revert into the generated config exactly as you sketched it, then exact-only origins + PSL fetched and cached at wrap time with exact-only fallback (vending 230 KB feels heavy for a CLI) plus the interactive confirm.

The two meatier ones (icon: 'app' and real cross-platform PNG padding) come last — and yes please on the linux+windows verify once icon: 'app' lands. Pushing to the same branch as I go.

Thanks again for the time this took.

…tches

Review round 1 on tarwin#20:
- --force overwrites only tinyjs.json / src/main.js / icon.png — user
  files, added files and .git survive; a re-wrap that finds no icon keeps
  the existing one; the generated src/main.js carries a hash stamp so a
  later --force warns when it was edited since generation
- tinyjs.json no longer carries a "studio" key (Studio keeps its own file)
- probe fetches cap and time out (page 10 s / 2 MB, icons 5 s / 5 MB) —
  a slow or hostile site neither hangs wrap nor feeds it an unbounded body
- --flag=value works for every value flag; usage + help text list all
  flags and say --name <name>; --panel without --menubar says so
…ated gate

Review round 2 on tarwin#20 (point 2): the runtime keeps API_ALWAYS at
client.hello only, so gate policy stops changing for every tinyjs app —
"api": "none" means none again. The generated config carries the chrome
sugar at the top level instead: an origin that matches no key (a redirect
target like consent.google.com) falls through to disable ["*"] + enable
[win.close/minimize/zoom/startDrag], so a wrapped page keeps its window
machinery on any origin while everything else stays closed. The wrap
summary prints the gate that is actually written.
…ldcards

Review round 3 on tarwin#20 (point 1): the generated gate is the exact origin
only unless subdomains are asked for. The public suffix list — fetched at
wrap time, cached ~30 days, exact-only fallback when neither is there —
decides where a wildcard may stop: bbc.co.uk may widen to *.bbc.co.uk,
while sam.github.io or myapp.vercel.app (tenants on a hosting suffix)
and every IP or http origin stay exact. Non-interactive by default:
--origins exact|subdomains|url,… plus --yes; on a TTY wrap shows the two
choices and defaults to exact on enter. IP hosts get a sane ip-*.wrap
bundle id, and the summary prints the origins actually written.

The PSL's own section markers sit inside comments — parsed before the
comment strip, or the private section (github.io, vercel.app) is never
seen and hosting-suffix tenants get wildcards (caught by the
sam.github.io test). The interactive prompt is TTY-gated and needs a
real terminal to exercise; undeclared stdin falls back to exact.
@slabbdev

slabbdev commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Pushed since the plan above — points 1 and 2 of your review are done on this branch:

  • Point 2 (5431d8c): API_ALWAYS is back to client.hello only; the generated config carries the chrome sugar top-level exactly as you sketched, so "api": "none" means none again.
  • Point 1 (bdac72e): exact-origin gates by default; the PSL — fetched at wrap time, cached ~30 days, exact-only fallback — scopes any wildcard: bbc.co.uk widens to *.bbc.co.uk, while sam.github.io / myapp.vercel.app (hosting-suffix tenants) and every IP or http origin stay exact. --origins exact|subdomains|url,… + --yes, an interactive confirm on a TTY that defaults to exact, sane ip-….wrap ids, and the summary prints the origins actually written.
  • The branch also merges current main (v0.42.3), so this PR's diff is now cli.js + pad-icon.jxa only — no runtime changes left in it.

Tested against the live list (8 scenarios, fresh dirs each): bbc.co.uk widens; the tenants, IPs and default runs stay exact. One honest gap: the interactive prompt needs a real terminal to exercise — the isTTY gate keeps pipes non-interactive (exact). Remaining from your review: #5 icon: 'app' and #6 real cross-platform PNG padding.

@tarwin
tarwin merged commit 5f0df20 into tarwin:main Oct 2, 2026
tarwin added a commit that referenced this pull request Oct 2, 2026
wrap fixes on top of the merged PR: the menu-bar tray icon ships as
src/tray.png and resolves next to the backend module (a packaged app runs
from / without the project's icon.png); the page title is cut at a tagline
separator and stripped of filename-unsafe characters (it becomes the .app
path); --force carries over unflagged choices (origins, ua, window modes,
external list, user-owned keys), adds --no-menubar/--no-panel/--no-top,
and backs up an edited src/main.js to src/main.js.bak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@slabbdev
slabbdev deleted the feat/wrap branch October 3, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants