Skip to content

OpenTray v0.21.1: a better creation experience, fresh installs that work

jixoai v0.21.1 opentray

A resident tray entry for lightweight programs such as CLI tools, AI skills, and local daemons, without pulling in Electron. Wizard command entry is per-ecosystem and form-driven, app identity survives runner switches, and env injection is explicit. The fresh-install 404 is fixed.

Esta publicación está escrita en inglés. Léela en chino →

$ npm view create-opentray version
0.21.1

OpenTray v0.21.1 is out (2026-09-06, the repo's first GitHub Release). OpenTray gives lightweight programs such as CLI tools, AI skills, and local daemons a resident tray entry: an icon and a menu that receive click events, without pulling in Electron for one small icon. This post covers 0.21.0 and 0.21.1 together; the changes center on the creation tool create-opentray, and both releases are linked in full at the end.

One line wraps an existing command into a tray app

A command that already serves HTTP locally can be wrapped into an OpenTray app with npx create-opentray. All three entry paths are in the create-app guide:

npx create-opentray web      # browser wizard, interactive
npx create-opentray create --app-id com.example.build \
  --app-name "Build" --exec "npm run dev"   # fully non-interactive
npx create-opentray skill    # the built-in AI skill, for agents to read

Command entry selects the ecosystem

Command input in the wizard is now a single-row input group (command families): an ecosystem-selector prefix plus the command body. Pick npm / Go / Rust / Python / .NET, and the command body turns from free text into a structured form; confirming writes the command string back, cancelling discards the whole input. Free-form custom commands are unchanged.

Details: ecosystem list and form fields

The five ecosystem families cover the npm series npx, pnpx, bunx, yarn dlx, nubx, deno run, vpx, plus go run, cargo install, uvx/pipx run, and dnx. Form fields are runner, package identifier (name / module / crate / tool id), version, and arguments; the Rust series adds the run binary name and an install reference line.

Details: form session, cargo guard, argument fidelity

Opening the form freezes a cancel snapshot; unconfirmed edits stay session-local. Cancelling (button / X / overlay / ESC) discards the whole session, and drafts survive switching ecosystems back and forth.

cargo install is never executed by the wizard: when the resolved executable normalizes to cargo and the argv contains a standalone install token, the command is refused with guidance toward the Rust form. The rule is deliberately conservative and over-refuses rare cargo subcommands that carry install as an argument.

Arguments are re-serialized with POSIX symmetric quoting so tokenize(build(parse(cmd))) stays token-for-token identical; the webui mirror uses the same shell-quote version as core. Value-bearing runner options (such as deno run --config <path>) fall back verbatim to custom instead of reinterpreting a flag value as the package name.

Switch runners, keep the app identity

The appId is derived from the ecosystem rather than the runner: the same package keeps one appId across runners within the npm series (npx to bunx), so app identity does not drift.

npx @deepseek-ai/dsh@latest web   → web.dsh.npmjs
go run rsc.io/fortune@latest      → fortune.golang
cargo install ripgrep (rg)        → rg.rust
Details: appId derivation

Recognized families take the pre-option subcommand segments plus a normalized package name, joined with an ecosystem tail (npmjs / golang / rust / python / dotnet). Rust uses the run binary; Python . and _ normalize to -; @scope, @version, npm: prefixes and path tails are stripped. Custom commands keep the existing derivation.

Env injection becomes explicit

npm_config_yes=true, which skips the first-run install confirmation of npx / pnpx, used to be injected implicitly by the wizard. It is now an explicit entry in the user env list, and that list is the single source of truth. The preset control in the wizard is a two-way projection of the entry: enabling writes it, removing deletes it; manual edits outside sync back instantly, and the last duplicate key wins. The indicator icon on the command row lights exactly when the entry exists.

Apps written in code start with one createTray()

Apps that skip the creation tool and are written directly need no bundler and load no Node addon. One createTray() is enough to start:

import { createTray, type TrayIcon } from "opentray";

let tray;
tray = await createTray(
  {
    id: "com.example.first-app",
    icon: { "text-only": "OT" },
    menu: {
      items: [
        { title: "Quit", primaryEvent: true, onMenuClick: () => void tray.destroy() },
      ],
    },
  },
  { appId: "com.example.first-app", appName: "First App" },
);

Application code talks to the runtime over the public tray/session protocol. The protocol has four entities: App is the runtime identity the caller holds and the isolation boundary; Tray is a single tray icon and its state under one app; Session is the live source of authority for tray events and mutations; Extension is a native capability package scoped to this app and its trays. The earlier Space / Surface generation is retired from the public API.

Details: workspace and publishing topology

Every package in the workspace publishes on one fixed version.

  • opentray (SDK + CLI)

  • @opentray/spec (the protocol contract)

  • @opentray/packaging

  • four bundler adapters: @opentray/vite-plugin, tsdown-plugin, esbuild-plugin, webpack-plugin (same manifest shape)

  • @opentray/ext-webview with the platform WebView dynamic libraries

  • @opentray/ext-badge

  • @opentray/<os>-<arch> platform runtime packages

ext-island is a roadmap item and stays unpublished; the Lynx extension is maintained separately at jixoai/opentray-ext-lynx.

Other changes

  • Fixed the fresh-install 404 (0b0fe63): the private workspace packages @create-opentray/core and @create-opentray/cli shipped in runtime dependencies, so npm hit a 404 resolving them. They now live in devDependencies, which is also what makes tsdown bundle them into the published single-package dist with no residual imports.

  • 0.21.0 fails every fresh install; 0.21.1 is the first installable release of command families.

  • Release automation is live (9e68724c): every published version cuts a GitHub Release of its own, with the tag equal to the package version and notes generated by changesets. This one was cut through the bootstrap path as the repo's first release; from the next version on, release notes are fully automated. This site's version pill showed a real number for the first time instead of "v—".

  • Same-line publishing: @opentray/spec, @opentray/packaging, @opentray/vite-plugin, and the rest of the workspace publish at 0.21.1 alongside it; the full list is in the fold under the createTray() section.

From the commit after the 0.20.0 version bump through this release's version bump (both inclusive), the range holds 22 commits: strict conventional prefixes count feat 2, fix 8, docs 6, test 2, chore 3, plus 1 changeset commit. Breaking-change count: 0 commits carry the ! marker, so there is nothing to migrate.

Upgrade

npm i create-opentray@0.21.1   # 0.21.0 fails fresh installs; install 0.21.1 directly
npm i opentray@0.21.1          # the SDK, same line

When using official extensions, lock the same protocol line: pnpm add opentray@stable-A-B @opentray/ext-webview@stable-A-B (protocol-line tags are published by @opentray/spec; do not mix with latest).

Acknowledgements

Thanks to the install-failure report from 2026-08-20. The devDependencies rule was tightened because of it.

Links