Skip to content

OpenTray v0.29~0.32: more system interaction, better presentation on unsigned apps

jixoai v0.32.0 opentray

0.29.0 through 0.32.0 add five native extensions for dialog, sound, clipboard, opener, and notification, covering the system interactions desktop tools need most. Notifications and dialogs on unsigned macOS apps fall back through osascript, messageDialog supports custom icons, and the extension command protocol moves to ABI v2.

Эта запись написана на английский. Читать на китайский →

$ npm view opentray version
0.32.0

OpenTray 0.29.0 through 0.32.0 fills in the system interactions desktop tools need most: asking the user and picking files, playing a sound, reading and writing the clipboard, opening links and revealing files, and posting a system notification, each as its own native extension. The macOS 26 presentation limits on unsigned apps also get a complete fallback story. The previous post covered up to 0.28.0. This one walks every change in these nine versions, grouped by theme, with permanent release links at the end.

All five packages publish on the same version line in embedded form: the binaries for the four macOS and Windows target architectures sit inside the package, so a normal package-manager install is complete. There is no platform subpackage to install and no native library to locate. Measured pack sizes run from 0.93 to 1.35 MiB, under the 2 MB warning line. On Linux every method rejects with its own typed error (for example clipboard_platform_unsupported) before any dispatch.

Adding one extension takes one line:

npm i opentray @opentray/ext-clipboard

Ask and pick through native dialogs

A tray menu can now raise a native dialog directly: ask the user a question, or let them pick a file, pick a directory, or choose a save location. A dismissed picker resolves to null; it does not throw. alert and confirm are the one-button and two-button sugars.

import { attachDialog } from "@opentray/ext-dialog";
import { createTray } from "opentray";

const tray = await createTray(
  { id: "com.example.tool", icon: { "text-only": "OT" } },
  { appId: "com.example.tool", appName: "Tool" }
);
const dialog = attachDialog(tray);

const picked = await dialog.pickFile();     // string | null
const saveTo = await dialog.pickSavePath(); // string | null
const choice = await dialog.messageDialog({
  message: "Export finished.",
  buttons: ["Open folder", "Close"],
  defaultId: 0,   // button focused by Enter
});
Details: platform engines and long-command settlement

macOS drives NSAlert and NSSavePanel through modal-session stepping instead of a main-loop-blocking runModal; Windows runs TaskDialogIndirect on a bounded dedicated STA worker and falls back to MessageBox on old systems. Long-running commands like these settle through the DeferredOperation transaction: an Accepted frame first, then exactly one terminal frame at completion.

Presentation on unsigned macOS apps (0.31.2-0.32.0)

macOS 26 gates interactive dialogs by signing class as well as notifications: an ad-hoc or unsigned carrier's in-process NSAlert renders in a degenerate form and never receives any click. A real-machine matrix tried seven activation strategies; none worked, and the 0.31.1 activation-only fix is retired.

The handling mirrors the notification law: every show command probes the process signature first, a properly signed carrier keeps the full in-process experience, and unsigned carriers fall back to /usr/bin/osascript's display dialog / choose file / choose folder / choose file name. The result contracts are identical on both paths (button index, cancel resolves null). The documented degradations: detail folds into the message, suppressed is always false, at most three buttons, and no mixed file-and-directory selection.

The icon slot defaults to the osascript host and the title bar carries the carrier display name (0.31.3). 0.32.0 adds the darwin.icon option: pass an image file path to replace the icon slot (custom png/icns files and system icon files both work); an unreadable path rejects typed before any dialog appears.

Details: the degenerate form and the isolation matrix

The degenerate form shows stacked blank button slots and a stray round help button at the top right. The 2026-09-19 isolation matrix covered five carrier shapes and seven activation strategies (including runModal, modal-session stepping, and activation-before-build); none worked, and activateIgnoringOtherApps cannot change a signing class. The signature probe uses the SecCode family, and every osascript payload crosses as run arguments, never AppleScript literal interpolation.

Play a beep or a local audio file

Three methods, each resolving when playback is accepted; there are no completion events. beep() plays the system default. playSystemSound uses the frozen common-name table (notification/warning/error plus platform-native names), where a missing name is a typed error, never a silent no-op and never a fallback to a default sound. playSound plays a local audio file.

const sound = attachSound(tray);
await sound.beep();                          // system default beep
await sound.playSystemSound("notification"); // frozen common-name table
await sound.playSound("/path/to/ding.wav");
Details: Windows miss detection and WAV validation

On Windows, a missing system alias is decided by the registry's sound-scheme catalog, because winmm's BOOL return value proves nothing about alias misses in this query mode. playSound performs exact RIFF validation with a 64 MiB cap.

Clipboard writes read back verbatim

null is a first-class value: when the board holds no text, reading returns null, not an error and not an empty string. The contract counts UTF-16 units, with a write cap of 1,048,576; a lone surrogate rejects typed instead of round-tripping through a replacement character, so what you read back matches what you wrote.

