Skip to content

Latest commit

 

History

History
1006 lines (855 loc) · 80.1 KB

extensions.md

File metadata and controls

1006 lines (855 loc) · 80.1 KB

Raycast extensions

Tinycast runs Raycast extensions: the same package.json + prebuilt CommonJS bundles Raycast itself produces, rendered natively into the palette. No Electron, no browser, no Node.js.

Invariants

  • At most two commands run concurrently. The palette and scheduled no-view refreshes share one runtime; a foreground launch preempts its background refresh. Menu commands use a separate runtime, serialized by ExtensionMenuBarManager. Every menu session has its own bridge and an immutable extension namespace, so storage, preferences and command launches cannot target the palette's extension. Shutdown cancels pending host tasks; a generation check rejects replies from old contexts.
  • Bridges share one private HTTP transport, never execution state. Cookies, credential storage and URL caching are disabled. Individual task cancellation leaves other requests running; releasing the fetcher invalidates its session so CFNetwork does not retain discarded connections and sessions. Only its session configuration opts out of the AppSSO broker through the private _preventsAppSSO property when its setter is available. Other sessions keep their own authentication behavior.
  • An idle menu item holds no JavaScript. Once isLoading clears, keep only the native button and its snapshot. Reload a fresh context when its menu opens; retain it until the menu closes and any asynchronous action and host calls finish. Never keep a context alive to preserve handlers.
  • ExtensionRuntime's @unchecked Sendable is load-bearing. Every JSContext / JSValue touch happens on its private serial queue, and only plain Sendable values (RenderValue, RenderTree, JSON strings) cross in or out. Keep that boundary.
  • Resources/RaycastRuntime.generated.js is emitted by Scripts/raycast-runtime/build.mjs and committed — never edit it by hand; change Scripts/raycast-runtime/src/ and rebuild.
  • ExtensionScreen is the only place extension row order is decided, so the flat palette selection keeps matching the visible rows — the same invariant every other palette screen holds.
  • Off means off. extensionsEnabled is opt-in, and ExtensionManager.setEnabled(false) stops the foreground and menu commands, removes status items and refresh tasks, discards JS contexts, empties the installed set and clears the launcher rows; refresh() returns early while it is off, so nothing is scanned and nothing is held. Enabling is also consent to run third-party code, so it confirms first and never rides a settings backup.
  • SymbolCatalog reads a system bundle, not API. The list comes from CoreGlyphs.bundle at runtime; every read stays optional and falls back to SymbolCatalog.suggested, and Apple's restricted marks are never offered.

How it works

