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.
- How it works · The JS runtime · The Swift host · Rendering
- Turning it on · Installing extensions · Installing from GitHub · Updates · Shortcuts · Aliases · Deeplinks · What's supported · What isn't · Working on the runtime
- At most two commands run concurrently. The palette and scheduled
no-viewrefreshes share one runtime; a foreground launch preempts its background refresh. Menu commands use a separate runtime, serialized byExtensionMenuBarManager. 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
_preventsAppSSOproperty when its setter is available. Other sessions keep their own authentication behavior. - An idle menu item holds no JavaScript. Once
isLoadingclears, 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 Sendableis load-bearing. EveryJSContext/JSValuetouch happens on its private serial queue, and only plainSendablevalues (RenderValue,RenderTree, JSON strings) cross in or out. Keep that boundary.Resources/RaycastRuntime.generated.jsis emitted byScripts/raycast-runtime/build.mjsand committed — never edit it by hand; changeScripts/raycast-runtime/src/and rebuild.ExtensionScreenis 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.
extensionsEnabledis opt-in, andExtensionManager.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. SymbolCatalogreads a system bundle, not API. The list comes fromCoreGlyphs.bundleat runtime; every read stays optional and falls back toSymbolCatalog.suggested, and Apple's restricted marks are never offered.
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__slotchildren; 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.
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.
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.
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.
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.
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
filteringis on (Raycast's default unless the command suppliesonSearchTextChange) rows are filtered with the launcher's ownFuzzyMatchover title, subtitle and keywords, and a section whose items all drop loses its header too.isShowingDetailsplits 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.Itemcarries both the flatselectionindex and the scroll id, and is theForEachidentity of the row and the grid cell alike — see the scroll-id rule in ui.md. A matchingselectedItemIdseeds the palette highlight when the screen first appears.onSelectionChangeis 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 reportsnull, matching the API contract. -
Search-bar dropdown —
List.DropdownandGrid.Dropdowndraw asExtensionSearchAccessoryButtonat the header's trailing edge and dropExtensionPickerListas one of the palette'sOpenMenucases, so the arrows, ↵, Escape and the click-away come from the one menu path and no second key handler exists to disagree with it.PaletteFilterActionroutes ⌘P, so a command's own dropdown answers before Tinycast's clipboard filter can. The list islistWidth(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 keepsmakeSearchDropdownhook-free so an extension may callList.Dropdown({…})directly — soExtensionManager.accessoryValueskeys it by render-node id andseedSearchBarAccessoryreports the opening choice throughonChangeon the first commit, as Raycast does; without that, a command filtering its rows by the value renders nothing (issue #511). Avalueprop makes it controlled: the extension holds it, nothing is seeded, nothing reported.storeValueparks the pick inExtensionStorage.accessoryValues— host UI state, outside theLocalStoragenamespace JavaScript reads, and gone when the extension is uninstalled. -
Grid tiles —
ExtensionGridLayoutreads theGrid'scolumns,aspectRatio,fitandinsetand is the one place tile geometry is decided. A tile is a column wide andaspectRatiotall, 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;insetis 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.Sectionprops 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 notationColorValuereads — a colour picker writesoklch(), not hex. -
Detail — markdown rendered block-by-block (headings, lists, code fences, quotes, rules, tables, fetched and inline images) with
AttributedStringhandling inline styling, plusDetail.Metadata— a sidebar on a Detail screen, appended below the markdown in aList'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 byExtensionImageSize, size it. A rowless Detail's screen actions remain available through the primary ⏎ action and the ⌘K Actions panel. -
Appearance —
environment.appearancereports the real one, so an extension that branches on it is told the truth. It is an injected field onExtensionLaunchContext(aModel/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 byExtensionImage.resolve(_:assetsPath:isDark:), whoseisDarkcomes from the view's\.isDarkAppearanceso 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 toNSWorkspacerather than being decoded as an image file — an.apphas no bitmap to read. Adata: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. AtintColoron any of them draws the image as a template, which is what colours an SVG written againstcurrentColor. Araycast-*colour name inside that SVG is rewritten torgba(…)during the decode, inExtensionIconCache.loadInlineAsync: the name is legal wherever a Raycast tint is, so extensions write it straight intostroke, and no SVG renderer knows it — left alone the shape draws nothing at all. It happens there rather than inresolvebecauseresolveruns in abodyand the decode already runs detached, and because the markdown images a Detail draws never pass throughresolveat all. The palette is handed in as[name: css], resolved once per appearance byExtensionImage.svgPalette(isDark:)— aColorcan 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.taskonExtensionImage.LoadKey, since the URL alone no longer says what will be drawn. The feature's own fills live inExtensionColors— never inTheme. -
Form — label-left/control-right rows. Field values live in the extension (React owns them); every edit dispatches
onTinycastChangeand the resulting re-render is what updates the control, sodefaultValue, a controlledvalue, andref.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.ExtensionFormFieldsays what eachForm.*node is — which of them focus lands on, which keys the control keeps, and which need a focus ring drawn — andExtensionScreenpublishes exactly the focusable ones asitems, 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 markedautoFocusis where the form opens, otherwise the first one. While a control holds focusPaletteState.isEditingFieldis 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.
ExtensionFieldChromeis the one rounded surface they all share andExtensionFormMetricsthe one place their geometry is stated, so a field, a picker and a text area line up by construction. APickeropens only to a click and aDatePickerhas 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.Dropdownand aForm.TagPickerare the same control —ExtensionPickerField— differing only in whether it holds one value or several. It dropsExtensionPickerList, 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.isControlListOpenis 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 byMenuPanelController. 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 inExtensionFormMetricsrather 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.ExtensionQueryTextoverlays 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 — arepeatForeveranimation fades where a real caret switches. The list is results only.Form activation keys use
ExtensionFormKey, applied byExtensionFormKeysto each field. The palette defers to focused fields; an open Actions menu and IME composition keep precedence.ExtensionListKeyhandles list navigation and search editing. A stack of separateonKeyPressmodifiers let a character rule shadow ⌫, and ⌫ arrives carrying U+007F rather than the U+0008 SwiftUI's.deletenames, so nothing was ever deleted from a search. Both spellings are answered before characters are considered at all, and the rules are pure soext-form-testdrives them.ExtensionDateFieldis the same shape overExtensionDateExpression, 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, soext-form-testdrives 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.placementapplied byExtensionListPlacementagainst 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.swiftdrives 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 ofActionPanel.Sectionnode, 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 areExtensionActionItem, notPopoverMenuItem: an action'siconis a fullImageLike, so it resolves throughExtensionImagelike every other extension icon and keeps itstintColor— 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 ownshortcutis matched against modified keystrokes, with the panel open or closed.ExtensionCommandScreen.menuContenthands the whole panel to the palette as aPaletteMenuContent, so the palette never learns the row type — and a row's handler is taken from the flattenedExtensionActionlist 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 —
showToastreplaces 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;showHUDis a centred pill, andconfirmAlertgoes throughDialogControllerlike 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 itsawait confirmAlert(…)ever returns. -
Command arguments — a command declaring
argumentsshows 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 asprops.arguments; a blank required argument blocks the launch and focuses the offending field. The fields get their ownFocusStaterather than joining the search field's, so the palette's one always-attachedTextField(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)is0for""butNaNforundefined, so omitting a blank argument silently corrupts whatever they compute — Coffee's "Caffeinate for…" spawnedcaffeinate -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.
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.
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:
- 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. - Install from GitHub — builds one extension from its source on this Mac. See below.
- 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/raycastand~/.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. - Add from folder — pick any directory with a manifest and built command files, e.g. an extension
you just ran
ray buildin.
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.
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.
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.
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.
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.
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.
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.
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.
| 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. |
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- 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. EXT_TEST_VERBOSE=1surfaces the extension'sconsole.error, which is usually where an extension explains itself.- Build the runtime with
--devto get unminified React errors instead ofMinified 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.
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.
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.
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 inExtensionAppearanceStore, and applies to every command of that extension — the same inheritance Raycast has when a command declares no icon of its own.ExtensionManager.publishLauncherEntriesresolves it into eachAppEntry;setAppearancere-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.
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).