const clipboard = attachClipboard(tray);
await clipboard.writeText("copied from the tray");
const text = await clipboard.readText();  // string | null
await clipboard.clear();
Details: the Windows busy-clipboard retry discipline

When Windows reports the clipboard as busy, the extension retries only on ACCESS_DENIED, inside a 2-second total budget anchored at command dispatch; exhaustion reports clipboard_locked with attempt count and elapsed time. Other open failures reject typed immediately.

Links and files go to the system defaults

open hands a link or file to the system's default application; revealInFolder locates a file in Finder or Explorer. The open scheme allowlist is frozen to http/https/file/mailto. Targets dispatch verbatim with no normalization, and a drive-relative path like C:x rejects as semantically ambiguous. revealInFolder rejects paths containing a quote or any C0 control character, and root paths never get their trailing separator trimmed.

const opener = attachOpener(tray);
await opener.open("https://example.com");        // default browser
await opener.open("/tmp/report.txt");            // default app for the file type
await opener.revealInFolder("/tmp/report.txt");  // Finder / Explorer reveal
Details: per-platform acceptance criteria

On Windows, acceptance is a ShellExecuteW return value greater than 32; on darwin, open follows the NSWorkspace.open boolean while the Finder reveal call is void and accepted by definition.

Notifications work on unsigned apps too

The UTF-16 bounds for title, body, and subtitle are frozen at 64/256/64 units; anything over rejects typed rather than truncating silently. The Windows projection rides the app's own registered tray icon channel, one Shell_NotifyIcon balloon modification, creating no window and no icon of its own. Both authorization queries answer granted on Windows. (Dialogs follow the same signing-class gate from 0.31.2 on; see the dialog section.)

const notification = attachNotification(tray);
const granted = await notification.requestAuthorization(); // first darwin call triggers the system prompt
await notification.notify({ title: "Export finished", body: "report.pdf is ready." });

Authorization and the fallback (0.31.0)

macOS 26 refuses UNUserNotificationCenter authorization for ad-hoc and unsigned carriers in every launch shape: direct execution, LaunchServices launch, LSUIElement, and accessory AppKit were all probed; no prompt, no banner, and no entry in System Settings. The variable is the signing class, not the plist.

Starting with 0.31.0, a darwin notify first probes the running process's code-signing class. A properly signed app keeps the full UN experience; an unsigned carrier falls back to /usr/bin/osascript with display notification, whose host is Apple-signed and therefore always allowed. Title, body, and subtitle pass as run arguments, never interpolated into AppleScript literals.

The fallback banner attributes to the osascript icon, a documented degradation. Authorization commands keep honest UN semantics, and a denied snapshot still rejects typed with zero delivery. Developers who can re-sign their carrier with a Developer ID identity get the full UN experience back.

Details: the fallback's acceptance semantics

The fallback accepts on spawn success (resolve-on-acceptance) and carries the default alert sound unless silent is set; the signature probe is the same SecCode family the dialog bridge uses.

A real-machine acceptance panel

A new host-atoms panel in examples runs all five extensions from one tray menu: 43 scenarios covering happy paths, typed rejections, and boundary inputs, with --self-test skipping the 5 interactive dialog scenarios for 38 unattended ones. The panel caught a bug on a real machine that scripted transport tests could not: every transport test was green while the first real notify was rejected by the facade, because darwin's first snapshot-less notify legally settles deferred and the facade accepted only immediate settles (fixed in 0.30.2).

Long commands settle as transactions

The extension command ABI moves to v2: long-running commands settle through the DeferredOperation transaction, where the broker issues an opaque handle and the extension emits exactly one terminal frame. v1 interoperates fully; existing extensions need zero changes. The broker protocol moves to version 2 in step. npm publishing switched to OIDC trusted publishing with provenance, so the package page shows build provenance.

Other changes

  • Fixed: dialog command wire format. In 0.29.0 and 0.30.0 all four show commands wrapped their payload in a nested options object the native decoder never accepts, so every show command failed against the real extension (issue #8, d4b56bb).

  • Fixed: the deferred settle path for the first notify (7b226d3).

  • Fixed: dialog clicks on an unactivated host. When a macOS accessory host is not activated, clicks are consumed by the activation itself and the modal session never ends; every modal session now activates the host first (issue #10).

  • Docs: five consumer references under skills/opentray and the per-package READMEs shipped with the releases.

  • 82 commits after 0.28.0 (merges excluded, counted by conventional-commit prefix): 15 feat / 26 fix / 19 docs, with the remaining 22 split across chore, tests, and release engineering. No breaking changes in this range.

Upgrade

npm i opentray@0.32.0
npm i @opentray/ext-dialog @opentray/ext-sound @opentray/ext-clipboard @opentray/ext-opener @opentray/ext-notification

Links