A Raycast extension command is a prebuilt CommonJS file that keeps react, react/jsx-runtime, @raycast/api and the Node built-ins external. Tinycast supplies those and resolves any third-party packages shipped under node_modules/, then renders the React tree:

  <command>.js  (esbuild output, deps inlined or in node_modules)
        │  require("@raycast/api"), require("react"), require("node:fs"), …
        ▼
  RaycastRuntime.generated.js          ← in the app bundle; React 19 + react-reconciler
        │                                 + the @raycast/api shim + Node/web polyfills
        │  render tree as JSON      ▲  dispatch(handlerId, args)
        ▼                          │
  ExtensionRuntime (JavaScriptCore, private serial queue)
        │  RenderTree / RenderValue (Sendable)     ▲  host calls
        ▼                                          │
  ExtensionManager (@MainActor) ── ExtensionHostBridge ── Clipboard / storage / toasts / fetch / exec
        │
        ▼
  ExtensionScreen → Features/Extensions/* → the palette

Two conventions make the Raycast component surface expressible in a tree:

  • __slot — Raycast passes elements as props (actions={<ActionPanel/>}, detail={<List.Item.Detail/>}, metadata={…}, searchBarAccessory={…}). React never renders an element sitting in a prop, so each shim component re-emits those props as __slot children; the serializer folds them back into the parent's props. That way hooks inside them work and Swift receives them as structure.
  • {"$fn": "<nodeId>:<propName>"} — function props become dispatchable handles. The handler table is rebuilt on every commit, so a dispatch always reaches the callback from the newest render.

Why JavaScriptCore

JavaScriptCore ships with macOS: embedding it costs zero binary size and no vendored C. QuickJS would add ~1 MB plus a build-system detour, for an engine that is slower and no more capable here — the work is not in the interpreter, it's in the @raycast/api shim and the Node surface, which are the same either way. A bare JSContext has the full modern language (checked: Object.groupBy, Array.fromAsync, Intl, lookbehind regex) and nothing else, so the runtime supplies console, timers, fetch, URL, URLSearchParams, Blob/File/FormData, DOMException, TextEncoder/TextDecoder, AbortController, Event/EventTarget, MessageChannel/MessagePort, atob/btoa, ReadableStream/WritableStream/TransformStream and structuredClone itself.

The JS runtime

Tinycast/Resources/RaycastRuntime.generated.js (~200 KB minified) is generated and committed, the same arrangement as EmojiData.generated.swift: building Tinycast never needs Node. Sources live in Scripts/raycast-runtime/:

File What it is
src/index.js the __tinycast object Swift calls into (boot, start, dispatch, popNavigation, settle, fireTimer, stop)
src/host.js the JS→Swift seam: async hostCall, blocking hostCallSync, logging
src/reconciler.js react-reconciler host config that commits into a JSON tree
src/api/components.js every @raycast/api component
src/api/system.js Clipboard, LocalStorage, Cache, Toast, preferences, environment
src/api/enums.generated.js Icon / Color / Toast.Style / … extracted from the real @raycast/api types
src/node-shims.js path, fs, os, child_process, crypto, zlib, util, events, buffer, punycode, …
src/websocket.js the WebSocket global, and the raw socket a bundled ws attaches to
src/dgram.js a UDP socket that answers one thing: an mDNS lookup of a .local name
src/url.js, src/punycode.js, src/buffer.js web/Node primitives JavaScriptCore lacks

Two host-call flavours:

  • Async (invoke) for anything that needs the main actor — clipboard, toasts, window control, fetch, exec. Swift answers later through __tinycast.settle, so the JS thread never blocks on the UI.
  • Blocking (invokeSync) for the synchronous Node shims only — fs.readFileSync, execSync, createHash, gunzipSync. Safe because Swift services these entirely on the JS queue; nothing there touches the main actor, so a blocking answer cannot deadlock.

JavaScriptCore exposes native Atomics but no SharedArrayBuffer constructor. The runtime aliases SharedArrayBuffer to ArrayBuffer so dependencies can allocate their locks at module scope. Native atomic reads and writes still apply; Atomics.wait sleeps on the JS queue for a finite timeout, returns immediately for a mismatched value or nonpositive timeout, and rejects an indefinite wait. There are no worker threads to notify a waiter, so this supports synchronous sleep helpers rather than shared-memory coordination. If native shared buffers are available, both globals stay native.

The Swift host

Tinycast/Features/Extensions/, split the same way as every other feature:

File Role
Service/ExtensionRuntime.swift the JSContext, host-function installation, timers, exception reporting
Service/ExtensionHostBridge.swift main-actor host APIs (clipboard, storage, cache, window, toasts, system)
Service/ExtensionSignInSession.swift the browser step of an extension's OAuth sign-in (ASWebAuthenticationSession)
Service/ExtensionNodeShims.swift the synchronous fs / os / child_process / crypto / zlib services
Service/ExtensionFetcher.swift HTTP over URLSession or a Unix socket, plus async exec collection and the shared PATH resolver
Service/ExtensionWebSocketBridge.swift URLSessionWebSocketTask connections, opened and read from JS
Service/ExtensionNameResolver.swift getaddrinfo, which is how a .local name resolves
Service/ExtensionStorage.swift per-extension LocalStorage, Cache and preference values (one JSON file each)
Service/ExtensionCommandMetadataStore.swift every command's subtitle override, refresh bookkeeping and menu-bar state, in one small file
Service/ExtensionCatalog.swift discovery on disk, install, uninstall, import-from-Raycast
Service/ExtensionCleanup.swift the build workspace's name, the launch sweep, and reclaiming orphans
Service/ExtensionManager.swift the single owner: installed set, foreground session, no-view refreshes, menu-bar manager, launcher entries
Service/ExtensionMenuBarManager.swift serialized refreshes, short-lived menu sessions and their deadlines
Service/ExtensionMenuBarHost.swift immutable per-session namespace and menu-specific host behavior
UI/ExtensionMenuBarController.swift native NSStatusItem and NSMenu rendering and dispatch
UI/ExtensionMenuBarImage.swift small native icons with light/dark variants
Model/ExtensionManifest.swift package.json → commands, preferences, arguments
Model/ExtensionRefreshPolicy.swift background-refresh decisions: interval parsing, due dates, backoff
Model/ExtensionLaunchType.swift userInitiated / background, mirroring @raycast/api LaunchType
Model/RenderNode.swift the decoded render tree (RenderTree / RenderNode / RenderValue)
Model/ExtensionAppearance.swift the per-extension icon override and its tint palette
Service/ExtensionAppearanceStore.swift where those overrides persist
Service/SymbolCatalog.swift the SF Symbol list read from CoreGlyphs.bundle
UI/ExtensionScreen.swift flattens one screen into the palette's row order
UI/ExtensionCommandScreen.swift that order adapted to PaletteScreen, so the flat selection indexes it
UI/ExtensionCoordinator.swift launching, leaving, and every host callback that touches a surface

ExtensionRuntime is @unchecked Sendable deliberately and narrowly: every JSContext / JSValue touch happens on one private serial queue, and only plain Sendable values cross in or out (RenderValue for arguments, RenderTree for output, JSON strings for results). That keeps extension evaluation and the blocking shims off the main actor.

One foreground command at a time, one context per command. Starting a foreground command stops its predecessor and throws the whole JSContext away; the next launch boots a fresh one (~7 ms warm, measured). Scheduled no-view refreshes borrow this runtime while the palette is idle and yield to a foreground launch. Menu commands use a separate transient lane owned by the extension feature.

Reusing a context was subtly broken. Timers are global and React's scheduler drives every commit through setTimeout, so cancelling an extension's leftover timers on teardown also cancelled the scheduler's — which latches isMessageLoopRunning and silently stops every later session from committing. The symptom was a command that worked once and then hung on "Starting…" forever. Leaving the timers alone instead leaks any interval an extension forgot to clear. Discarding the context avoids both, and as a bonus no module-level state in an extension bundle survives into its next run.

Each runtime owns its bridge. Foreground calls use ExtensionManager.activeExtensionName; a menu bridge uses ExtensionMenuBarHost's captured extension name. A late response cannot settle a call in another context, even if JavaScript reused its numeric call ID.

Menu bar commands

Run a menu-bar command once from the launcher, or turn on Show in menu bar in its configuration. Installation alone never runs it, including commands without disabledByDefault. The command's interval accepts seconds, minutes, hours and days, with Raycast's ten-second minimum. One sleeping Swift task wakes for the nearest deadline; refreshes never overlap, and waking from sleep does not replay missed intervals. Scheduled refreshes coalesce without replacing queued explicit launches or their arguments and context. Commands without an interval run only on request or when opening their menu.

MenuBarExtra renders its title, icon and tooltip in a native NSStatusItem. Its menu supports items, submenus, section headers, separators, subtitles, tooltips, shortcuts (including macOS-specific shortcuts), and Option alternates. Items without actions and empty submenus are disabled. Actions receive left-click or right-click; keyboard activation is a left click. Asynchronous actions are awaited before teardown, including overlapping actions after reopening the same menu. Opening another menu queues its runtime until the current work finishes. Returning null removes the item while keeping its refresh schedule. Opening during a background refresh reuses that session and enables interactive confirmations and HUDs for its actions. Its original JavaScript launch type still describes how the session started.

The menu is attached to the status item, so AppKit owns tracking: it positions and dismisses the menu, and a click on any other status item hands off to that item the way two native menus do. Attaching it once at init rather than per snapshot keeps the first click working before any render arrives. Native rows and small icons stay prepared between runs; teardown clears handler IDs while preserving action appearance. A click before the new runtime is ready waits for a fresh callback with the same section/submenu path, label and shortcut, including an alternate's primary item. Changed or ambiguous items ask the user to reopen the menu instead of dispatching an old handler. Opening reuses those rows while a fresh context loads. Only a menu without prepared content shows a loading row, prepared before native menu sizing. Subtitles follow the title on the same line. React updates reconcile text and callbacks immediately, fill reserved icon slots asynchronously, and preserve settled content while loading. Button changes wait until the menu closes so its anchor does not move under the pointer. The session stays alive while the menu is open. After a settled render or menu closure, a 100 ms coalescing delay lets React commit effects and host calls drain before releasing the context; a further 50 ms after the drain lets their results render. The runtime queue drains temporary Objective-C objects after each work item, including context teardown. Loading and closed-menu actions have a 60-second deadline; an open, settled menu is exempt. This bounds asynchronous work, but cannot interrupt an extension stuck in synchronous JavaScript or a blocking Node shim on the runtime queue.

A saved button restores after relaunch without executing JavaScript; only its next due refresh boots the runtime. Activation and the saved button live on the command's own record in extension-commands.json, which is channel-local Application Support data and is excluded from settings backups. The command's Show in menu bar toggle, uninstall, and disabling extensions all tear down the corresponding native items and work. Removing a menu item leaves the extension's other commands installed. Only explicitly activated commands have saved records.

launchCommand preserves type, arguments and JSON context. Background menu refreshes and explicit background no-view launches use the transient lane at utility priority and leave the palette alone. A background launch never activates a menu command; it only refreshes one already shown. A user-initiated view launch from a menu opens the palette. Menu toasts are suppressed; errors appear in the menu and user-initiated failures also use the HUD. updateCommandMetadata publishes subtitles for the executing command, including menu commands, without changing another runtime's command.

Menu-bar icons retain successful small raster variants and let AppKit choose the drawing appearance. Failed loads retry on the next session, never on each React render. Native rows retain no render tree. Do not observe the status button's effectiveAppearance to redraw: AppKit temporarily changes it when rendering replicas, which would schedule another redraw indefinitely.

Reference contracts: Menu Bar Commands and Background Refresh.

Rendering

ExtensionScreen is the single source of truth for row order, so the flat selection index the rest of the palette relies on maps 1:1 onto visible rows — the same invariant the launcher, clipboard and emoji screens hold (see palette.md).

  • List / Grid — sections and items flattened in render order. When filtering is on (Raycast's default unless the command supplies onSearchTextChange) rows are filtered with the launcher's own FuzzyMatch over title, subtitle and keywords, and a section whose items all drop loses its header too. isShowingDetail splits the screen into rows plus a detail pane and drops each row's subtitle, but not its accessories: the API only advises an extension against sending them in this mode, and Raycast draws the ones it is sent, so suppressing them here would lose a row its whole signal. As in Raycast, they keep their full width and the title is what truncates. ExtensionScreen.Item carries both the flat selection index and the scroll id, and is the ForEach identity of the row and the grid cell alike — see the scroll-id rule in ui.md. A matching selectedItemId seeds the palette highlight when the screen first appears. onSelectionChange is reported with that visible item's string id. The observer keys on the id, not just the numeric index, because local filtering can replace row zero without changing the palette selection; an empty result reports null, matching the API contract.

  • Search-bar dropdown — List.Dropdown and Grid.Dropdown draw as ExtensionSearchAccessoryButton at the header's trailing edge and drop ExtensionPickerList as one of the palette's OpenMenu cases, so the arrows, ↵, Escape and the click-away come from the one menu path and no second key handler exists to disagree with it. PaletteFilterAction routes ⌘P, so a command's own dropdown answers before Tinycast's clipboard filter can. The list is listWidth (240) rather than a form picker's field width: it hangs off a chip, not a field. Its native search field sits above the choices and uses the palette menu's fuzzy matcher. Swift owns the selection — the runtime keeps makeSearchDropdown hook-free so an extension may call List.Dropdown({…}) directly — so ExtensionManager.accessoryValues keys it by render-node id and seedSearchBarAccessory reports the opening choice through onChange on the first commit, as Raycast does; without that, a command filtering its rows by the value renders nothing (issue #511). A value prop makes it controlled: the extension holds it, nothing is seeded, nothing reported. storeValue parks the pick in ExtensionStorage.accessoryValues — host UI state, outside the LocalStorage namespace JavaScript reads, and gone when the extension is uninstalled.

  • Grid tiles — ExtensionGridLayout reads the Grid's columns, aspectRatio, fit and inset and is the one place tile geometry is decided. A tile is a column wide and aspectRatio tall, and its content is scaled to that tile rather than drawn at an icon size, which is what makes an image-heavy grid (GIFs, logos) look as it does in Raycast; inset is the extension's own knob for pulling small artwork back in, so never compensate for a too-large tile by clamping the content. The grid is measured once for every cell — a tile that measured itself would cost a layout pass each. A symbol or glyph has no artwork to scale, so it takes a share of the tile; Grid.Section props are not read, since the grid draws one column count throughout. A tile may be a bare {color} swatch instead of an image, stated in any notation ColorValue reads — a colour picker writes oklch(), not hex.

  • Detail — markdown rendered block-by-block (headings, lists, code fences, quotes, rules, tables, fetched and inline images) with AttributedString handling inline styling, plus Detail.Metadata — a sidebar on a Detail screen, appended below the markdown in a List's detail pane. An image draws at its own size, shrunk to fit the pane and never enlarged, unless ?raycast-width= / ?raycast-height= on its URL, read by ExtensionImageSize, size it. A rowless Detail's screen actions remain available through the primary ⏎ action and the ⌘K Actions panel.

  • Appearance — environment.appearance reports the real one, so an extension that branches on it is told the truth. It is an injected field on ExtensionLaunchContext (a Model/ type owns no environment), which means a running command keeps the appearance it booted with; a change reaches it on the next launch. A {light, dark} icon or colour is picked by ExtensionImage.resolve(_:assetsPath:isDark:), whose isDark comes from the view's \.isDarkAppearance so the pick re-renders when the surface flips; either side stands in when an extension supplies only one. {fileIcon: path} is its own source: the path names a bundle or document whose Finder icon is wanted, so it goes to NSWorkspace rather than being decoded as an image file — an .app has no bitmap to read. A data: URL is a source of its own too: an extension that renders its own SVG hands over the bytes, so they are decoded inline rather than fetched. A tintColor on any of them draws the image as a template, which is what colours an SVG written against currentColor. A raycast-* colour name inside that SVG is rewritten to rgba(…) during the decode, in ExtensionIconCache.loadInlineAsync: the name is legal wherever a Raycast tint is, so extensions write it straight into stroke, and no SVG renderer knows it — left alone the shape draws nothing at all. It happens there rather than in resolve because resolve runs in a body and the decode already runs detached, and because the markdown images a Detail draws never pass through resolve at all. The palette is handed in as [name: css], resolved once per appearance by ExtensionImage.svgPalette(isDark:) — a Color can only be flattened to sRGB on the main actor, which is exactly what the decode must not touch. Anything reading a decoded image keys its .task on ExtensionImage.LoadKey, since the URL alone no longer says what will be drawn. The feature's own fills live in ExtensionColors — never in Theme.

  • Form — label-left/control-right rows. Field values live in the extension (React owns them); every edit dispatches onTinycastChange and the resulting re-render is what updates the control, so defaultValue, a controlled value, and ref.reset() all behave. A form takes the whole keyboard: its fields are the palette's rows, so the search field is hidden and the header left empty. ExtensionFormField says what each Form.* node is — which of them focus lands on, which keys the control keeps, and which need a focus ring drawn — and ExtensionScreen publishes exactly the focusable ones as items, so ↑/↓, ⇥/⇧⇥ and the flat selection all walk one order. ⇥ wraps at both ends, ↵ opens a closed picker then commits its choice, while ⌘↵ submits the form from any field. Return and keypad Enter behave alike; holding either never repeats an activation or submission. Space or ↵ toggles a checkbox and opens a file picker, ←/→ step a dropdown's value and a tag picker's chips, and a text area keeps ↑/↓ for its own lines so only ⇥ leaves it. A field marked autoFocus is where the form opens, otherwise the first one. While a control holds focus PaletteState.isEditingField is set, which is what keeps a bare backspace deleting text rather than backing out of the command. The footer's Actions half is drawn only when the panel holds more than the one action the ⌘↵ pill already runs, so a plain Submit-only form shows just the pill.

    Every control is drawn by the feature, none by SwiftUI's stock parts. ExtensionFieldChrome is the one rounded surface they all share and ExtensionFormMetrics the one place their geometry is stated, so a field, a picker and a text area line up by construction. A Picker opens only to a click and a DatePicker has no expression field, which is why neither is used.

    Form fields use a 12pt corner radius and the same neutral border colour at rest and in focus; focus thickens the border to 2pt without changing the fill. These values stay extension-owned, independently of the native launcher forms. Editable text controls receive the I-beam from their visible native bounds.

    Their height is 34pt, with 12pt horizontal and 8pt vertical text insets. Their width matches native single-column forms at every Interface Size, with the geometry restated locally rather than shared.

    A Form.Dropdown and a Form.TagPicker are the same control — ExtensionPickerField — differing only in whether it holds one value or several. It drops ExtensionPickerList, a searchable list, and the control keeps first responder the whole time it is open: the list is a separate window, and a second field inside it would take focus off the control and close the list. So the popover's search row renders the query rather than editing it, and every key — the arrows, ↵, esc, ⌫ and each typed character — is claimed on the control. PaletteState.isControlListOpen is what keeps the palette's own arrow and Escape handlers out of an open list; without it ↓ moved the form's selection instead of the list's highlight.

    The list is hosted in a window of its own, ExtensionListPanel, exactly as the ⌘K menu is by MenuPanelController. Glass samples what lies behind the window it is in, so a list drawn as an in-window overlay sampled the form and read as a different material however its fill was tuned. With its own borderless child panel it samples the desktop, and a picker and the actions menu are the same surface by construction rather than by matching. It keeps the panel's row pitch, icon slot and overflow fade; overflowing lists keep the native elastic boundary, while short lists do not bounce against empty space. Its menu symbols are 14pt Medium and monochrome unless the extension supplied a tint. A focused control takes the system accent edge that Settings and the shortcut recorder already draw. Opening a long list reveals its current selection; updates to row titles, icons, sections and date details refresh an open panel even when row IDs stay the same. Its metrics are restated in ExtensionFormMetrics rather than read off the panel: an extension's surfaces own their own, and a launcher change must never move a form.

    The query is typed into the control, not into the list. The one field editor belongs to the palette's search field, so a picker draws its own caret (ExtensionCaret) and renders what has been typed in place of its value — the text appears where the eye already is, and a multi-select keeps its chosen values beside it. ExtensionQueryText overlays the caret on the text's edge, so the prompt and the typed query start exactly where the closed control's value does, and the caret is stepped by a timer at AppKit's own rate — a repeatForever animation fades where a real caret switches. The list is results only.

    Form activation keys use ExtensionFormKey, applied by ExtensionFormKeys to each field. The palette defers to focused fields; an open Actions menu and IME composition keep precedence. ExtensionListKey handles list navigation and search editing. A stack of separate onKeyPress modifiers let a character rule shadow ⌫, and ⌫ arrives carrying U+007F rather than the U+0008 SwiftUI's .delete names, so nothing was ever deleted from a search. Both spellings are answered before characters are considered at all, and the rules are pure so ext-form-test drives them.

    ExtensionDateField is the same shape over ExtensionDateExpression, which parses what Raycast's date field parses — "tomorrow at 10am", "in 3 days", "next friday", "25 dec" — and offers the same presets. It is pure and takes its clock and calendar as parameters, so ext-form-test drives it and the popover's flip-up rule directly.

    A picker opens downward, or upward when the form's bottom edge would cut the list off, which is ExtensionFormMetrics.placement applied by ExtensionListPlacement against the palette's own frame in screen space, so a list can overhang the form's scroller but never the window. The chevron points the way the list actually went. Scrolling its control out of view closes the list, and so does a press on bare form, which the form catches behind its fields.

    React answers a keystroke a render late, so a value echoed back mid-word is older than what has been typed since. Both text controls hold the last edit they dispatched and ignore every echo until it catches up; without that, typing at speed dropped characters — "Test from Codex" arrived as "T Codex".

    Every control carries its title as an accessibility label and its selection as a value, so a picker announces "Difficulty, Easy" rather than the chevron it is drawn with. It reads under the pointer as well as the keyboard: controls lift on hover, a list's rows highlight under the mouse so both share one selection, and clicking a control takes focus as well as acting, which is what lets the two be mixed mid-form.

    Tests/ext-form-test.swift drives activation rules, geometry and the parser; earlier interaction checks used a Form Lab extension covering every control, sectioned and empty and 40-option lists, validation errors, wrapping labels, and forms taller than the palette, in both appearances.

  • ActionPanel — flattened (sections and submenus included) into ExtensionActionsPanel, the feature's own scrolling ⌘K panel. A separator marks each change of ActionPanel.Section node, titled or not, including to or from loose actions. A submenu's actions stay in their section, and an empty section draws nothing. Its rows are ExtensionActionItem, not PopoverMenuItem: an action's icon is a full ImageLike, so it resolves through ExtensionImage like every other extension icon and keeps its tintColor — which is what makes a palette of {Icon.Circle, tintColor} rows read as colours rather than a column of grey circles. Untinted symbols use the extension's 14pt Medium monochrome treatment; a destructive action with no tint of its own falls back to red. Section boundaries add the list inset (8pt) above and below their separator without moving ordinary rows; a capped panel ends mid-row, so its edge never lands on a separator, and every hairline is one device pixel. The title shares the elastic scroller with the actions. A native, row-height search field below it filters titles through the launcher's fuzzy matcher, preserves section boundaries and centres No Results in one row when empty; the scrolling edge beside that field has no dissolve. The 8pt resting inset scrolls with the actions, so rows can reach the panel edge without shifting their initial position; hover keeps the shared 10pt menu-row corner. The panel opens and closes from its bottom-right attachment with extension-owned opacity and scale timing, briefly reaching 1.003; its attached corner matches the footer button. The first action is the primary ↵ action; an action's own shortcut is matched against modified keystrokes, with the panel open or closed. ExtensionCommandScreen.menuContent hands the whole panel to the palette as a PaletteMenuContent, so the palette never learns the row type — and a row's handler is taken from the flattened ExtensionAction list rather than the drawn rows, so ↵ and the panel fire the same one without resolving an icon per arrow key. Header accessory symbols use the same 14pt Medium monochrome treatment; their menus use the same extension-owned transition, anchored to the control.

  • Feedback — showToast replaces the current toast, a glass pill that takes the footer menu button's place and is lit by its style's colour. Hovering turns its mark into an ×, and clicking anywhere but its button dismisses it and gives the menu button back. A failure toast's button is always Copy (title and message); any other style shows the command's primary action; showHUD is a centred pill, and confirmAlert goes through DialogController like every other question the app asks. Its dialog sits at .dialog, above the palette's .palette, so a view command keeps its screen behind it — and the palette does not dismiss while it is up (AppCore.isShowingDialog), because dismissing pops to root, which would tear the command down before its await confirmAlert(…) ever returns.

  • Command arguments — a command declaring arguments shows inline fields sized to their placeholders, right after the typed text. Tab walks search field → each argument → back; Left/Right do the same only when their caret reaches a field boundary. Returning to the search field selects its query, so Right first places the caret at its end and then enters the first argument. ↵ from any of them runs the command with the values as props.arguments; a blank required argument blocks the launch and focuses the offending field. The fields get their own FocusState rather than joining the search field's, so the palette's one always-attached TextField (see palette.md) keeps owning focus.

    Every declared argument is sent, empty string when unfilled (ExtensionCommand.completeArguments). That is Raycast's contract and extensions depend on it: Number(args.seconds) is 0 for "" but NaN for undefined, so omitting a blank argument silently corrupts whatever they compute — Coffee's "Caffeinate for…" spawned caffeinate -t NaN, which exits instantly.

Escape clears a non-empty search field first, and dispatches onSearchTextChange as any other edit would, so a command that took the search text over sees the empty string. Only over an empty field do Escape and a bare backspace pop the extension's own navigation stack, and only leave the command once it's at its root. Pushed screens stay mounted, so popping back restores their state. Each pushed screen starts with an empty search; going back restores the parent's query and selected row.

Turning it on

Extensions are off until asked for, and the switch is a real one rather than a filter: while it is off no directory is scanned, no launcher row is published and no JavaScript context exists. Turning it on confirms first — it is consent to run third-party code, and a running command holds a JavaScript engine in memory until you leave it, which is the one standing cost this app has.

Show in launcher is separate, and independent: it decides whether the commands reach launcher search at all, without unloading anything. Below it, each extension has a Show in launcher switch of its own, and each command a checkbox beside its shortcut — an extension ships many commands, and a user often wants a few. Both write VisibilityStore, so a hidden command keeps its shortcut and ⇧⌘H in the launcher unticks the same checkbox. The extension's switch reads on while any command is shown, and flipping it shows or hides every one.

A published row carries the extension's own title in AppEntry.ownerName, which both labels the row and makes the extension a keyword for every command it ships — lucide finds Search Icons. It is matched in the launcher's weakest literal band, so a third-party title can never take a query from a real app; see launcher.md.

Installing extensions

Extensions live in ~/Library/Application Support/<bundle id>/extensions/<name>/, keyed by bundle id like everything else, so a Debug build never shares installs with a release channel. A directory holds package.json, assets/, one <command>.js per command and, when the build externalised dependencies, a node_modules/ of pre-built packages — byte-for-byte the layout Raycast's own build produces.

Settings → Extensions offers four routes, under Install New:

  1. Search extensions — searches the Raycast Store and installs the bundle it already built. Nothing is compiled, so no Node or package manager is involved. The search is raycast.com/frontend_api/extensions/search, the endpoint the store's own site uses; it is unofficial, so Install from GitHub is the way in when it changes.
  2. Install from GitHub — builds one extension from its source on this Mac. See below.
  3. Import from Raycast — copies the already-built bundles out of a local Raycast. Nothing is compiled, so no Node, npm or network is involved. The pane also scans whenever it opens, and says so when Raycast has something Tinycast doesn't — installing in Raycast otherwise leaves no trace here. Both channels are searched: ~/.config/raycast and ~/.config/raycast-x, the latter being Raycast Beta v2. Checking only the first reported "no Raycast install" to every Beta user, whose stable directory is present but empty. The same extension in both is offered once.
  4. Add from folder — pick any directory with a manifest and built command files, e.g. an extension you just ran ray build in.

Only package.json, the built commands, assets/ and any bundled node_modules/ are copied — never the multi-megabyte .js.map Raycast writes beside each bundle. Store dist bundles leave third-party packages external and ship them, pre-built CommonJS, under node_modules/; the embedded runtime resolves those from the command's directory, never above the extension's own folder, so they must survive the install. A build that inlined everything simply has no such folder.

Installing from GitHub

The panel takes owner/repo, a clone URL, or the /tree/<ref>/<path> link a browser copies from an extension's folder — ExtensionGitHubSource parses all three. A bare repository builds its root on HEAD, which follows the default branch whatever it is called. The package manager and custom search paths sit in the same panel, because only this route needs them.

Only the extension's own folder is ever fetched. raycast/extensions is gigabytes; cloning it to install one extension would be absurd.

Downloading is a walk to the folder's tree, then one recursive listing. The contents API caps a directory at 1000 entries without saying so, and costs a call per directory against GitHub's anonymous budget of 60 an hour per IP — Color Picker has 17 directories, so an install used to spend 18 calls and three of them exhausted the hour. Walking <path> to its sha and asking for that tree with recursive=1 costs one call per path segment plus one, whatever the folder holds, and the file bodies come from raw.githubusercontent.com, which the API budget does not count. A truncated listing is a prefix, so it throws rather than install part of an extension. A 404 from the API is reported as a missing repository or branch: anonymous requests cannot tell a private repository from no repository.

Installing runs <package manager> install --ignore-scripts, then node_modules/.bin/ray build -e dist -o <build dir> directly — never the manifest's build script. That script is ray build, whose default environment is dev, and dev mode installs into the local Raycast rather than emitting anything. The build reported success and exited 0 while writing nothing beside the manifest, so every source install failed afterwards with "no built command bundles", and each attempt quietly added the extension to the user's own Raycast.

-o points at a sibling build/ directory, never at the source. ray clears its output directory first, so aiming it at the source deleted assets/ before the install could copy it — the extension arrived with no icon. Building into its own directory leaves the source intact and yields exactly the layout ExtensionCatalog.install expects: package.json, one <command>.js each, and assets/. What it installs from is that directory, not the source.

Only the build survives. Source, node_modules and build all live in the install's workspace (below), which a defer removes whichever way the install ends. Closing the panel cancels the install: the running child is terminated and the workspace goes with it, so a cancelled build leaves nothing behind.

Child output is drained while the command runs, retaining its last 64 KiB for error reports. Trimming compacts the buffer's backing storage so verbose builds cannot retain every byte they wrote.

An extension carrying a Rust package builds -e dev instead. A rust: helper is Raycast's Windows counterpart to swift:, and dist cross-compiles it with cargo xwin for x86_64-pc-windows-msvc — a toolchain nobody on macOS has, so Color Picker failed its whole build on a binary it would never load. dev is the environment whose Rust plugin skips it and emits the stub that throws on use; the Swift helper still compiles. ExtensionInstaller.environment(for:) picks the environment by looking for a Cargo.toml, so every other extension keeps dist's minification, external source maps and type check.

-e dist also type-checks, so an extension that does not compile now fails at the build rather than at the copy. An extension without ray falls back to its own build script and installs from the source, which is the only contract such an extension offers. Lifecycle scripts are skipped on purpose: the build script is the contract, a postinstall is code nobody asked to run. The package manager is Automatic by default, which takes the first of pnpm, Bun, Yarn and npm that is installed — a GUI app inherits none of a login shell's PATH, so ExtensionPackageManager.searchPaths is where they are looked for, version managers included (Homebrew, Volta, asdf, mise, fnm, nvm, Yarn).

That hardcoded list can never cover every toolchain layout — Nix among them — so the panel also has "Custom search paths": a :-separated list, extensionCustomSearchPaths in AppSettings, checked before the built-in list wherever it resolves a package manager or Node. Set once, it applies to every future install. ExtensionInstaller takes it as additionalSearchPaths rather than reading settings itself, keeping the Model/Service split intact.

Neither the package manager nor the custom search paths ride a settings backup: the first names a tool the machine an import lands on may not have, and the second is a set of paths specific to this Mac's toolchain layout.

Updates

Only store extensions update; a GitHub or folder install is the user's own copy, and reinstalling it is how it changes. The check runs when Settings › Extensions opens, and at no other time.

ExtensionVersionStore records the store's commit_sha for each store-sourced extension in extension-versions.json, because nothing installed carries a version: neither the store's zip nor Raycast's own copy has one in its package.json. A store install records the listing's commit. An import from Raycast records an unknown version, which the next check adopts from the store — Raycast keeps its own copies current, so that is what an import holds. A folder or GitHub install removes the entry, and an extension with no entry is never checked.

A check looks each tracked extension up by GET /api/v1/extensions/<handle>/<name>, where the handle is the manifest's owner when it has one and its author otherwise. A different commit is an update. A lookup that fails is skipped rather than reported, so a flaky network never invents an update. Updating is a store install of that listing, which replaces only the extension's directory — its preferences, storage and icon carry over.

Shortcuts

A global shortcut binds to a command, not to an extension — a shortcut has to land on one thing to run, and an extension is a set of commands. HotKeyAction.extensionCommand is keyed by the launcher entry id (extension:<extension>/<command>).

A view command summons the palette when the shortcut fires while it is hidden, or it would load behind a closed window. A no-view command still hides it and reports through its HUD.

Its index is not pruned at launch the way the UUID-keyed ones are: the installed set is scanned asynchronously and only while extensions are on, so at launch "not installed yet" and "gone" look identical, and pruning there would quietly drop a working binding. Uninstalling clears its own instead, along with the extension's stored preferences and its chosen icon.

Aliases

A user alias binds to a command, keyed by the launcher entry id (extension:<extension>/<command>) — the same key the shortcut, favorite and ranking stores use. Settings › Extensions › the command › Alias is the writer; AppIndex already ranks it as .userAlias. The field sits beside the shortcut recorder on the command's title row, the same pairing Settings ▸ Commands uses. It dims when the command is hidden from launcher search — the global Show in launcher switch, or this extension's — because the ranker never sees the entry then.

Deeplinks

Tinycast registers raycast, com.raycast and its own scheme to receive extension links. The own scheme is tinycast, or tinycast-dev in Debug; ExtensionDeepLink.claims accepts both, and Copy Deeplink builds an extension row's link in the running channel's scheme. An installed Raycast competes for its schemes, and macOS chooses which app receives a link.

raycast://extensions/<owner>/<extension>?source=webstore, used by the Store website's Install buttons, opens Settings → Extensions and looks up that exact listing to offer the existing Install or Reinstall action. Nothing installs or runs until asked. When extensions are disabled, the link opens their Settings pane so they can be enabled through the usual consent flow.

raycast://extensions/<owner>/<extension>/<command> runs an installed command from outside the app — a browser link, another app, a Shortcut — and tinycast:// mirrors it so our own links never depend on Raycast winning the scheme. Both accept Raycast's query parameters: arguments as URL-encoded JSON, fallbackText, context (or launchContext) as a URL-encoded JSON object that arrives as props.launchContext, and launchType=background, which only a no-view command receives — a view command always takes over the palette, so it launches as userInitiated. The owner is a hint: a scoped install matches by owner/extension first and falls back to the bare slug, so short links keep working. Anything else on a Raycast scheme just reopens the palette, and an unknown command says so rather than failing silently. Every other tinycast:// link is a built-in's, and never reaches ExtensionDeepLink. ExtensionDeepLink owns the claimed schemes and the parsing, covered by Tests/ext-test.swift. Two-segment links without source=webstore keep running commands as <extension>/<command>; three-segment command links keep running even with that parameter. An extension's own open("raycast://…") resolves through the same ExtensionManager.resolve(_:) instead of launching Raycast. Copy Deeplink on a command's launcher row, or on its Settings shortcut field, writes the tinycast://extensions/… form, with any typed arguments as arguments; built-in rows have their own links, in deeplinks.md.

For view commands, nonempty fallbackText also prefills the search field: lists and grids filter locally or receive it through onSearchTextChange when their handler mounts. It remains available in the view command's launch props as well.

Background refresh

A no-view command declaring interval ("90s", "1m", "12h", "1d") re-runs headlessly on that schedule, on the semantics extensions are authored against: the same bundle runs to completion with environment.launchType and props.launchType set to Background, and updateCommandMetadata is the only thing that escapes it — the subtitle it writes appears beside the command's name in launcher search, unless it merely restates the owning extension, which the row already carries on the right. Coffee's "Caffeinate Status" is the reference case: every minute it rewrites its subtitle to ✔ Caffeinated (…) or ✖ Decaffeinated.

Refresh is opt-in per command: off until the first manual run or the Settings toggle (Settings › Extensions › the command › Background refresh), which also shows the last refresh and the last error. The launcher row carries the state too: a dot while refresh is on, its dimmed twin while it is off, a warning with the error as its tooltip when the last background run failed, and the Actions menu offers Enable / Disable Background Refresh plus Refresh Now. Refresh Now never queues: while another command holds the runtime it shows a HUD saying so, since a deferred run could fire long after the click, when a foreground command finally closes, with nothing to show it ran. The override lives in extension-commands.json — derived state, so no backup carries it — and uninstall removes an extension's records with everything else. Deliberately not in extension-data/<name>.json: drawing a launcher row reads every command's metadata, and that file holds the extension's whole Cache.

The scheduler is one loop doing date math, not one timer per command: close ticks run as a single batch, installs share a deterministic phase so they don't re-fire in lockstep after sleep, and a wakeup with nothing due costs a comparison. Three guards keep it cheap:

  • Intervals clamp to a minute; failures back off exponentially to a day.
  • A tick never preempts a running command — foreground first, the tick waits for the next due.
  • A hung run dies before its successor is due, and a background run shows no toast, HUD, alert or window call, since those would fire on a timer.

ExtensionRefreshPolicy is where the parsing, due dates and backoff live, driven by Tests/ext-refresh-test.swift; Tests/ext-metadata-test.swift covers the store behind it. Menu-bar commands run on their own lane in ExtensionMenuBarManager but read the same policy — nextDue, its failure backoff and its per-command phase — measured from the same lastRun, with a ten-second interval floor instead of sixty.

What's supported

Components — List (+ Item, Section, EmptyView, Item.Detail, Dropdown), Grid (+ Item, Section, EmptyView, Dropdown), Detail (+ Metadata with Label, Link, TagList, Separator), Form (TextField, PasswordField, TextArea, Checkbox, Dropdown, TagPicker, DatePicker, FilePicker, Separator, Description), ActionPanel (+ Section, Submenu) and Action with every convenience variant (CopyToClipboard, Paste, OpenInBrowser, Open, OpenWith, ShowInFinder, Trash, Push, SubmitForm, PickDate). Deprecated aliases (ActionPanel.Item, Form.DropdownItem, CopyToClipboardAction, …) are present too — shipped bundles still use them.

APIs — Clipboard, LocalStorage, Cache, environment, getPreferenceValues, showToast, showHUD, confirmAlert, closeMainWindow, popToRoot, clearSearchBar, open, trash, showInFinder, getApplications, getDefaultApplication, getFrontmostApplication, getSelectedText, getSelectedFinderItems, launchCommand, updateCommandMetadata, openExtensionPreferences, OAuth, useNavigation, Icon, Color, Image.Mask, Keyboard.Shortcut.Common, LaunchType.

raycast:// URLs — extensions address Raycast by scheme; the most common is a bare open("raycast://") to bring the window back after something stole focus (1Password's auth flow does this). ExtensionHostBridge keeps those inside Tinycast: raycast://extensions/<author>/<extension>/<command> runs that command when it's installed, anything else reopens the palette. Handing them to the workspace would launch Raycast itself.

OAuth — PKCEClient builds the RFC 7636 request in JS, and @raycast/utils' OAuthService runs on top of it unchanged. The browser step is ExtensionSignInSession: providers have Raycast's fixed redirect URIs registered, which all end on raycast://oauth or com.raycast:/oauth, and ASWebAuthenticationSession intercepts that scheme itself, so an installed Raycast cannot take the redirect. On the web method state is an envelope — nonce, provider name and callback scheme as base64url JSON — because the hosted redirect page reads its target scheme from it; every other method sends a plain random nonce. Whatever state the outgoing URL ends up with, an extraParameters override or a custom { url } included, is the value checked: the redirect must echo it back once, unchanged. Each provider's token set lives in the extension's data file, beside its preferences, keyed by providerId. Only a command the user opened can start a sign-in; a background refresh fails until then. While the browser is up the palette hides, but Pop to Root Search holds off, because resetting would stop the command and cancel the sign-in. Summoning the palette meanwhile returns to the waiting command, and the palette comes back on its own when the redirect lands. Leaving the command cancels its browser session and finishes the pending host call without waiting for a browser callback, so the sign-in hold cannot outlive the command. A browser session that refuses to start fails immediately; duplicate or late callbacks cannot finish it twice.

Node built-ins — path, fs (+ fs/promises, createReadStream/createWriteStream, a snapshot-backed opendir, and the descriptor calls tar unpacks through), os (networkInterfaces reports every MAC as zeros), child_process (exec, execFile, execSync, execFileSync, spawnSync, and a streaming spawn, each async form reporting the child's real pid for process.kill — Timers pauses that way), crypto (hashes, HMAC, PBKDF2, AES-CBC/ECB, AES-GCM with 16-byte tags and nonces of 12 bytes or more — CryptoKit's limits, narrower than Node's — random, UUID), zlib (gzip/zlib/raw deflate, both directions, plus create* streams that buffer until end), http/https (request, get and Agent, buffered over the same URLSession bridge as fetch, with socketPath for Unix-socket HTTP), stream (Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, plus stream/promises and stream/web), util, events, buffer, url, querystring, punycode, assert, string_decoder, timers. Every other built-in resolves to a stub that throws only when used, so a bundle that merely references http2 or domain still loads. Those stubs are manufactured lazily, but each module still has to enumerate its members as own keys: esbuild's __toESM — what every namespace or named import compiles to — snapshots own keys instead of reading through the proxy, and a member it cannot see arrives as undefined, which class … extends reports as TypeError: The superclass is not a constructor at import time, naming nothing. async_hooks hands out a real AsyncLocalStorage and AsyncResource rather than a stub for the same reason: undici extends the latter at module scope, and running the callback in place is the whole of it here.

Buffer.allocUnsafeSlow uses the same zero-filled, independent allocation as allocUnsafe. Its presence lets bundled safe-buffer select the modern Buffer API rather than calling the class as a legacy function. EventEmitter.once passes the emitter as the listener's receiver, like on, and removes the listener before invoking it; bundled duplex streams rely on that receiver at end.

WebAssembly — compile, instantiate and their streaming forms run through the synchronous Module and Instance constructors. JavaScriptCore settles the promise forms from a run-loop timer on the thread that owns the VM, and the runtime's queue never spins one, so they stayed pending forever. sql.js loads that way; Zotero is the reference case, whose Search Database sat on Loading… with no error.

Streams — the stream core is Node's real contract, not a stand-in: an extension that ships stream-chain and stream-json to walk a package index builds object-mode pipelines out of it, and Homebrew is the reference case. fetch responses expose body as a ReadableStream, so pipeThrough → Readable.fromWeb → pipeline → fs.createWriteStream works end to end. The bytes have already arrived by then, though: the "stream" hands out a buffered response in reader-sized pieces, so a progress callback reports how much has been written, never how much has been received. One shortcut inside Transform: it acknowledges a write as soon as _transform calls back rather than waiting for room on its readable side, so only a transform nobody reads from can grow unbounded.

url.fileURLToPath decodes percent-escapes the way Node does on darwin, so an asset path carrying a space resolves to a file the image loader can open, and it rejects an encoded separator or a non-local host rather than returning a wrong path. Node's windows override is absent: Tinycast only runs on macOS, so drive-letter and UNC output would be unreachable. url.pathToFileURL escapes ? and # so a filename holding either survives the round trip.

The fs functions hand URL arguments to that same validator: a URL whose scheme is not file: throws ERR_INVALID_URL_SCHEME instead of degrading to its pathname, and fs.existsSync counts that as absence, like Node. Raycast's Visual Studio Code extension leans on the guard — a vscode-remote:// workspace whose stripped pathname exists locally (an SSH host opened at / always does) would otherwise pass isFolderEntry and reach fileURLToPath, which took the whole Search Recent Projects command down.

Non-recursive mkdir is atomic and returns POSIX error codes. utimes updates real timestamps, so extension lockfiles can detect stale owners and refresh their heartbeat.

A bundle that ships its own HTTP client rather than calling fetch — node-fetch travels inside @raycast/utils, and axios has a Node adapter — reaches the network through http.request, so the shim answers it: one request when the body ends, one response chunk when the bridge replies. The transport decodes for us, so the response drops content-encoding and content-length rather than have the client gunzip plaintext.

An explicit socketPath selects Unix-socket HTTP, which Docker uses for its local daemon. The host runs macOS's bundled /usr/bin/curl off-main because URLSession has no public Unix-socket transport. Pipe draining and process-exit waits run on a Dispatch worker while the Swift task suspends, so slow socket responses do not occupy Swift's cooperative thread pool. The child bypasses proxies and user curl configuration, decodes compressed bodies, and returns HTTP statuses, headers and binary bodies through the same bridge. Cancellation terminates and collects that request's child; request-body scratch files are private and removed on exit. Requests without socketPath continue through the shared ephemeral URLSession. Responses remain buffered, so Docker log streams, event streams and interactive attach sessions are outside this transport's support. An unavailable socket reports an actionable connection error and shows the existing failure toast for a foreground command. Background commands stay silent, and the extension still receives the rejected request so its own error handling can run. Repeated failures of that socket show one toast until a connection succeeds or the command session ends, so polling cannot continually replay its entrance or undo a dismissal.

Two things decide whether it gets there. Axios enables that adapter only when Object.prototype.toString.call(process) reads [object process], so process carries the tag; and follow-redirects inherits with Writable.call(this), so stream hands out callable constructors.

http.Agent is a real class whose addRequest does nothing, because the bridge owns every socket. A request calls it only for an http.Agent subclass, which is where axios-cookiejar-support's http-cookie-agent reads and writes its jar — Hide My Email is the reference case. URLSession folds repeated Set-Cookie headers into one line, so the response splits it back into Node's array.

WebSockets — WebSocket is a global backed by URLSessionWebSocketTask. Swift owns the wire and the framing, and JS reads a socket by keeping one receive call outstanding, so an inbound message needs no push channel; sends are chained, because two host calls can otherwise settle out of order.

A bundled ws never looks at that global. It runs its handshake through http.request and waits for an upgrade carrying a raw socket it frames itself, so the shim answers with one that re-frames RFC 6455 in both directions on top of the native task. The 101 it synthesises names no extension, which is what keeps permessage-deflate — incremental zlib, which the shims have no answer for — off the connection. Home Assistant is the reference case: it authenticates, subscribes, and re-renders on every state push over that socket. The scheme rides with the module for the same reason: ws hands https.request an options bag with no protocol in it, and a wss: URL that went out as ws: would never connect.

.local names — Home Assistant's default URL is homeassistant.local, and the extension resolves it itself with multicast-dns because Node cannot. macOS can: mDNSResponder answers .local through getaddrinfo like any other name. So dgram hands out a socket that never reaches the network — it decodes the query, asks the system resolver, and emits an answer packet back. Nothing joins a multicast group, so no multicast entitlement and no Local Network prompt of our own. It answers an address question and nothing else: a service enumeration, or anything sent to another port, throws.

Bundled helpers — compiled Mach-O files and shebang scripts live in assets/. GitHub's raw-file downloads and some store zips lose their executable mode, so installation preserves Git tree mode 100755; discovery also repairs known executable payloads already installed as 644. That covers both generated wrappers and extensions that call a helper directly with execFile. spawn covers the rest of a Swift wrapper. Color Picker is the reference case.

Command modes — view renders into the palette; no-view runs headless with the palette closed. Both receive props.arguments and props.launchType. A no-view command declaring interval ("1m", "12h", "1d") also refreshes in the background — see Background refresh. menu-bar commands render native menu extras with the lifecycle described above. Launch contexts also carry JSON props.launchContext.

Measured against the 37 extensions installed in a real Raycast on the development machine: 32 extensions / 114 of 147 view commands boot and render. Scripts/raycast-runtime/test.mjs <dir> and Scripts/run-tests.sh ext-test reproduce that measurement.

What isn't supported yet

Gap Why
AI, BrowserExtension, WindowManagement Raycast services with no local equivalent. Importing them works; calling one throws with a clear reason.
A WebSocket to a host with a certificate macOS distrusts ws's rejectUnauthorized: false is ignored — URLSession validates the chain either way.
Aborting a fetch already in flight AbortSignal is complete — timeout, abort and any included — and fetch checks it on both sides of the host call, so a caller gets its AbortError. The request itself still runs to completion: the signal isn't carried across the bridge, so nothing cancels the URLSessionTask. A timeout bounds the caller, not the network.
Interactive spawn stdin stdout and stderr stream, but stdin is sent once as the child starts: whatever was written in the same tick. A later stdin.write is dropped.
net / tls Resolve but throw on use. Nothing bridges a raw socket; a bundled ws reaches the network through the WebSocket bridge instead. tls.TLSSocket is the one exception: http2-wrapper, inside got, derives a class from one at import time, so it constructs as an inert duplex.
Streaming HTTP The bridge answers a request with the whole body at once, so http.request delivers one chunk and Response.body replays bytes that already arrived. Server-sent events, network-level progress and backpressure onto the socket are all out of reach; stream itself is real enough to carry them the day the bridge is.
Tool/AI-extension entry points (tools/) Not surfaced.

Working on the runtime

cd Scripts/raycast-runtime
pnpm install
node gen-enums.mjs        # only after bumping the @raycast/api devDependency
node build.mjs            # → Tinycast/Resources/RaycastRuntime.generated.js (commit it)
node build.mjs --dev       # unminified, React in development mode (better error messages)

Then the tests, fastest first:

# 1. JS-only fixtures, in a bare `vm` context (the closest thing Node has to JavaScriptCore)
node fixtures.mjs

# 2. any prebuilt extension, printing the render tree it produces
node test.mjs ~/.config/raycast/extensions/<uuid> [command]

# 3. the real Swift engine, against JavaScriptCore
Scripts/run-tests.sh ext-test
"${TMPDIR:-/tmp}"/tinycast-harness/ext-test ~/Library/Application\ Support/com.tinycast.app.dev/extensions/<name> [command]

ext-test compiles the real engine sources — there is no copy to keep in sync. EXT_TEST_VERBOSE=1 prints the extension's own console output; EXT_TEST_SETTLE_MS=8000 gives a slow command longer; EXT_TEST_PREFS='{"version":"v8"}' stands in for preferences the user set in Settings, which is the only way to reach a code path an extension gates on a preference with no manifest default. Both harnesses read the same three variables.

For OpenCodex Usage, the native harness also exercises the actual menu renderer, three Refresh round-trips and the Provider Usage action, with live fetches but a recorded launchCommand. Harness status items stay hidden so a test run cannot interfere with the running app's menus:

EXT_TEST_MENU_BAR=1 "${TMPDIR:-/tmp}/tinycast-harness/ext-test" \
  "$HOME/Library/Application Support/com.tinycast.app.dev/extensions/opencodex-usage" usage-menu-bar

Debugging a failing extension

  1. Run it through node test.mjs <dir> for a full render-tree dump, then through /tmp/ext-test <dir> to confirm the same behaviour under JavaScriptCore.
  2. EXT_TEST_VERBOSE=1 surfaces the extension's console.error, which is usually where an extension explains itself.
  3. Build the runtime with --dev to get unminified React errors instead of Minified React error #130.

Two JavaScriptCore differences that have already bitten and are worth remembering: Error.stack contains frames only (V8 repeats the message, so the headline has to be prepended by hand), and MessageChannel is absent, so React's scheduler falls back to setTimeout.

Where things are stored, and what uninstall removes

Everything is under ~/Library/Application Support/<bundle id>/, keyed by bundle id so a Debug run never shares with an installed copy.

What Where Gone on uninstall
The extension extensions/<name>/ yes
LocalStorage, Cache, preferences, OAuth tokens extension-data/<safe name>.json yes
Command subtitle, refresh state extension-commands.json yes
Installed store version extension-versions.json yes
environment.supportPath extension-support/<safe name>/ yes
Menu-bar activation and snapshot extension-commands.json yes
Icon override UserDefaults → extensionAppearances yes
Command shortcuts UserDefaults → hotkey.extensionCommand.<entry id> yes
Favorites, hidden items UserDefaults → favoriteApps, hiddenItemKeys yes
User alias UserDefaults → launcherAliases yes
Launch ranking launcher-ranking.json yes

ExtensionCatalog.safeName maps an npm-style name onto one path segment, and is the only copy of that mapping — a second one that drifts orphans every file the first one wrote.

The last four rows are pruned by ExtensionCoordinator.removeExtensionReferences, reached through ExtensionManager.onDidUninstall. An extension's preferenceKey is its entry id, because it has no bundle id, so extension:<name>/<command> is what those stores are keyed by. CustomCommandCoordinator and QuicklinkCoordinator prune the same stores the same way; extensions are not a special case.

Builds happen in $TMPDIR/tinycast-install-<UUID>/, named by ExtensionCleanup.workspace so the sweep below cannot disagree about what a workspace is called. A defer removes it on every exit an install can take. A crash mid-build is the one it cannot cover, so ExtensionManager.start sweeps strays once at launch — deliberately not gated on extensionsEnabled, because a stranded node_modules is ours either way.

$TMPDIR is kept on purpose over a directory of our own: it is the same APFS volume, equally excluded from Time Machine, and com.apple.tmp_cleaner reclaims it as a second backstop. ~/Library/Caches has no such daemon, so a leak there would be permanent.

Settings › Extensions › Storage measures the same strays and offers them back, so a leak from an older build is recoverable without a terminal. It sits outside the enabled group deliberately: the files are on disk whether or not extensions are on. The row is empty in normal use — an install cleans up after itself — and the scan runs off-main, because measuring walks a node_modules.

Nothing here touches ~/Library/pnpm or ~/.npm. Those belong to the package manager and are shared with every other project on the machine.

Making one look native

An imported extension draws whatever icon it shipped, which rarely matches the rest of the launcher. Settings › Extensions › Configure › Launcher icon replaces it with an SF Symbol on a tinted tile — the same tile IconCache draws for the built-in commands, so the row reads as part of the app.

ExtensionIconCache, and why extension artwork draws smaller

An extension's own artwork has its own cache — Service/ExtensionIconCache.swift — rather than living in IconCache. That split is the point: IconCache stays the app-and-symbol layer and knows nothing about extensions. It lends out only the pixel work (displayPixel, artworkExtent, paintedExtent, rasterized), so there is one definition of how an icon is measured and drawn.

ExtensionIconCache.extent fits that artwork to 0.76 of the canvas, where an app icon and a symbol tile both sit at IconCache.artworkExtent 0.83. The gap is deliberate and optical, not a size correction — measured, all three paths already produce an identical 40pt box.

Every macOS 26 app icon is a squircle with a glyph inside it, and the ground disappears into the palette, so only the glyph reads. A Raycast icon is a flat, fully saturated tile, so every pixel of it reads. At equal geometry the extension shouts, and fitting it smaller is what makes the two match by eye. Shipped and fetched images take the same target, so an icon doesn't change size depending on where it came from. An inline data: image is the one exception and is never fitted: the extension that drew it has already sized it, and rasterizing would cost the vector.

Change the number only against a rendered strip of real icons; it means nothing on its own. ext-icon-test guards the invariant: padding in the source cannot change the drawn size, and a data: payload decodes in either encoding — and one naming a raycast-* colour draws ink, in the stroke that appearance calls for.

  • ExtensionAppearance (symbol + ExtensionTint) is stored per extension by manifest name in ExtensionAppearanceStore, and applies to every command of that extension — the same inheritance Raycast has when a command declares no icon of its own.
  • ExtensionManager.publishLauncherEntries resolves it into each AppEntry; setAppearance re-publishes immediately, so rows change without waiting for a rescan.
  • 18 tints, pinned sRGB rather than system colours: tiles rasterize off the main thread, where a dynamic colour would resolve against whatever appearance that thread sees. Pinning also makes the picker's SwiftUI preview and the drawn bitmap the same colour by construction.
  • "Use Original" clears the override. Choices ride along in a settings backup.

Where the symbols come from

SymbolCatalog reads the system's own catalog at runtime from /System/Library/CoreServices/CoreGlyphs.bundle — the symbol order, each symbol's categories, and the extra search terms the SF Symbols app matches on, so "coffee" finds cup.and.saucer. Reading it beats bundling a name list: the offer always matches the OS, with nothing to regenerate per release.

Two filters apply, leaving ~6,500 of the 8,302 names on macOS 26:

  • Apple's reserved marks (symbol_restrictions.strings, ~600 symbols: iCloud, iPhone, AirPlay…), which may only refer to those products.
  • Locale renderings (.ar, .hi, .rtl…), near-duplicates of a symbol already in the list.

None of this is API, so every read is optional and SymbolCatalog.fallback — the curated ~85 in SymbolCatalog.suggested — stands in if the bundle ever moves. That curated set is also what the picker opens on, since scrolling six thousand icons is not a way to choose one; a search reaches the whole catalog regardless of the selected category.

Tests/symbols-test.swift compiles the real source and asserts those invariants against this machine's CoreGlyphs (shapes and rules, not counts — those move every release).