Files
Arthur Fontaineandabue-ammar 4cb914e76d Run Raycast extensions natively (#235)
* Grow Gunzip into a full Zlib in Platform/

The extension runtime's `node:zlib` shim needs deflate and the raw/zlib
wrappers too, not just gzip decompression — and none of that is Backup's
business, so the file moves to `Platform/Compression/`.


* Run Raycast extensions natively

Tinycast can now run the Raycast extensions you already have. A prebuilt
bundle boots in a JavaScriptCore context on a private serial queue; a
bundled React reconciler commits a JSON render tree that the palette
flattens and draws with native SwiftUI rows — no Node.js, no WebView, so
it costs nothing in binary size.

- `Features/Extensions/` — the manifest model, the catalog of what is
  installed, the runtime and its host bridge, the render tree, and the
  screens that draw it. `docs/features/extensions.md` opens with the
  invariants: exactly one command runs at a time in its own context, and
  every `JSContext` touch stays on the runtime's queue.
- `Resources/RaycastRuntime.generated.js` — the embedded runtime, built
  from `Scripts/raycast-runtime/` and committed, so building the app
  never needs Node.
- Extension commands join the launcher as their own `AppEntry.Kind`,
  drawing the icon the extension ships. Arguments a command declares are
  typed inline in the header, beside the search field.
- Settings gains an Extensions pane: install, remove, per-extension
  preferences, and an optional symbol/tint override for the icon.
- Two new harnesses in `Scripts/run-tests.sh`: `ext-test` boots a real
  bundle end to end, `symbols-test` guards the SF Symbol catalog.


* List extension commands in the launcher's Extensions section

`LauncherList` groups the empty-query list by kind against an explicit
order, and `.extensionCommand` was never added to it — so every extension
row was dropped from the rendered list while still counting in the flat
`results` index behind it. Extensions only appeared once a query switched
the list to its flat "Results" shape, and any row after where they should
have been activated its neighbour.

The order matches `AppIndex.publishEntries`, and an assertion now catches
the next kind that forgets to join it.


* Draw extension icons at the size of every other icon beside them

A Raycast extension ships a PNG that paints edge to edge, where a macOS
app icon leaves a margin inside its canvas, so drawing it to the same box
made it read a size larger in the launcher. `imageIcon` now fits artwork
the way file icons already are — the scaling `fittedIcon` did inline moves
to `fittedToArtwork`, and both call it.

In Settings the extension icon was explicitly `settingsRowIcon + 6`; it is
now the same token every other pane's row icon uses.


* Add a master switch for extensions, and one for the launcher

Extensions were always on, always scanned and always published. They are
now opt-in, through the same `FeatureSwitchSection` every other feature
pane opens with.

Enabling asks first. It is consent to run third-party JavaScript, and it
is the one feature here that carries a standing memory cost, so the
confirmation says so plainly instead of leaving it to be discovered.

Off means off: `setEnabled(false)` stops the running command, discards the
JS context, empties the installed set and clears the launcher rows, and
`refresh()` returns early — nothing is scanned and nothing is held.

`extensionsShowInLauncher` rides a settings backup; `extensionsEnabled`
deliberately does not, for the same reason `snippetsEnabled` doesn't — an
import must not switch on the execution of third-party code.


* Uninstall and configure an extension from the launcher's ⌘K menu

An extension row's actions menu offered only the generic entries, so
removing one meant going to Settings and finding it there.

Uninstall confirms first — it deletes the extension's files, preferences
and cache — and the palette hides before the dialog, since a sheet behind
a floating panel is unreachable.


* Bind a global shortcut to an extension command

Adds `HotKeyAction.extensionCommand`, keyed by the launcher entry id
(`extension:<extension>/<command>`), and a recorder beside each command in
Settings.

Per command rather than per extension: a shortcut has to land on one thing
to run, and an extension is a set of commands.

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


* Rebuild the Extensions pane around the extensions themselves

The pane was a wall of controls: an import row, an add row, and rows that
expanded into an appearance control, an unlabelled preference block and a
flat list of commands.

Now it reads top to bottom as the questions someone actually has. What is
this feature and do I want it (the switch section). Will my extension work
(a new, honest compatibility notice, expandable into what does and doesn't
reach here). What do I have — one row per extension, expanding into its
preferences, its commands with their shortcuts, and Uninstall.

Adding moved into an "Install New" menu in the section header, where it
belongs beside what it adds to, instead of taking two permanent rows.

The icon is now the button that changes it, with a pencil badge, and "Use
Original Icon" moved inside the picker it belongs to — so re-skinning is
one press on the thing being re-skinned rather than a row of its own.

A filter appears past six extensions.


* Notice when Raycast has extensions Tinycast doesn't

Installing something in Raycast left no trace here: import was a button
you had to think to press, against a list that gave no hint anything in it
was new.

The pane now scans Raycast's directory whenever it opens and, when it
finds extensions that aren't here, says which and offers Import All. The
picker preselects exactly those, so the common case is one press.


* Fetch extensions from a registry, and build the ones that arrive as source

Two registry kinds, because the two sources hand back different things.

Raycast's store serves the bundle it already built — the same layout
`ExtensionCatalog` installs — so installing from it needs no toolchain at
all: download, expand with `ditto`, install. That is the default path and
the one almost everyone will take.

A GitHub registry serves source. Only the extension's own folder is ever
fetched, never the repository: `raycast/extensions` is gigabytes, and
cloning it to install one extension would be absurd. Dependencies are then
installed with the chosen package manager — pnpm, npm, Yarn or Bun, or
whichever is present — and the extension's own `build` script runs, which
is `ray build`. Lifecycle scripts are skipped: the build script is the
contract, a postinstall is code nobody asked to run.

Folder listings go through the Git trees API rather than the contents API.
Contents caps a directory at 1000 entries and says nothing about having
done so, and `raycast/extensions` holds 3167 — everything alphabetically
past the cap was simply unfindable.

Registries are a list, seeded with the store and the official repository,
so someone can add their own. Neither the list nor the package manager
rides a settings backup: one names a tool that may not exist on the
machine an import lands on, the other is a source of code that will be
run, which has to stay a deliberate act.


* Search and install extensions without leaving Settings

One search field over every enabled registry, and one list. Where a result
came from changes only two things anyone can see: a "builds on install"
badge, and whether installing it runs a build first.

Installing reports each step — a source install runs a dependency install
and a build, which takes minutes, and silence for that long reads as a
hang. A registry that fails is named in the footer rather than quietly
narrowing the results.

Searching is debounced: every keystroke is a request to someone else's
API, and anonymous GitHub allows sixty an hour.

Advanced holds what almost nobody needs to touch — the package manager,
and the registry list.


* Document the switch, the registries and the shortcuts


* Make the Extensions pane behave like a settings pane

Ten things, all from using it:

- The filter did nothing. It sat in the same section as the results, so
  every keystroke rebuilt the rows beside it and SwiftUI took first
  responder with them — the same hazard the palette's search field is
  pinned in place to avoid. It gets its own section.
- "Advanced" and the compatibility notice only toggled from the chevron,
  and didn't look like anything macOS ships. Both are now
  `Section(isExpanded:)`, which is the collapsible a settings form
  actually uses: native chrome, and the whole header row is the target.
  That also settles the icon that sat level with the title but not with
  the chevron — there is no such icon any more.
- Extension icons read too small. Artwork is fitted to the share of the
  canvas an app icon paints, so the box has to be slightly larger than a
  symbol's to land the ink at the same size as its neighbours.
- "Install New" was the wireframe's placeholder wording. It's a + menu in
  the section header now, which is how macOS adds to a list.
- A GitHub registry gets the GitHub mark the About window already ships.
- raycast/extensions ships disabled. It serves source, so it needs Node
  and a package manager, and the store already covers the same catalogue.
- The package manager picker explained nothing. It now sits under
  "Building", which says what has to be built and why the store never
  needs it.
- Text fields and pickers in an extension's preferences were invisible
  until focused: a plain-styled field has no bezel outside a form row.
  They're bordered and menu-styled now.
- An expanded extension ran preferences, commands and their shortcuts
  together into one undifferentiated column. Grouped under headings, one
  ruled block per command, indented under the icon.


* Fix the click targets instead of moving what they were on

The previous commit answered "I can't click this" by rearranging the pane.
Put back what was there and fix only what was broken:

- The filter is back inside the Installed section. It is plain-styled and
  so has no bezel of its own, which left only the glyphs as a target —
  `SettingsFilterField` now takes focus from a tap anywhere in the row,
  which fixes it in the two other panes that use it as well.
- Advanced and the compatibility notice keep the disclosure they had; only
  their labels became tappable. A `DisclosureGroup` toggles from its
  chevron alone, and the label is the rest of the row.
- Escape and Return now release a preference field. A SwiftUI text field
  on macOS holds first responder until something else claims it, so
  clicking away left it focused with no way out but tabbing onward.


* Rebuild the Extensions pane out of the components Settings already has

The pane had drifted into hand-built layout: a filter field that renders
as a left-hand label inside a Form rather than as a search box, rows that
expanded into a column of bare HStacks, disclosure groups that only
toggled from the chevron, and text fields with no bezel until focused.
None of that was a component this app owns — it was all reinvented, and
worse each time it was patched.

Rewritten on what the other panes use:

- One `SettingsRow` per extension — icon, name, what it holds, then the
  same pencil and trash buttons a custom command has.
- Configuring opens an editor sheet, like the custom command and snippet
  editors. Preferences and per-command shortcuts are `LabeledContent` in
  a `Form`, so the label column, control styling, hover and focus are the
  system's rather than approximated.
- Adding is three plain buttons at the end of the list, where the
  Commands pane puts "Add Custom Command…".
- Compatibility, Registries and Building are plain sections with headers
  and footers. Nothing to expand, so nothing to fail to click.
- The filter is gone. It was never a search box in a `Form`, and a list
  this short doesn't need one.


* Expand an extension in place, as the wireframe has it

The sheet was wrong: the design expands a row into its own settings, and
that is what this does again. The rows an open extension adds are siblings
in the same section rather than a nested layout, so each is a real form
row — label column, control styling, hover and focus all the system's.

A text field in a form row collapses to nothing beside a long label, which
is how "Custom Brew Executable Path" ended up invisible but still
clickable. Fields carry a border and a fixed width now.

Commands are grouped together after the preferences instead of each
sitting in a section of its own, and adding is one menu in the section
header rather than three buttons trailing the list.


* Bring the filter back, working, and separate preferences from commands

The filter was never a search box: `TextField(title, text:)` inside a
`Form` renders its title as the row's left-hand label, so the placeholder
became a heading and the field an unmarked strip beside it. `prompt:` is
the placeholder argument. Fixed in `SettingsFilterField` itself, so the
two other panes using it get a working search field as well.

An open extension ran its preferences and its commands together as one
flat column of identical rows. They are different kinds of thing, so each
run gets a heading — a row of its own, a form section having no
sub-sections.


* Fold registries and building back under Advanced

Both defaults are right for almost everyone, so neither belongs in the
pane's main flow. One collapsible holds them, and its label toggles as
well as its chevron.


* Make the text fields behave like text fields

Three separate causes, all visible in the filter row:

- The field sat halfway across the row, far from its magnifying glass. A
  `Form` reserves the left column for a text field's label, and an empty
  title still claims it. `labelsHidden()` gives the column back.
- Clicking away left the field focused. A SwiftUI text field on macOS
  holds first responder until another focusable view takes it, and blank
  form space takes nothing. A local mouse-down monitor now drops focus
  when the click lands outside the open field editor — a monitor rather
  than a gesture, since a gesture over the form would either swallow the
  click or fire alongside the one focusing another field and steal it
  back. Escape and Return do the same from the keyboard.
- No I-beam on hover: a bezel-less field doesn't get one. `pointerStyle`
  sets it, here and on the preference fields.


* Separate the list, the filter and an extension's settings

Everything sat in one undifferentiated block: the filter, the rows, the
open extension's preferences and its commands were all form rows of the
same section, with nothing to say where one ended and the next began.

Rebuilt on what the app and the platform already do:

- The list follows `LauncherItemsSection` — filter row, then the rows in a
  single form row holding their own stack with separators between them. A
  list now reads as a list rather than as a run of settings.
- An open extension's settings sit on a card inset beneath its row, so
  "this extension" is visibly a different thing from "the list". Apple's
  guidance is explicit that structure inside the content layer comes from
  standard materials and separators — Liquid Glass belongs to the layer
  floating above content, not within it — so the card is the existing
  cardFill/cardStroke pair, not a glass effect.
- Inside the card, a columns-style `Form`: the controls keep the aligned
  label column and system styling they would lose in a hand-built stack,
  without the grouped chrome that would fight the card around them.
  Preferences and commands are titled runs, commands ruled between.


* Lay an expanded extension out on a grid, checked against the screen

The card was unusable and I had not looked at it. A nested `Form` inside a
form row takes the width it wants rather than the width it is given, so it
overflowed the pane, and it ran each row's label and description together
into one unbroken string — "Custom Brew Executable PathSet this if…".

One `Grid` for the whole card instead: labels left, controls right, all of
them sharing a column. One grid rather than one per run, or a short label
in one group would leave its control stranded mid-row while a long label
in the next pushed its own to the edge. Run titles span both columns.

The section header's menu is borderless: a bordered one draws its label
and its chevron as two separate pills.

Verified by driving the built app and reading the screen rather than by
reasoning about it.


* Drop the box in the box, and settle the expanded block's hierarchy

The expanded settings sat on a card of their own inside the section's
card. The indent under the row and the run titles already say where an
extension's settings begin, so the inner card was one border too many.

Controls now share a fixed width as well as a trailing edge — a toggle, a
pop-up and a text field have three different natural widths, so aligning
only their right edges still left them ending in three places.

From a design review of the built pane:

- Rows inside the expanded block get the same inset hairlines every other
  multi-row group in the app uses; without them the block read as a
  different component from the list two rows above it.
- The run titles are a step below the pane's section headers through size
  and colour, so the two levels can't be confused. Not through case:
  nothing else in this app sets a heading in capitals.
- A command's own preferences are indented under it, so the association
  rests on geometry rather than on reading order.


* Quieten the expanded extension, from a second design review

A reviewer scored the pane 6/10 against the app's other panes and named
what was wrong. Acting on it:

- The shortcut recorder gains a quiet variant, used where one appears per
  command: no filled well until it is hovered, recording, or holds a
  shortcut. A column of eight identical filled pills was the loudest thing
  in the pane, and the eye read the pills rather than the commands.
- An unsupported command carries a small grey "Menu Bar" capsule beside
  its name instead of orange text in the control column — that orange was
  the only warning colour in the app, and it sat where a control belongs.
- The runtime note is one footnote line, the register the other panes use.
- The "Appearance" heading is gone: one row doesn't need a group, and
  fewer heading species inside the card means less competition with the
  pane's own section headers. The rest separate by whitespace.
- "Install…" is a real small pull-down rather than text and a chevron.
- A command's own preferences indent under it, so the association rests on
  geometry rather than reading order.
- The import banner has one button: reviewing first is a link beside it,
  and its icon is grey — accent colour here belongs to controls.


* Let the quiet recorder still look like a control

The previous pass dropped the whole well when nothing was bound, which
went too far: "Record" then read as dead text, and it no longer matched
the same control in the Clipboard pane. Only the fill goes now — the
border stays, and the label sits one shade lighter rather than faint. A
column of them is still quiet, because an unbound recorder is an outline
where a bound one is filled.

Inset hairlines now separate the runs inside an expanded extension, so it
carries the same label → rows → rule → label rhythm as the rest of the app
instead of relying on whitespace alone.


* Give the rest of the Extensions surface the same pass as the pane

The pane reached the app's standard; its sheets and popover had never had
a design pass at all. From a review of the whole surface:

- Adding is now its own "Install" section — Search extensions, Import from
  Raycast, Add from folder, each an icon row saying what it does, with the
  registry settings behind a "Where to search" disclosure beside them. A
  menu in the section header hid all three behind one word.
- The sheet a row opens is named after the row: "Search Extensions". The
  banner lost its blue text link and carries one button, since choosing
  what to import is that Install row's job.
- The search sheet's empty state says what to type instead of showing one
  centred line in a tall void, and names the registries it will search.
- The import sheet gained the filter the pane's own list has, its
  Select All reads against what is actually selected, and an
  already-installed row says it can be ticked to update rather than
  looking switched off.
- Both sheets use the app's borderless inline search field, and both
  scroll under the app's own edge dissolve instead of cutting a row dead
  against the footer.

The icon picker offers the marks the app ships — bluetooth, GitHub,
Discord, X — ahead of the system's. There is no bluetooth symbol in
CoreGlyphs at all: 8302 symbols, none named for it, and not on Apple's
restricted list either. It simply doesn't exist, so an extension that
toggles bluetooth had no icon to pick.


* Fade a scroll edge only where content actually continues

The edge dissolve fades both ends, which is right in the palette — those
lists underlap a floating bar at each end. Applying it to a sheet dimmed
the first row against nothing, and in a list of checkboxes a dimmed row
reads as one you aren't allowed to choose.

It takes an edge set now. The sheets and the icon picker fade only their
bottom, where a part-row means "more below"; the palette is unchanged.


* Drop the registry summary while its rows are on screen

It repeated what the rows beneath it already say, a few points apart.


* Put each step of the flow where it is reached for

From a review of the flows rather than the pixels:

- Registries are a sheet now, opened from beside "Search…" and from
  inside the search sheet. They were a row under the import row, which
  said nothing about the one thing they govern: what searching can find.
  The sheet separates the store from GitHub registries, since one is a
  fixed prebuilt source you switch on or off and the other is something
  you add that has to be built — and the package manager lives in the
  GitHub section, the only place it applies.
- Install comes before Installed. Adding an extension is why the pane is
  opened after day one, and it sat below the whole inventory.
- "N extensions in Raycast aren't here yet" folds into the Import row as
  a state of it, rather than floating at the top of the pane. One job,
  one place; before, the two halves of the same path sat at opposite ends
  of a long scroll.
- Searching with no registry enabled was a field that could only find
  nothing. It now opens on that fact, with the way to fix it.
- An extension can be kept out of the launcher on its own. Importing
  everything Raycast has can add hundreds of commands at once, and the
  global switch was the only answer to that.
- Install failures report under the buttons that caused them.

The icon picker is laid out from one column system derived from the
symbol grid: the search row, the category menu, the swatches and the
footer all shared a popover but not a margin, which is what read as
"nearly aligned". The swatches now span the grid's width, and the grid
shows six whole rows plus half of the next as a deliberate scroll hint.


* Finish the flow pass: visibility, bulk import, picker margins

- "Show in launcher" per extension was written but never placed in the
  expanded row. It sits first now, beside the launcher icon — the two are
  one idea, and an extension with nineteen commands would have buried it.
- Importing everything Raycast has now reports: the row counts through
  the batch and resolves to what landed and what didn't, rather than
  going quiet for thirty installs and leaving the outcome to a footnote.
- The picker's symbol grid centred itself in the scroll view's spare
  width, putting it twelve points right of the swatches, the search row,
  the menu and the footer. Leading-aligned, so the column system that was
  supposed to be shared actually is.
- The Raycast preview names sort on their first letter, so a name like
  "(Basic) Bookmarks" no longer leads the list on the strength of its
  bracket.


* Leave EdgeDissolve alone; give Settings its own overflow fade

The edge dissolve is tuned by eye against the palette's floating bars, and
until now every one of its call sites was a palette screen. Parameterising it
for three Settings lists put it to work where its measurements mean nothing:
a Settings list underlaps no bar, so the bands, the alpha floors and the
safe-area correction all describe geometry that isn't there.

Revert the file to its previous state and add OverflowFadeMask beside it.
Bottom only, a flat 24px band, no alpha floor — the fade eases in with how
much content is hidden below and clears completely once the list rests, since
it is an affordance rather than a bar showing through.

* Fetch on private sessions, and count the setting a restore applies

`IconCache.loadRemoteAsync` and `ExtensionStoreClient` both went out on
`URLSession.shared`, which keeps an on-disk HTTP cache. An extension names
the image URLs, so that cache recorded what an extension asked for, in a
store nothing in the app owns. Both now use their own `.ephemeral`,
`urlCache = nil` session, the shape `CurrencyRateStore` already uses.

`extensionsShowInLauncher` was the one restored setting that never
incremented `count`, so the report a restore shows the user was low by one.

* Give extension artwork its own cache, sized for the palette

An extension's icon read heavier than the app icon beside it. Measured, it
wasn't larger: the local PNG, an app icon and a symbol tile all produce an
identical 40pt box. In dark mode a macOS 26 icon is a dark squircle whose
ground disappears into the palette, so only its glyph reads, while a flat
Raycast tile shows every pixel of itself. The correction is optical, and
naming it as such keeps it from being mistaken for a geometry fix.

ExtensionIconCache owns that decision, along with the cache, the keys and
the ephemeral session behind a remote fetch. IconCache keeps only the pixel
work it lends out, and no longer carries an extension-only image: family.
Its logic is untouched — app icons, symbol tiles, File Search and Uninstall
render exactly as before.

Remote images gain the fitting they never had, so an icon no longer changes
size depending on whether it shipped with the extension or was fetched.

ext-icon-test pins the invariant: padding in the source cannot change the
drawn size, and artwork lands below IconCache.artworkExtent.

* Make uninstall remove everything it says it removes

The confirm dialog promises an uninstall takes "everything it stored — its
preferences, its cache and its own files". Two things outlived it.

`environment.supportPath` was never deleted: this machine still had an
extension-support directory for an extension uninstalled long ago. It is the
extension's own scratch space, so nothing else was ever going to collect it.
`ExtensionCatalog.uninstall` now takes both paths, being the one function that
knows every path an extension owns.

Favorites, hidden items and launch ranking were never pruned either, and a
ranking entry is written every time a command runs — so that one leaked for
every extension anybody used. Extensions were the only entry-removing path
skipping those stores; `ExtensionCoordinator.removeExtensionReferences` now
mirrors what CustomCommands and Quicklinks already do, including the
recordingAction reset extensions had also missed.

`ExtensionCleanup` owns the build workspace's name so the code that creates one
and the code that sweeps for one cannot drift, and sweeps strays at launch —
the case a crash mid-build leaves behind, which the installer's `defer` cannot
reach. Its roots are injected rather than read through `Bundle.main`, which is
what lets a harness exercise deletion without pointing it at real directories.

`safeName` was written twice and is now written once. A second copy that drifts
orphans every file the first one wrote, which is this bug in miniature.

* Offer leftover extension files back in Settings

The sweep and the uninstall prune stop new leftovers, but a machine that ran an
older build already has them — this one still had an extension-support
directory for an extension removed long ago. Settings › Extensions › Storage
measures those strays and offers them back, so recovering the space doesn't
need a terminal.

The row sits outside the enabled group on purpose: the files are on disk
whether or not extensions are switched on, and switching them off is exactly
when somebody wants the space. It reads "Nothing to clean up" in normal use,
since an install cleans up after itself.

Measuring walks a node_modules, so it runs off-main and repeats only when the
installed set changes. Deleting an extension's own files is destructive, so it
confirms through DialogController and reports through the same pill as every
other outcome.

* Build with ray directly, and find Raycast Beta

Installing App Cleaner from a GitHub registry failed with "no built command
bundles" after a build that reported success. `ray build`'s default
environment is `dev`, and dev mode installs into the local Raycast instead of
emitting anything — so the build genuinely succeeded, wrote nothing beside the
manifest, and the copy step had nothing to find. Reproduced end to end: the
bundle landed in ~/.config/raycast-x/extensions/appcleaner/uninstall.js.

Call `node_modules/.bin/ray build -e dist -o <dir>` directly rather than the
manifest's build script, which sidesteps how each package manager forwards
arguments and works whether or not a build script exists. An extension without
ray falls back to its own script. A side effect goes with it: every attempted
install had been adding the extension to the user's Raycast.

`-e dist` also type-checks, so an extension that does not compile now fails at
the build rather than at the copy — later is not better here.

Separately, Import from Raycast only looked in ~/.config/raycast. Raycast Beta
v2 uses ~/.config/raycast-x, and a machine that switched leaves the first
present but empty — so the pane told every Beta user no Raycast was installed.
Both roots are searched now, an extension in both is offered once, and the
subtitle names what was actually checked.

* Build into its own directory, so assets survive

The previous commit pointed `ray build -o` at the source directory. `ray`
clears its output directory first, which deleted `assets/` before
`ExtensionCatalog.install` could copy it — so an extension installed from a
GitHub registry arrived with no icon. Coffee landed with nine command bundles,
a manifest, and nothing else.

Build into a sibling `build/` inside the workspace instead. `ray` then writes
exactly what an install wants — package.json, one `<command>.js` each, and
`assets/` — while the source keeps its own copy. The install reads from the
build directory rather than the source.

`stage` went with it: validating a directory holds a manifest is the same job
for either path, and it now has one name.

* Keep the icon picker's grid inside its margin

The symbol grid was framed at the popover's width while sitting inside the
popover's own inset, so it overhung the margin by half the difference on each
side — the one row in the picker that didn't line up with the rest. The height
was a second hardcoded number that had drifted from the computed one, so the
scroll hint showed two thirds of a row instead of the half it documents.

Both now read from Metrics, which the empty state already used. The two
branches agreed on the column before; they agree on the frame now too.

The grid also gains the scroll treatment every other list in the app has —
hidden native scrollers, the thin overlay — which it was the only one missing.

* Give AbortSignal the statics the standard defines

A GIF extension called `AbortSignal.timeout(15e3)` and got "is not a
function". The polyfill built the instance shape — aborted, reason, listeners,
throwIfAborted — and stopped there, so `timeout`, `abort` and `any` were all
missing. JavaScriptCore supplies no AbortController of its own, confirmed by
probing a bare context, so the shim is the whole surface an extension sees.

A half-built polyfill is worse than none: an extension type-checks against the
real API, finds the global present, and fails at the call. All three statics
are here now, with `timeout` rejecting as TimeoutError rather than AbortError,
which is what callers branch on.

The generated bundle is rebuilt from source rather than edited.

Aborting still does not cancel a request already in flight — the signal isn't
carried across the bridge, so `fetch` observes it on both sides of the host
call and the caller gets its error while the URLSessionTask runs on. That is
now written down under what isn't supported rather than left to be discovered.

* Keep extension views inside the extensions feature

An extension renders third-party code whose shape we don't control, so it must
never be able to force a change on a launcher surface. Three pieces of this
feature had leaked outward, and each one made a shared file answer to it.

The palette's PopoverMenu had grown a scroll view and Theme a menuMaxHeight,
both only because an extension's action panel runs long. Both are reverted;
ExtensionActionsPanel owns that behaviour now, with its own rows, its own
metrics and thinScrollbar. EmojiGridGeometry had been renamed and moved out of
Emoji to be shared; it is back where it was, and ExtensionGridGeometry carries
the extension's own grid maths. The animated image view moved out of
DesignSystem, and the two palette modifiers out of RootPaletteView.

Duplicating a view or a piece of layout maths to keep it here is the trade this
asks for, and AGENTS.md now says so — along with the line on the other side:
Theme's base tokens, PopoverMenuItem as a data shape and Platform stay shared.

Also fixed in passing, from the same session: GIFs animate in grid tiles and
Detail rather than showing one frame, since the icon cache was flattening them
before any view saw them; Clipboard.copy puts a file on the pasteboard as a
file and its image rather than as its path; and a Grid's arrow keys move a row
instead of stepping sideways one cell at a time.

* Let IconCache lend a capability, not its internals

Fitting an extension's artwork had been paid for by making four of IconCache's
privates internal — displayPixel, artworkExtent, paintedExtent and rasterized.
Checked: ExtensionIconCache was the only caller of all four. The platform layer
had opened its internals for exactly one consumer, and that consumer draws
third-party content.

Two intentional symbols replace them. `fitted(_:to:)` measures and rasterizes
to whatever extent the caller names, and `artwork(atPath:extent:)` caches an
image file by path *and* extent, so two features asking for different sizes of
one file never serve each other's. `appIconExtent` is the reference an artwork
is sized against, which is a fact worth stating rather than an implementation
detail. The other three are private again.

The split now reads correctly: Platform knows how to draw, Extensions knows
that its own tiles want 0.76 and why.

* Give a launcher entry one icon descriptor

AppEntry carried imageIconPath, kindLabelOverride and appearance — the last of
which imported an Extensions type into the launcher's model — and branched on
kind == .extensionCommand in isSymbolIcon, symbolIconName, icon and iconKey.
Seven places where the launcher's own model had to know what a Raycast
extension is, and what one wants its glyph to look like.

EntryIcon replaces all of it: file, symbol, tintedSymbol, or artwork at a named
extent. A feature whose glyph isn't derivable from its kind sets `iconOverride`
and says nothing else; ExtensionManager sets .artwork(path:extent:) with its own
0.76, so the number stays where the reasoning for it lives. IconCache switches
on the descriptor once, and AppIconView loses both of its branches.

The case exists for any feature, not just this one — a quicklink wanting a
tinted tile now has somewhere to say so rather than a fifth branch.

Platform/ no longer names extensions anywhere.

* Let a screen hand the palette controls it doesn't understand

RootPaletteView held four members that existed only for extension arguments —
selectedCommandArguments, argumentBinding, selectedEntryID and a
searchFieldWidth that reached into CommandArgumentsRow.totalWidth to measure a
strip it was drawing on another feature's behalf. The palette was doing an
extension's layout arithmetic, which is why it named extensions 32 times.

A screen can now return a PaletteHeaderAccessory: a width to give up, the
fields Tab should walk, which one still has to be filled, and a view. The
palette acts on those four facts and asks nothing further. LauncherScreen
forwards to ExtensionArgumentsAccessory, so which arguments exist, how wide
their fields are and which is still empty are all decided inside the feature.

The contract is generic on purpose — any screen wanting controls beside the
search field has somewhere to put them, and the next one won't add a fifth
private to the palette.

The search field keeps its single structural position throughout. That
invariant is load-bearing: moving it inside a branch tears down the field
editor and drops first responder mid-navigation.

RootPaletteView is down to 20 extension references, in the same band as
clipboard and quicklinks rather than double them.

* Guard EntryIcon's four cases

EntryIcon decides what every launcher row draws, and nothing tested it
directly. `ext-icon-test` covers the artwork case only, so a mistake in the
other three — a tint dropped on the way to the tile, two extents colliding in
the cache — would have passed all 30 harnesses and shown up only by eye.

Eighteen checks over the three things that can break: each case reaches its own
drawing path, none of them shares a cache entry with another, and every case
prints distinctly. That last one is not cosmetic — `AppEntry.iconKey`
interpolates an EntryIcon into a string, and a row's async load is keyed on it,
so two icons printing alike would serve each other's bitmap.

Verified the guard bites: dropping the tint in `icon(for:fileURL:)` fails two
named checks, and restoring it passes them.

* Stop the extension panel scrolling under the pointer

Hovering a row set the selection, and the panel scrolled the selection to
centre — so passing the cursor down the list dragged it out from under you.
The panel now skips that scroll when the pointer caused it, a hovered row
being visible by definition, and a keyboard step scrolls the least that
reveals its row rather than re-centring the list.

Neither scrollbar belonged here. `thinScrollbar` is tuned to the palette's
floating bars, which a glass popover doesn't have, and the native scroller
draws through the glass corner. The panel shows none, like a real macOS menu,
and leaves half a row visible as the affordance instead.

Its height counted points — the panel less both bars, 379pt, nearly the whole
window. It counts rows now, six and a half of them, and the row reads the same
constant so the cap can never slice one in half.

* Cut the PR's comments back to one line each

The comment rules say one line, never two in a row, hard cap 100 characters,
and the why rather than the what. Measured against main, this PR had been
ignoring them: 10 stacked blocks in these files became 208.

This pass takes out 111 of them across 20 files. Where two lines carried one
thought, the thought survives and the prose goes; where a doc comment argued a
decision at length, the argument moves to docs/features/extensions.md or is
dropped, since the code it guards hasn't changed.

Nothing here changes behaviour: build, 31 harnesses, lint and format are all
as they were.

97 remain, mostly two-line docs on the runtime's own types. They need reading
rather than rewriting in bulk, so they are a second pass, not a worse one.

* Store the JSContext only once it is known good

`bootOnQueue` assigned `self.context` before evaluating the runtime source, so
a boot that threw left the half-built context in the field. `boot` opens with
`guard context == nil else { return }`, which meant every later boot returned
early and reported success, `start` then invoked `__tinycast.start` on a
context where `__tinycast` was never defined, and the palette sat on
`.launching` with nothing to show. One bad boot took the feature down for the
rest of the session.

The assignment moves below the throw, so a failed boot leaves the field nil
and the next run builds a fresh context. The first exception handler goes with
it rather than outliving the function and writing into a dead local.

Safe because nothing reads `self.context` during boot: `__tinycastCompile` is
the only path that does, and the runtime calls it from `evaluateCommonJS`,
which runs when a command starts.

* Arm a child's deadline before draining it, not after

Both `child_process` paths drained stdout and stderr and only then checked the
timeout. `readDataToEndOfFile` returns when the child closes its pipes, so a
child that hangs never lets the check be reached — the timeout only ever bit
for a child that closed both pipes and kept running. `exec("sleep 1000",
{timeout: 500})` waited forever.

Draining first is still right, since a child that fills the 64 KB pipe buffer
blocks before it can exit. That leaves the deadline nothing to be but a
watchdog, so it becomes one: a timer armed before the reads, cancelled after
the wait. The `usleep(2000)` poll that held a worker thread for the whole
timeout goes with it. It signals the pid rather than capturing the `Process`,
which a `@Sendable` handler cannot hold.

`ExtensionNodeShims` had the same bug and the worse blast radius — it runs on
the JS queue, so a hung child froze the whole runtime rather than one call. It
now shares `ExtensionAsyncProcess.drain`, alongside the `resolveExecutable` it
already borrowed.

* Join the Finder selection on a linefeed, not a comma

`getSelectedFinderItems` asked AppleScript for a list of POSIX paths and split
the result on `,`, which is what AppleScript happens to join a list with. A
comma is legal in a filename, so any selection holding one came back cut into
two paths that exist nowhere, and the `trimmingCharacters` pass then ate real
leading spaces.

The script sets `text item delimiters` to linefeed, which Finder forbids in a
name, and builds the list in a loop. The loop is not decoration: `POSIX path
of {}` throws, so the old one-liner also failed outright on an empty
selection rather than returning nothing.

Checked both through osascript — a comma-bearing path survives whole, and an
empty selection returns "" instead of error -1700.

* Ask an extension's question through Tinycast's own dialog

`confirmAlert` was an `NSAlert`, which the non-negotiables rule out: an Aqua
alert reads as a different product on this surface, and its `runModal` loop
keeps Carbon hotkeys firing underneath while it blocks the main actor.

It routes through `DialogController` now, like every other question the app
asks. The palette does not need to hide first — the dialog is `.modalPanel`
and the palette `.floating`, so a view command keeps its screen behind the
question, which is what Raycast does.

Two seams widened to carry what the alert already knew. `AppCore.confirm`
takes an optional message, since an extension's alert may have none and
`DialogView` already handles nil. `DialogController.confirm` gained a
`dismissTitle` defaulting to "Cancel" — without it the extension's own dismiss
label was decoded and then thrown away.

`openWithPicker` is still an `NSAlert`. It needs an N-choice shape on
`DialogController`, which is its own change rather than a rider on this one.

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-08-15 02:25:42 +06:00

45 lines
1.5 KiB
JavaScript

// Bundles the embedded extension runtime into Tinycast/Resources/RaycastRuntime.generated.js.
//
// pnpm install && node gen-enums.mjs && node build.mjs
//
// The output is committed (like EmojiData.generated.swift) so building Tinycast never needs Node.
import { build } from "esbuild";
import { mkdirSync, statSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const outFile = resolve("../../Tinycast/Resources/RaycastRuntime.generated.js");
const dev = process.argv.includes("--dev");
mkdirSync(dirname(outFile), { recursive: true });
const result = await build({
entryPoints: ["src/index.js"],
bundle: true,
format: "iife",
platform: "neutral",
// JavaScriptCore on macOS 26 is fully modern; no downlevelling needed.
target: ["es2022"],
minify: !dev,
sourcemap: false,
legalComments: "none",
write: false,
define: {
"process.env.NODE_ENV": dev ? '"development"' : '"production"',
__DEV__: dev ? "true" : "false",
},
banner: {
js: "// Generated by Tools/raycast-runtime — do not edit. Regenerate with `node build.mjs`.",
},
});
for (const warning of result.warnings) console.warn(warning.text);
const [output] = result.outputFiles;
// `__tinycastCompile` has to compile in the *global* scope so the extension bundle it evaluates can't
// see the runtime's own module locals. It is installed by Swift, not here.
writeFileSync(outFile, output.text);
const { size } = statSync(outFile);
console.log(`RaycastRuntime.generated.js — ${(size / 1024).toFixed(1)} KB${dev ? " (dev)" : ""}`);