Skip to content

OpenTray v0.23.0: expanded packaging entries, better default icons

jixoai v0.23.0 opentray

OpenTray 0.22.0 and 0.23.0: a URL alone now packages into a desktop app with its own identity, with no spawned process and no node-pty; window titles follow the page by default; omit appIcon and the runtime synthesizes a first-character glyph icon, with the generation kernel unified in @opentray/icon's WebAssembly stack and no sharp.

$ npm view create-opentray version
0.23.0

OpenTray 0.22.0 to 0.23.0 has two themes: create-opentray's packaging entry widened from commands to URLs, and apps that omit appIcon now get a generated default icon. The rest is one macOS CPU fix and two icon fallbacks. The previous post covered 0.21.1 (2026-09-06); this one groups the whole interval by theme, and the link bar carries a permanent release link for both versions.

A URL alone becomes an app

From 0.23.0 on, handing create-opentray a URL is enough: an instance somebody else deployed, an online service, or a static page all qualify. npx create-opentray create --url <address> registers a desktop app pointing at that address, with the identity derived from it; --app-id and --app-name override.

npx create-opentray create --url https://example.com
# https://example.com/app  ->  app id "app.com.example", name "App"

The output is lighter than command mode: nothing is spawned, there are no PTY or shell assets, and no dependency on node-pty. URL apps ride the same v1 registry pipeline as command apps, so app list, app edit, app copy, app export and app uninstall all apply, and app edit can switch an app between the command and URL sources.

The window, tray and identity derivation are existing machinery; what this release adds is the entry point, aimed at the same ground a PWA covers: install one icon, click it, the app is there, resident in the tray. The difference is the artifact: a PWA lives inside the browser's install model and its sandbox, while this is a desktop app with its own identity, where the tray menu, the Dock pin and the window lifecycle belong to the app itself.

Command mode is the path that already existed: it spawns a command that serves HTTP locally, waits until something is listening, opens a native window at that port under a fixed app identity, and keeps a tray entry for show, raise and quit. ComfyUI, gradio, llama.cpp's server, or the npm run dev in your own repo are the typical "web UI behind a single command" cases.

--arg repeats, and each value is one argv element with no shell splitting, so && stays a literal argument. The port is not baked into the config: the generated app keeps sniffing the ports the command's process tree owns and verifies each over HTTP, so dynamic ports behave like fixed ones. Switch on the launch terminal in the wizard to watch the command's output.

Example: a full command-mode invocation
npx create-opentray create \
  --app-id app.local.mytool --app-name "My Tool" \
  --exec npm --arg run --arg dev --cwd /path/to/project

Because the address is known before anything runs, creation scrapes the page once and adopts its <title> and best favicon as defaults. Explicit flags always win, a failed scrape falls back silently to the address-derived name and a glyph icon, and --no-scrape disables the fetch.

Adding --toolbar wraps the page in an address-bar shell: back, forward and reload buttons, plus the ⌘/Ctrl+←→, ⌘/Ctrl+[, ⌘/Ctrl+R, F5 and ⌘/Ctrl+L shortcuts while focus is inside the wrapper. Pages that forbid embedding cannot be wrapped; when the site refuses, creation falls back to a direct window with a notice. Every URL app gets Reload in its tray menu regardless of toolbar mode.

Window titles follow the page document by default, with --no-title-follow to stop; the icon does not follow favicon changes at runtime unless --icon-follow opts in. Both persist in the v1 config, round-trip through app edit and app export, and apply to command-mode service windows too.

--exec (command mode)--url (URL mode)
Inputa local commandan http(s) address
What it doesspawns, waits for a port, opens a windowopens a window
Runtime needsoptional PTY and shell assetsno PTY, no shell
Identity fromrunner and package namethe address
Title and icon defaultsyours to supplyscraped from the page
Mechanism: how the embedding probe reads

The probe rides the default scrape: a GET request reads X-Frame-Options and CSP frame-ancestors from the response headers. HEAD would miss policies carried only on GET responses, so the probe uses the same method a browser would. With --no-scrape there is no probe data and the --toolbar request stands.

App icons, generated

Omit appIcon and the runtime synthesizes a squircle glyph icon from the first character of the app name (white glyph on a blue tile), writes it into the macOS bundle, caches it under the runtime directory, and the Dock stops showing the generic executable placeholder. A Chinese app name renders its first Chinese character. appBundle.defaultAppIcon: false restores the old behaviour, and an explicit appIcon always wins.

await createTray({ /* tray icon and menu */ }, {
  appId: "com.example.build",
  appName: "Build",
  appBundle: { defaultAppIcon: false },   // opt out of the synthesized icon
});

The generation kernel moved into a new package in 0.22.0, @opentray/icon: glyph defaults, composition, squircle tiling and the ICNS, ICO and Linux PNG encoders, on a WebAssembly image stack (jsquash decodes and resizes, resvg renders SVG) with no sharp and no libvips.

create-opentray, @opentray/vite-plugin and bare-SDK consumers now share one icon standard; the Vite plugin calls the same kernel, keeps its public API and drops its sharp dependency.

The wizard's URL mode treats the two macOS icons separately: app icon candidates are the original plus an AI-extracted subject, while the tray icon uses a solid silhouette, which is what the macOS tray template wants. Extraction runs in the browser through a model proxied by the wizard server, backed by a persistent on-disk cache: the model downloads from the CDN once, and every later session, on any random port, is served from loopback.

The advanced settings (model precision, alpha threshold, edge shrink) apply on change and replace the previous subject and its derived silhouettes; after a URL switch or a re-extraction, thumbnails and any live selection re-fuse automatically.

Mechanism: the extraction model and cache

The model is @imgly/background-removal (ONNX Runtime Web); the default switched this release from isnet_quint8 to isnet_fp16, because the quantized build left more white-pad residue on flat favicons (measured residue coverage 0.175 vs 0.081 for fp16). The wizard offers three precision levels: high (isnet), standard (fp16) and light (quint8). The disk cache lives at ~/.opentray/cache/imgly-data/<version>/ with a 16 MB per-file bound; model weights do not ship with the package, keeping the webui output at 4 MB.

Next: local capabilities for pages

The boundary of URL mode is easy to state: the page inside the window is still an ordinary web page.

The next step is a set of standard interfaces that let a front-end page reach fs and child_process. With those two in place, a page packaged by OpenTray could do what a native app does: read and write local files, spawn child processes, manage its own data, with no Node backend written first. That is planned work; nothing of it has shipped yet.

Other changes

  • On macOS, @opentray/ext-webview no longer re-asserts the activation policy on every event-loop drain tick. The tick called set_activation_policy about 60 times a second, resetting the app icon and repainting the Dock tile, holding one broker at a steady 50-80% of a core; measured 77.3% down to 4.4% after the fix (2026-09-09). The policy target is now compared against AppKit's current value first (029d286).

  • @opentray/icon's auto background honors a solid border ring, so white-padded favicons keep their backdrop.

  • Hosts missing CJK fonts degrade to the neutral terminal mark instead of failing icon generation.

52 commits after 0.21.1, by conventional prefix: 15 feat / 10 fix / 15 docs / 6 chore / 2 test / 2 perf, plus 1 style and 1 refactor. No breaking changes in the interval; upgrading needs no migration.

Upgrade

npm i create-opentray@0.23.0
npm i opentray@0.23.0          # SDK

With official extensions, pin one protocol line: pnpm add opentray@stable-A-B @opentray/ext-webview@stable-A-B (the line tag is published by @opentray/spec; do not mix it with latest).

Links