OpenTray v0.23.0: one command turns any web UI into a native window (command and URL modes)
create-opentray now takes either a command or a URL. Command mode turns a local web UI into a tray-resident desktop app; URL mode packages any page as an app window of its own. App icons are generated when you don't supply one.
Dieser Beitrag ist auf Englisch verfasst. Auf Chinesisch lesen →
$ npm view create-opentray version
0.23.0
OpenTray v0.23.0 is out (2026-09-10). create-opentray now has two entry points: hand it a command, or hand it a URL, and either way you get a desktop app with an identity of its own. App icons are generated when you don't supply one.
Command mode: a web UI that gets its own window
Start with the path that already existed. Plenty of projects ship a web UI whose only entry point is a command: ComfyUI, gradio, llama.cpp's server, or the npm run dev in your own repo. Once it is running it occupies one browser tab among fifty. There is no Dock icon of its own, no tray entry, closing the tab does not stop the process, and next time you are back in a terminal typing the command again.
Command mode takes over those steps. It spawns the command, waits until something is listening, opens a native window at that port under a fixed app identity, and leaves a tray entry to show, raise and quit. The port is not baked into the config: the app sniffs the ports the command owns at runtime.
npx create-opentray create \
--app-id app.local.mytool --app-name "My Tool" \
--exec npm --arg run --arg dev --cwd /path/to/project
--arg repeats, and each value is one argv element with no shell splitting, so && stays a literal argument. Switch on the launch terminal in the wizard if you want to watch the command's output.
URL mode: package an address instead
That path assumes you are holding a command. Often you are not: an instance somebody else deployed, a service online, a static page. URL mode closes that gap.
npx create-opentray create --url https://example.com
# https://example.com/app -> app id "app.com.example", name "App"
Not a new capability, to be plain about it: the window, the tray and the identity derivation were already there. Only the entry point changed, from a command to an address, which leaves the output smaller. Nothing is spawned, there is no PTY, no shell assets and no dependency on node-pty. The point is completeness, covering the same ground a PWA does: one icon to install, one click to open, resident in the tray. A PWA stays inside the browser's install model and its sandbox. This is a desktop app with its own identity, and the tray menu, the Dock pin and the window lifecycle are its own.
--exec (command mode) | --url (URL mode) | |
|---|---|---|
| Input | a local command | an http(s) address |
| What it does | spawns, waits for a port, opens a window | opens a window |
| Runtime needs | optional PTY and shell assets | no PTY, no shell |
| Identity from | runner and package name | the address |
| Title and icon defaults | yours to supply | scraped from the page |
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; --no-scrape skips the fetch.
Adding --toolbar wraps the page in an address-bar shell with back, forward and reload buttons, plus ⌘/Ctrl+←→, ⌘/Ctrl+[ , ⌘/Ctrl+R, F5 and ⌘/Ctrl+L while focus is inside the wrapper. Pages that forbid embedding cannot be wrapped: creation probes the response headers during the default scrape (X-Frame-Options and CSP frame-ancestors) and falls back to a direct window with a notice when the site refuses. Every URL app gets Reload in its tray menu either way.
Next: backend capabilities for front ends
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 a direction, not a shipped feature.
App icons, generated
When you omit appIcon, the runtime synthesizes a glyph icon from the first letter of the app name, writes it into the macOS bundle and caches it under the runtime directory, so the Dock stops showing the generic executable placeholder. 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 kernel now lives in one package, @opentray/icon: glyph defaults, compositing, squircle cuts, 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. @opentray/vite-plugin calls the same kernel and keeps its public API.
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 over a persistent on-disk cache: the model downloads from the CDN once and later sessions, on any port, are served from loopback. The advanced settings (model precision, alpha threshold, edge shrink) apply on change and replace the previous subject and its silhouettes.
Other changes
On macOS,
@opentray/ext-webviewno longer re-asserts the activation policy on every event-loop drain. The tick calledset_activation_policyabout 60 times a second, resetting the app icon and repainting the Dock tile, holding one broker at 50–80% of a core even when nothing moved. It now compares 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 that need CJK glyphs but have no matching font installed now degrade to the neutral terminal mark instead of failing.
@opentray/vite-plugindropped its sharp dependency.
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
Changelog: GitHub Release opentray@0.23.0 · opentray@0.22.0 (the icon-kernel release, earlier the same day, 2026-09-10) · packages/create/CHANGELOG.md
Docs: opentray.jixoai.com · create README · create-app guide · app-mode guide
Upgrade: see the section above
Feedback: GitHub Issues
npm: opentray · create-opentray
On this blog: OpenTray v0.21.1 · UniPty v0.2.2
Chinese version: /zh/blog/2026-09-10-opentray-v0-23-0/
