50 Commits
Author SHA1 Message Date
Abue Ammar 14c08d9380 Search emoji in the Mac's preferred languages (#1268)
Ship CLDR keyword packs for nine widely used languages as plain bundle files and merge the ones
matching Locale.preferredLanguages into the catalog's keywords at load. English stays indexed for
every user and keeps ranking first. EmojiIndex now folds each entry's text once at load through
FuzzyMatch.Candidate, so a keystroke folds only the query.

Closes #890
2026-10-02 03:41:59 +06:00
Abue Ammar 1d7d22663f Add an opt-in settings.json mirror, and custom Snippets and Notes folders (#1186)
* Add an opt-in settings.json mirror for preferences and window management

* Let Snippets and Notes use a chosen folder, from Settings or settings.json

* Keep a Notes or Snippets folder change from reusing the old folder's contents

* format fix
2026-09-27 01:51:22 +06:00
Clément Knodererandabue-ammar b2be48bec8 Polish dialogs and settings editors (#834)
* Polish dialogs and settings editors

* Address dialog review feedback

* Refine dialog presentation and simplify event handling in SettingsEditorPresenter

* Refactor tooltip implementation across various components and update documentation

* Re-centre a growing editor panel and unify the action buttons

A Settings editor panel sizes itself from its content, so one that grew
kept its top edge and walked down the window. The presenter now watches
each panel's own resize, not just its parent's, and reuses `layout()`.

CameraButton was a hand-copied twin of the dialog button and the Quick
Action footer used Aqua controls on vibrancy; both now speak
ModalActionButtonStyle, so every borderless surface answers alike.

Also shortens two doc comments past the 100-character cap and settles
`tooltipDelay` at 0.4s.

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-09-18 02:07:43 +06:00
Abue Ammar f2430e6efc Answer time-zone queries for countries (#710)
`time in uk` earned no card: the zone lookup only knew IANA city names and
a curated alias table, and Foundation carries no country for a zone.

Scripts/gen-countries.js now emits CountryZoneData.generated.swift by
joining IANA's zone.tab, which lists each country's zones most populous
first, with CLDR's English territory names, short forms included. Where
zone.tab's geographic order puts a remote edge first (Lord Howe, Kaliningrad),
the country answers with its capital's clock instead. CalcTimeZone checks
aliases, then cities, then countries, so no existing name changes meaning.
`usa` and `uae` join the aliases, since CLDR carries neither.
2026-09-15 02:20:06 +06:00
Abue Ammar 9ad7f376ce Scale the palette with an Interface Size setting (#597)
Tinycast rendered every surface at one fixed size, which reads small on a
large display and fine on a laptop. General ▸ Appearance now carries an
Interface Size control — Default, Large, Larger — that uniformly zooms the
palette and the surfaces that float with it, at 1.0 / 1.1 / 1.2.

The setting reaches the palette, the ⌘K menu, the extension list panel, Quick
Actions, the snippet prompt, dialogs and HUDs. Settings, Onboarding, Support,
Update, About and Notes never scale: a zoom there only breaks their layout.

`InterfaceMetrics` stores a scale and nothing else, deriving every value from
the `Theme` literal, so `Theme` stays the one place a number is written down.
It reaches views through an `@Entry` environment key defaulting to `.standard`,
which is why `BarButton`, `KeyCapChip`, `PopoverMenu` and the rest render
unscaled in Settings without being forked — the scope is the injection, not the
component. An AppKit site reads `settings.interfaceSize.metrics` where it
computes its frame, so no second owner of the value exists.

Two things in this are not obvious from the diff.

A scaled font is rebuilt from that style's own `NSFontDescriptor` at the scaled
point size, never reconstructed as `.system(size:weight:)` from a written-out
weight table. On macOS `Font.headline` resolves to `.SFNS-Bold` and
`Font.caption2` to `.SFNS-Medium`, so a table lightens both the moment the user
leaves the default size. The same helper yields the `NSFont` twins the caret
width and the AI chips are measured against, which have to move in lock-step
with what SwiftUI renders or the caret detaches from the text.

And the palette's environment is pushed by a `ViewModifier` rather than a
stored `.environment(_:_:)` value. `PaletteWindowController` builds the hosted
tree once and reuses the panel, so a stored value would have frozen at
build time and the setting would never have taken effect — not even on the next
summon.

A length measured against the screen does not scale; a length measured against
our own content does. So `hairline`, `paletteTopMarginFraction`,
`paletteSnapDistance`, `paletteMinimumVisible`, the drop-guide dashes,
`hudEdgeOffset` and every row *count* stay on `Theme`. Scaling rounds to whole
points once, at the leaf accessor, and a derived token composes already-scaled
parts rather than scaling the derived result, so an AppKit frame can never
disagree with the SwiftUI view inside it by a point.

A size change re-enters through `AppCore.track` → `applyInterfaceSize()`, which
drops the cached anchor and re-resolves it — one rule, the summon's. An
untouched palette re-centres at the new width; a dragged one keeps its stored
top-left unless the wider bar no longer leaves `paletteMinimumVisible` on any
display, in which case it falls home. `PalettePanel`'s stale `750, 475` literal
is gone; it reads the tokens.

`ExtensionFormMetrics` becomes a struct taking a scale, staying inside
`Features/Extensions/` and Foundation-only. `EdgeDissolve` derives its bands
from the metrics and resolves to the same 86 and 80 it draws today.
2026-09-12 02:22:01 +06:00
Abue Ammarandjoaogsleite 992c3dcdfc Search Menu Items for the frontmost app (#587)
* Add Search Menu Items for the frontmost app

Fuzzy-searches the frontmost application's menu bar from the palette
and activates the chosen item, via the Search Menu Items launcher
command or its bindable (unbound by default) global hotkey.

The bar is walked once per show, off-main, into plain MenuTreeNode
values: 20 levels deep, 4,000 items, 200 direct leaves per submenu,
1-second budget, every cap truncating silently. Attribute reads are
batched per leaf and only enabled, visible, pressable leaves become
rows, each with its full menu path and shortcut glyph (Shift/Option/
Control bits decoded live, unrenderable keys degrade to path alone,
private-use scalars mapped to arrow and page glyphs). Filtering
reuses the launcher's fuzzy tiers over the frozen snapshot, ranked
once per query change and capped at 200 rows. Activation re-resolves
the row by path, re-checks it, and presses via AXPress after
dismissing the palette and reactivating the frozen target; failures
report through the dialog controller.

Accessibility gates show and activate with an Open Settings recovery.
No new AppEntry.Kind, no visibility gate, no frecency learning, and
hiding the palette resets the session.

Invariants and internals are documented in docs/features/menu-search.md.

Originally submitted as #586. Rebased onto main, formatted, and
documented in a feature doc while folding in a small dedupe of the
shortcut glyph ordering.

Co-authored-by: abue-ammar <iabueammar@gmail.com>

* Drop derivative framing from comments and docs

Removes the wording that described Tinycast's own design as derived
from another launcher: "a port of", "-style", "as Raycast does",
"exactly as in Raycast", "Raycast's own shape/form". Tinycast's
surfaces are its own, and the comments now say what they do and why
rather than what they were measured against.

Every reference where Raycast names something Tinycast actually
reads, parses, fetches or must stay API-compatible with is left
exactly as it was: the extension API and runtime, raycast:// URLs,
raycast-* colour keywords, the .rayconfig importer and RAYCFG3
container, script-command headers, store endpoints, on-disk install
layout and accepted token spellings. Those name a real dependency,
and deleting them would delete the reason that code exists. The
measured comparisons in docs/features/uninstall.md stay too; they
state where Tinycast deliberately differs.

Comments and prose only; no behaviour changes. Two stacked-comment
and three line-length violations are fixed in passing.

---------

Co-authored-by: joaogsleite <joaogsleite@gmail.com>
2026-09-11 22:07:53 +06:00
Abue Ammar 21f9de86d9 Give each command exactly one pane and one switch (#577)
A feature's commands were listed twice — in the feature's own pane and in
Settings › Commands — and gated twice. With Notes on but `Enable Commands`
off, the Notes shortcuts silently did nothing and the pane gave no hint why.

`SettingsTab.ownedCommands` is now the one table of which pane lists a
command. A pane that claims a command owns its shortcut, alias and launcher
checkbox, and its own switch decides whether the command exists, so
`Enable Commands` neither lists nor gates it. `CommandID.owner` inverts the
table once and `CommandCatalog.makeEntry` stamps it onto
`AppEntry.settingsOwner`, so nothing re-derives ownership from an entry id.

Ten panes own commands: AI, Quick Actions, File Search, Notes, Snippets,
Window Management, Clipboard, Emoji, Calendar and Quicklinks. Settings ›
Commands keeps the rest, which no feature switch governs.

One `FeatureCommandsSection` replaces the four bespoke copies, so Clipboard,
Emoji and Snippets gain the alias field and launcher checkbox they only had
in the Commands pane.
2026-09-11 19:28:23 +06:00
Quentin Eudeandabue-ammar 2364cd1164 Background refresh for no-view extension commands (#495)
* Refresh no-view extension commands in the background on their manifest interval

* Keep command metadata out of the extension data file

Drawing a launcher row read every command's metadata, and that metadata
lived in the same file as the extension's LocalStorage and Cache, so
publishing the launcher entries faulted every installed extension's whole
store in synchronously on the main actor and held it there. Give the
metadata its own small file, outside extension-data so the cleanup sweep
doesn't read it as one extension's own. ExtensionStorage returns to what
it was, which retires the tolerant decoder it had grown for the key that
is no longer there.

The store is Observable, so the manual metadataRevision counter goes with
it. A cancelled tick no longer records itself as a timeout: cancelling the
loop preempts the run the way a manual launch does, and only an elapsed
timeout counts as a failure. The Settings toggle now gates on the parsed
interval rather than the raw string, so an unparseable one gets no switch
it can never honour, and a failure is cut to its headline before it reaches
AppEntry rather than at render.

* Say what the extensions-stay-inside rule actually forbids

"Never shared with another feature" reads as a ban on any other feature
rendering an extension-owned view, which LauncherScreen has done with
ExtensionArgumentsAccessory since extensions landed. The rule is about
where a view is owned, not which file renders it: the danger is an
extension's shape reaching DesignSystem or Theme and forcing a change on a
palette surface, not a launcher row embedding a box it never looks inside.

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-09-09 02:05:43 +06:00
Jonas Listandabue-ammar f3337ca8c5 Show a copied colour as the colour, and convert it in the notations people write (#440)
* Show a copied colour as the colour, and convert it in the notations people write

A clipboard entry holding a colour is drawn as one: a swatch in the row and the
colour in the preview. Pasting a colour into the launcher answers with a card,
whose ⌘K menu copies it in any notation.

ColorValue stores sRGB components, so every notation derives from one source
rather than a second parser that can drift. ColorFormat offers the four worth
having — hex, rgba(), hsl(), oklch() — plus their alpha spellings. A colour is
never named: NSColorList naming was built, measured and removed, since it knew
only the 59 names macOS ships and CSS's own are in no catalog at all.

The card is built from the calculator card's own parts, which LeadCard.swift now
owns for all three lead cards, so none can drift in height or hover from another.
PopoverMenu gains a stated-value column and an icon-less row, both opt-in.

* Delete the storage relocation, and the folder that held it

`Tinycast/Migration/` carried one move — the clipboard store out of
`~/Library/Caches` — behind a delete-by date its own harness tripped on. The
date is here, so the folder goes rather than the tripwire being pushed out:
`StorageRelocation`, `storage-relocation-test`, the `TinycastApp.init()` that
called it, and the rows in AGENTS.md and the docs that described it.

The move stays done for anyone who has launched since it shipped. Nothing else
read the code, so nothing replaces it.

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-09-04 01:02:54 +06:00
Abue Ammar aedbb55236 Keep the clipboard history and learned data out of ~/Library/Caches (#380)
* Keep the clipboard history and learned data out of ~/Library/Caches

The clipboard store, the calculator history and the two frecency tallies sat in
Caches, which is excluded from Time Machine and which the system may reclaim
under disk pressure without telling the app. None of them can be rebuilt: the
clipboard offers a Forever retention and explicit pins, and relearning a launch
ranking takes weeks of use. All four move to Application Support, beside
quicklinks and snippets. What stays in Caches is only what a network call can
fetch again — exchange rates, the update check and staged downloads.

Existing installs are moved once by Tinycast/Migration/StorageRelocation.swift.
Image rows hold absolute paths, so moving the blobs without rewriting the rows
would orphan every thumbnail; the relocation rewrites them with a substr prefix
match, which leaves externally-referenced images alone.

The migration is temporary and deletes itself from the schedule rather than the
memory: storage-relocation-test asserts the removal date has not passed, so the
suite fails on 2026-09-05 with the checklist of what to delete.

* Refactor code formatting in CustomCommandTests and CustomCommandEditorSheet for improved readability
2026-08-29 05:26:05 +06:00
Abue Ammar ecf7d3d5b3 Delete both scheduled migrations (#333)
c1bcbb8 shipped two one-time migrations rather than plain deletions,
because v0.7.5 was a shipped stable release and each cleanup would
otherwise have destroyed real user data on update. Both were recorded as
scheduled deletions, to come out two stable releases after that one. It
shipped in v0.8.x and v0.9.8 is out, so both are due.

LegacyHotKeyRecords adopted the pre-`hotkey.<action>` records written
under the `KeyboardShortcuts_` namespace. It consumed each record on
first launch, so anyone who has opened a build in the past year has
already been migrated and the second launch found nothing. Its one call
site in `HotKeyManager.start()` goes with it; nothing else went dead,
since `candidateActions`, the reused coder pair and
`KeyShortcut.init(carbonKeyCode:carbonModifiers:)` all have other
callers.

ClipboardStore's two `ALTER TABLE` guards added `source_app` and
`pinned_at` to databases predating each column. With them gone the
`items_pinned_at` partial index moves into `schema`, where it always
belonged — it sat outside only because the column it indexes might not
have existed yet — and `columnExists` had no other caller. This is the
half with a live consequence: a database still missing either column can
no longer be opened, and the store deletes and recreates one it cannot
open, taking the history with it. That was the accepted cost of the
scheduled deletion and it lands the moment this ships.

The harness case covering the clipboard migration goes too, along with
`seedPrePinDatabase` and the `sqlite3`-CLI helper that existed only to
write a legacy database and read the schema back. The remaining pin
cases still cover the two-branch load shape.

AGENTS.md counted the two migrations; it now states the rule without a
count, since there is nothing left to count.
2026-08-25 01:40:09 +06:00
Mikhael Khrustikandabue-ammar d87fc624b5 Add input source switcher (#267)
* Add input source switcher

* Resolve the input source without re-reading the source list

Summoning the palette scanned every enabled input source twice — once in
beginSession to validate the preferred ID, once in endSession to find the
source to restore. The summon path must stay clear of work like that.

The session now holds the TISInputSource that TISCopyCurrentKeyboardInputSource
already handed us, so hiding restores it directly. It also skips the session
entirely when the preferred source is already active, and restores only when
the palette is still on the source it applied, so a switch the user or another
app made in between stands.

Properties decode through one generic reader that returns nil on an unexpected
type, rather than an unsafeBitCast that would trap on one.

Settings no longer clears a configured source that has since been removed; it
lists the saved ID instead. It refreshes on the TIS enabled-sources
notification rather than on every app activation, and the row now shows even on
a Mac with a single layout.

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-08-19 03:53:14 +06:00
Abue Ammar 541c35dfa8 Add a Light appearance, with Dark frozen as the baseline (#274)
* Add a Light appearance, with Dark frozen as the baseline

Tinycast forced `.darkAqua` in `AppCore.start()`, and `Theme.Colors` was
written against that: literal white alphas over an assumed-dark backdrop,
with the backdrop itself inlined at five view roots.

Appearance is now a setting (System / Light / Dark, System by default).
`.system` assigns `nil` so AppKit follows macOS on its own, and a change
applies live through the existing `AppCore.track` observation.

Dark does not move. Every token resolves per appearance through
`Theme.Colors.ramp`/`adaptive`, and each dark branch *restates* the literal
it replaced rather than re-deriving it. `appearance-test` compiles the real
`Theme` and asserts every dark branch is byte-identical to the shipped
value, so a future edit to a dark stop fails the suite.

Also folded three literals that were duplicated across views into tokens
(`iconPlaceholder` x6, `textPrimary`, `sheen`), and replaced the
`panelDimming: CGFloat` alpha with a `panelScrim` colour.

Extensions are included: their own fills move to `ExtensionColors` (kept in
the feature, per the Extensions invariant), `environment.appearance` reports
the real appearance instead of a hardcoded "dark", and Raycast
`{light, dark}` icons and colours are picked by the surface they're drawn
on. A running command keeps the appearance it booted with.

Left fixed on purpose: the `EdgeDissolve`/`OverflowFade` gradients are mask
luminance rather than colour, and `ExtensionTintColors` plus tinted
`IconCache` tiles keep white ink because a saturated tile carries its own
contrast. `IconCache` rasterizes off-main, so it cannot hold a dynamic
`NSColor` at all: it carries the surface explicitly and keys the cache on it.

* Follow the system icon style, and unify icon invalidation

macOS restyles the icons `NSWorkspace` hands out when System Settings →
Appearance → "Icon & widget style" changes (Default / Dark / Clear /
Tinted). Tinycast cached the old bitmaps and nothing asked for new ones, so
app and system icons kept the style they were first drawn under.

`IconStyleMonitor` observes the global `AppleIconAppearanceTheme` default.
KVO there fires cross-process, so System Settings writing the key is itself
the signal — no distributed notification, no polling, no private API. The
`@objc` name has to be the default's own spelling: `UserDefaults`
synthesizes notifications for a key path named exactly like the key, and
the Swift-cased name never fires. Measured both ways.

`IconCache.invalidateStyled()` is now the one invalidation path, and it is
also what our own appearance change goes through — a symbol tile is drawn
for a surface, an app icon for a system style, and both are stale for the
same reason. It drops the cached bitmaps and bumps a generation carried in
every cache key, so a decode already in flight writes under the old key
rather than repopulating a stale icon just after the purge.

Views subscribe by keying their fetch on `IconCache.style.generation`:
`IconRequest` wraps the view's own key for the five `.task`-driven icon
views, and `IconCache.observeStyle()` covers the three that resolve an icon
synchronously in a body. It is reached through `IconCache` rather than
injected because icons are drawn in menus, popovers and every list, where a
missed injection would be a silent staleness bug rather than a loud one.

Two bugs fell out of unifying this. `AppIconView` never depended on the
appearance at all, so a `.symbol` entry kept its old tile when Tinycast's
own appearance changed. `FileSearchList` and `UninstallView` guarded their
fetch with `image == nil`, which would have held the stale bitmap even once
the id moved.

* Trim the comments added by the appearance work

Cut the blocks back to one or two lines, dropped the ones restating what the
name already says (`iconPlaceholder`, most of `ExtensionColors`), and fixed
the one place two inline comments ran together.

* Wait for the icon swap to land before re-caching

AppKit posts NSWorkspaceIconAppearanceConfigurationDidChange 25-120ms before
NSWorkspace vends the new style, and the lag jitters run to run. Since the
images it hands back are live objects macOS restyles in place, flattening one
on the signal froze the outgoing style into a bitmap keyed to the incoming
generation, which nothing invalidated again. That is the mismatch; the jitter
is why it was intermittent.

Flattening at that instant also forces a cold IconServices regeneration,
measured at 160x the settled draw cost (6.41s vs 0.04s of draw for 66 icons),
which is the multi-second stall on a full index.

IconStyleMonitor now polls IconCache.styleFingerprint() until the pixels
actually move, then invalidates once. The notification also covers restyles
the AppleIconAppearanceTheme KVO missed, since under Default the key stays
absent while icons still follow the system light/dark flip.
2026-08-19 03:34:06 +06:00
Michael AristarcoandAbue Ammar 0747ed2d51 Add native floating Markdown notes (#232)
* Add unlimited live Markdown notes

* Preserve Markdown in Notes copy and cut

* Keep inserted note links editable

* Preserve caret through note block formatting

* Make note formatting state-aware

* Compose nested note formatting

* Anchor note caret during Markdown reveal

* Add native Markdown list editing

* Polish note Markdown presentation

* Cover canonical note editor interactions

* Avoid redundant note editor layout

* Preserve Markdown during rendered replacements

* Make note horizontal rules non-destructive

* Make note formatting context-aware

* Keep task toggles in rendered mode

* Fix nested and mixed note list editing

* Preserve reverse note selections

* Keep note formatting focus stable

* Report mixed note formatting honestly

* Preserve note line endings during formatting

* Recover notes after external removal

* Flush notes before collection mutations

* Release note task overlays

* Move notes filesystem effects into services

* Remove Notes design specs from PR

* Make file search policy test deterministic

* Make notes monitor tests deterministic

* Polish notes header and switcher

* Simplify Notes to a plain Markdown editor

* Improve Notes dragging and natural sizing

* Observe TextKit bounds for Notes resizing

* Measure Notes height from TextKit fragments

* Measure initial Notes content at panel width

* Skip unused Notes height measurement

* Require immediate Notes deletion sizing

* Optimize Notes height measurement

* Fix hotkey-test for the three bindable Notes commands

* Apply swift-format to the Notes branch files

* Fix broken project file and Notes settings row icon

Regenerate project.pbxproj from project.yml — the union-merged copy
committed in 8bb3918 does not parse. Update NotesSettingsView to the
post-merge AppIconView API, replacing the removed symbolIconName.

* Keep the editor mounted while the switcher overlays it

Opening and closing the switcher used to swap the editor out of the
hierarchy, tearing down the TextKit document, its coordinator and the
undo manager. Presenting the switcher as an overlay over the still-
mounted editor preserves undo history, the caret and the scroll position
across a switcher round trip.

* Cancel an in-flight search when renaming a note

Rename refreshes the summaries but left searchResults holding the
pre-rename summary, so the switcher row kept its old title until the
query changed. select and trash already cancel the search; rename now
does too.

* Notes search: never show stale results and clear task handles

A new non-empty query cancelled the old worker but left the previous
query's rows on screen until the new search landed, so results did not
match the query text for the whole debounce plus search. Clear the
results up front, and mirror cancelSearch by dropping the task handles
on the empty-query path.

* Remove unused Notes coordinator selection and active id

updateSwitcherSelection has no call sites and activeID is never read
(the callers use store.activeID directly).

* Reuse the last measured editor height per document

Every panel show recomputed the full document layout on the main thread,
and a switcher close that rebuilt the same document measured it twice.
Remember the last laid-out height per (id, epoch); live height reports
overwrite it, so an unchanged note pays for the layout once.

* Supersede in-flight Notes operations instead of dropping them

select, rename and trash each guarded on operationTask == nil, so a
second user action while one operation was in flight was silently
discarded, and the capture/nil/re-check scaffolding was triplicated.
A shared runOperation helper cancels and supersedes the in-flight
operation; generation checks still gate any presentation it completes.

* Make the notes load-failure wait fail closed

The waitUntil for the external-load .failed state never asserted its
outcome, so if the reconcile never landed within the timeout the harness
still reported success. waitUntil now returns whether the condition was
met and the load-failure wait is checked, matching the fail-closed
pattern in file-search-session-test. Also drop the gratuitous sleep
between the two updateSource calls: the second one cancels the first's
pending save either way.

* Pin the built-in hotkey catalog to the bindable commands

The fixed action list lived inside HotKeyManager, which no harness
compiles, so dropping a Notes case from candidateActions passed every
test. Move it to HotKeyAction.builtInActions and assert that every
bindable command appears among the built-ins.

* Remove write-only note fields and the unused search excerpt

NoteSummary.byteCount and NoteDocument.modifiedAt are written but never
read, along with the modificationDate helper they fed. The search
excerpt is computed then discarded before any view sees it, so the
excerpt helper and its first-body-range tracking go too, and an empty
query in match now returns nil instead of a fabricated score-0 result.

* Align testing.md's formatting note with development.md

The lint section linked to the wrong anchor: the formatter discussion
and its measurements live under development.md's Formatting section, not
Linting.

* Align NotesCoordinator with the core-based coordinator convention

Notes was the only coordinator that injected reportFailure, confirmTrash
and showMessage closures instead of holding AppCore, which put the trash
and termination copy in AppCore's wiring. Adopt `core: AppCore` like every
other coordinator, move the trash-confirmation copy to its call site, and
move prepareNotesForTermination into the coordinator as prepareForTermination
so the notice copy lives with the feature.

* Move Notes' drag-or-click handle out of the shared drag handle

Notes added an onClick variant to the shared WindowDragHandle so the
title could both drag the panel and open the switcher. Restore the
shared handle to its palette-owned drag-only baseline and give Notes
its own clickable handle, which also fixes the latent race of reading
re-bound callbacks after a mid-drag SwiftUI update.

* Unify feature command visibility behind one AppIndex setter

AppIndex used to know which Commands-catalog entries belonged to which
feature: file search, Notes and Quicklinks each had their own boolean
(fileSearchCommandVisible, notesCommandsVisible, quicklinkCommandsVisible),
their own setter, and a shared projectedCommandEntries() that hard-coded
the feature split (== .searchFiles, isNotesCommand, isQuicklinkCommand).

Every other feature gates presence by owning its own slice (window
management, snippets, extensions, quicklink entries), so these three were
the inconsistent ones — AppIndex carried feature knowledge it never needed.

Replace the three booleans and setters with a single hiddenCommands set
and one generic setCommandsVisible(_:, _:), computed commandEntries from
CommandCatalog.all minus the hidden set, and let each feature declare the
commands it owns:

- FileSearchCoordinator: setCommandsVisible([.searchFiles], ...)
- NotesCoordinator:     setCommandsVisible([.showNotes, .createNote, .searchNotes], ...)
- QuicklinkCoordinator: setCommandsVisible([.createQuicklink, ...], ...)

Also delete the now-unused CommandID.isNotesCommand / isQuicklinkCommand
flags. The quicklink showInLauncher split is preserved: entries are gated
by enabled && showInLauncher, commands by enabled alone. The Commands
slice still comes from CommandCatalog.all, so ordering, labels, symbols
and hotkeys are unchanged; behavior is identical, verified by the build,
all 33 harnesses, and lint.

* Fix four recovery defects in the Notes store

start() marked the store started before the load ran, so any initial load
failure left it permanently started but empty and every later Show Notes
silently no-opped. It now becomes started only once a document is loaded.

flush() saved directly, outside the saveHasStarted interlock, so it could
overlap the debounced save that the completing save task had just queued.
Both saves carried the same expectedRevision, so the loser reported a
conflict against the winner. flush() is now single-flight and drives the
same scheduler instead of saving behind its back.

NoteFileMonitor stops itself on every event, so the store must re-arm it
after each one. The reconcile and reload failure branches never did, which
left external-change detection dead after one transient error.

list() let one unstat-able entry abort the whole enumeration, failing
list, create, rename and search instead of skipping that file.

* Rebuild Notes on native window chrome and a popover switcher

Notes shipped as a borderless panel with a custom 44pt header, a
content-driven auto-height, a save-status glyph and a full external-file
watcher with conflict copies and a quit veto. That machinery was far more
than the feature needs, and the window did not behave like a Mac window.

Window: a titled, resizable, non-activating panel. AppKit draws the
traffic lights, the drag and the resize, and autosaves the frame under
"Notes Window". NoteWindowLayout and ClickableDragHandle are gone.

The title bar is drawn, not native. A title-bar accessory drops
NSThemeFrame off its centred-title layout, and the accessory also loses
the hit test to the hosting view that covers the window, so the actions
would not have been clickable there. Both now live in NotesView: the
title centred on the window and non-hit-testable, the actions beside it
as the launcher's own capsule (BarButton in frosted(in: Capsule())).
The band carries windowDraggable, since the hosting view eats the click
the title bar would otherwise use. Traffic lights are re-seated to clear
the palette's 26pt corner; AppKit resets them on resize and on every
title assignment, so seatTrafficLights is idempotent and runs on all
three. contentMinSize does not hold a floor on its own, so
windowWillResize returns the clamped size.

Switcher: its own child panel rather than an in-window overlay, so the
list is not bounded by a window that can be 440x180. It closes on
Escape, on a click outside and on resign-key, and carries the chords the
note window can no longer see while it is key.

Watcher and conflicts: deleted. NoteFileMonitor, Failure.conflict,
NoteDocument.Revision, every expectedRevision parameter, the conflict
copies and the termination veto all go. A save now overwrites whatever
is on disk, which is stated plainly in docs/features/notes.md.

Collections may be empty. loadOrCreate becomes load, trash stops
auto-creating a replacement, and deleting the last note closes the
switcher and leaves one empty state.

Dialogs: DialogRequest.symbol is optional so the trash confirmation can
render without a glyph.

* Correct two Notes defects and collapse the feature's duplication

Renaming a note by case or accents alone did nothing: the identity guard
folded both sides, so "todo" -> "TODO" and "Resume" -> "Résumé" returned
the old id and the switcher snapped the title back. The guard now compares
exactly, and the shared name-claiming loop treats a fold-equal candidate as
the note's own file rather than an occupied one.

`NotesStore.start()` returned early once loaded, so a note added through
Open Notes Folder never appeared. It now re-lists on every show and leaves
the active source alone, which is the half a draft would lose.

Both windows share one `NotePanel`: one floating recipe, one Escape rule,
one ⌘N and ⌘⌫, with each subclass adding only its own chords. `create` and
`rename` share one `claimUniqueURL`. The presentation-generation type, the
delete-shortcut policy enum, a pass-through and a dead `isVisible` are gone,
`AppCore` reaches the Notes directory through `AppPaths`, and search sorts
once instead of per match.

172 lines lighter, with notes.md's switcher, title-bar and rename text and
architecture.md's observable count corrected to match the code.

* Reshape the Notes switcher and let hiding leave focus alone

The switcher hung from the title bar's trailing edge at a fixed 340 points,
wearing the note window's dimmed-material recipe. It is now centred on its
host, dropped clear of the first lines of the note, and carries the same
`.glassEffect(.regular)` surface as `PopoverMenu`. The clip runs after the
glass rather than before it: in a borderless child window glass has nothing
to lens, and the opaque backing it falls back to painted the window's square
corners black outside the rounded surface.

240 points is now a ceiling instead of a size. The list reports its own
height and the controller re-anchors the panel to it with the top edge
pinned, so two notes no longer sit in a panel built for eight; the empty and
searching states settle at one fixed band.

Hiding reactivated whichever app was frontmost when the window opened, so
closing a Notes window the user had already walked away from pulled them back
to an app they had left. The panel is non-activating, so neither key state nor
app activation separates "typing in the note" from "in another app, clicking
the traffic light" — what does is whether focus still belongs to the app the
panel took it from, or to Tinycast itself. A third frontmost app means the
user moved on, and the restore is dropped. It is sampled before the order-out,
which can itself change which app is frontmost.

* Format NotesStore with the tree's swift-format settings

Six `recover:` trailing closures predate the last formatting pass over this
file; `.swift-format` breaks a multi-closure call's bodies onto their own
lines, so every save reproduced this diff.

---------

Co-authored-by: Abue Ammar <iabueammar@gmail.com>
2026-08-17 21:44:52 +06:00
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
Abue Ammar c51051ad48 Refactor currency (#243)
* removed currency convert consent

* feat(calculator): add region-based currency conversion

* add region based currency
2026-08-14 20:35:26 +06:00
Michael Aristarcoandabue-ammar 91ba10af3d Search files through the Spotlight index (#222)
* Add Spotlight-backed file search

* Document file search architecture

* Add file search performance baseline

* Serialize Spotlight file searches

* Cancel file search on click-away

* Correct file search documentation counts

* Stabilize file search scroll origin

* Keep file search disabled by default

* Configure file search scopes, ignore patterns and its command

File search resolved its policy from hardcoded rules: home plus the cloud
roots, minus six baked-in directory names. All three are now the user's.

Search Scopes is an editable folder list seeded with home. A configured
home root still expands into its visible children plus the cloud roots,
since ~/Library can never be an MDQuery scope; every other root is handed
to Spotlight verbatim. A cleared list searches nothing rather than
falling back.

Ignore Patterns are gitignore-flavoured globs run through fnmatch with
FNM_CASEFOLD and without FNM_PATHNAME, so **/[Cc]ache/** behaves as
written. FileSearchIgnoreList compiles each pattern once into a literal
name set, name globs and path globs, so matching stays cheap enough for
every candidate. The six shipped rules are compiled in and never
persisted, which is what makes them non-removable and lets the list
change without losing to a stale copy on disk.

Bare * name globs also join the Spotlight expression as exclusions, so
ignored files cannot consume the 1,000-candidate cap. Only that shape is
pushed: ? and [ are literals to Spotlight and kMDItemPath is not
queryable, so path globs stay local. Pushed patterns are escaped and any
still carrying a quote is dropped, since an unescaped one would nil
MDQueryCreate and break every search until it was deleted.

Search Files gains a HotKeyAction of its own, the first built-in command
to have one, so its launcher row prints the chord and both settings panes
carry the same recorder. Its launcher-visibility checkbox binds to
VisibilityStore on the CommandCatalog entry rather than a new setting, so
it is the same switch as the one in Settings > Commands. LegacyHotKeyRecords
now returns nil for an action that postdates the old scheme instead of
inventing a migration key.

Editing either list cancels the session: a result found under the old
rules must not publish under the new ones.

* Configure file search scopes, ignore patterns and its command

File search resolved its policy from hardcoded rules: home plus the cloud
roots, minus six baked-in directory names. All three are now the user's.

Search Scopes is an editable folder list seeded with home. A configured
home root still expands into its visible children plus the cloud roots,
since ~/Library can never be an MDQuery scope; every other root is handed
to Spotlight verbatim. A cleared list searches nothing rather than
falling back.

Ignore Patterns are gitignore-flavoured globs run through fnmatch with
FNM_CASEFOLD and without FNM_PATHNAME, so **/[Cc]ache/** behaves as
written. FileSearchIgnoreList compiles each pattern once into a literal
name set, name globs and path globs, so matching stays cheap enough for
every candidate. The six shipped rules are compiled in and never
persisted, which is what makes them non-removable and lets the list
change without losing to a stale copy on disk.

Bare * name globs also join the Spotlight expression as exclusions, so
ignored files cannot consume the 1,000-candidate cap. Only that shape is
pushed: ? and [ are literals to Spotlight and kMDItemPath is not
queryable, so path globs stay local. Pushed patterns are escaped and any
still carrying a quote is dropped, since an unescaped one would nil
MDQueryCreate and break every search until it was deleted.

Search Files gains a HotKeyAction of its own, the first built-in command
to have one, so its launcher row prints the chord and both settings panes
carry the same recorder. Its launcher-visibility checkbox binds to
VisibilityStore on the CommandCatalog entry rather than a new setting, so
it is the same switch as the one in Settings > Commands. LegacyHotKeyRecords
now returns nil for an action that postdates the old scheme instead of
inventing a migration key.

Editing either list cancels the session: a result found under the old
rules must not publish under the new ones.

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-08-11 03:24:23 +06:00
abue-ammar 0eedaf157d removed unnecessary comments 2026-08-09 01:02:31 +06:00
Abue Ammar 88e6d2a856 Give Settings its own lifecycle, independent of the palette (#209)
* Give Settings its own lifecycle, independent of the palette

Settings, About and Onboarding were owned by PaletteCoordinator through a
shared AuxWindowController, so the palette's coordinator decided when the
Settings window existed. Split them into two surfaces that cannot reach each
other:

  AppCore
  ├─ PaletteCoordinator ────► PaletteWindowController ──► PalettePanel
  ├─ SettingsCoordinator ───► AppWindowController ──────► NSWindow
  ├─ OnboardingCoordinator ─► AppWindowController ──────► NSWindow
  └─ MainMenuController ────► NSApp.mainMenu

PaletteCoordinator loses its `unowned let core` entirely — with Settings gone
it had no reason to reach AppCore at all, which is what makes the separation
structural rather than a convention.

Hiding the palette no longer disturbs Settings. The palette recorded
`previousApp` only when the frontmost app was not us, so summoning it over
Settings kept a stale app and hiding activated that app, burying Settings.
It now records our own key window instead and hands focus back to it.

Build the main menu by hand. SwiftUI installs one during
applicationWillFinishLaunching carrying a real Quit ⌘Q, so ⌘Q terminated the
agent from the Settings window — and from the palette. Ours replaces it in
applicationDidFinishLaunching: ⌘Q is "Close Settings", validated off when no
window is open, and quitting stays on the menu-bar extra and the launcher's
Quit command. The Edit menu is required, not decoration: without it ⌘C/⌘V/⌘A
do not work in Settings' text fields.

The Settings window gains working close, minimize and zoom traffic lights,
a resize floor and frame autosave. AuxWindowController's dictionary of
windows is replaced by one AppWindowController per window, with the
accessory ⇄ regular promotion factored into Platform/ActivationPolicy.swift.

* Declare the main menu, so a scene rebuild cannot restore SwiftUI's Quit

MainMenuController assigned NSApp.mainMenu once in applicationDidFinishLaunching.
That is not durable: SwiftUI rebuilds its menu on any scene change, and toggling
Show in Menu Bar is one — the imperative install was wiped and SwiftUI's real
Quit ⌘Q came back, so ⌘Q nuked the agent again.

Replace the whole controller with a `.commands` block on the MenuBarExtra scene.
A CommandGroup survives every rebuild because it is the scene definition rather
than something racing it, verified through the exact repro:

  ① at launch                    ⌘Q → Close Settings, Quit items: none
  ② after Show in Menu Bar OFF   ⌘Q → Close Settings, Quit items: none
  ③ after Show in Menu Bar ON    ⌘Q → Close Settings, Quit items: none

This also deletes the hand-built Edit and Window menus: SwiftUI's are richer
(Writing Tools, AutoFill, Dictation, Emoji & Symbols) and cost nothing.

Drop ⌘Q from the menu-bar extra's Quit button too. It advertised a shortcut
that no longer quits, and two contradictory ⌘Qs is worse than none.

* Own the activation policy on AppCore, keyed by window identity

ActivationPolicy was a static refcount: hidden global state, which decisions.md
entry 1 rules out, and the `max(0, openWindows - 1)` clamp was an admission that
the tally could drift. A drifted tally strands the Dock icon in the wrong state
with no way back.

Hold the open windows in a Set keyed by ObjectIdentifier instead, owned by
AppCore and injected into AppWindowController. Repeated opens and closes become
no-ops by construction, so the clamp is gone rather than defended:

  second show built a new window: false   policy=regular
  miniaturized Settings  → isOpen=true    policy=regular
  closed Settings twice  → onboarding.isOpen=true  policy=regular
  closed Welcome         → policy=accessory
  reopened then closed   → policy=accessory

Deriving the policy from NSApp.windows was considered and rejected: isVisible is
false while a window is miniaturized, so minimizing Settings would yank the Dock
icon, and at windowWillClose the closing window is still listed.

* Self-review: remove dead accessors and guard the deferred re-raise

Five findings from reviewing the branch as a whole.

`isOpen` on AppWindowController, SettingsCoordinator and OnboardingCoordinator
existed only for MainMenuController.validateMenuItem, which went away when the
menu became declarative. Nothing reads them now, so they go.

PaletteCoordinator.previousApp was never called by anything — dead on main too,
but this branch rewrites the file, so it goes with the rest.

AppWindowController.raise deferred a second makeKeyAndOrderFront with a strong
capture and no guard. A window closed before that turn would be re-ordered front
as a zombie the controller no longer tracks, so it could never be closed again
and the Dock icon would stay promoted for good. Guarded the way
PaletteWindowController.show already guards its own deferred re-assert.

focusExisting was marked @discardableResult but both call sites use the result.

OnboardingCoordinator's doc claimed the wizard is "also re-runnable from
Settings". Nothing in Settings calls it; the claim came from the old
PaletteCoordinator comment and was already untrue.
2026-08-08 20:46:48 +06:00
Abue Ammar 55d69ccd80 Docs/consolidate and standards (#199)
* feedbacks

* Split Tools/ into Tests/ and Scripts/

Tools/ held three unrelated things: 18 standalone Swift harnesses, the bash
test runner, and two Node data generators. Split them by what they are —
Tests/ for the harnesses, Scripts/ for everything executable — and move
build-dmg.sh off the repo root into Scripts/ with it.

The two generated files are regenerated rather than hand-edited, since their
'Generated by' header comes from the generator.

* Split release.md out of development.md

development.md covered two things that change for different reasons and are
read at different times: the local loop, and how a build reaches a user.
Packaging, CI, the release workflow, the Homebrew tap and the website deploy
move to release.md; development.md keeps setup, build, dev channel, editor,
formatting and generated data.

* Rewrite AGENTS.md and de-duplicate the docs around it

AGENTS.md restated standards.md's naming table and comment rules almost
verbatim, and printed the comment-budget shell checks that testing.md and
standards.md also carried — changing the comment cap meant editing three
files. Each rule now lives in exactly one place, with AGENTS.md stating it
and the doc carrying the reasoning.

Promotes the latest-only posture from a mid-document paragraph in
standards.md to the first thing AGENTS.md says, in directive form: prefer
modern Apple APIs, migrate rather than wrap, no compatibility layers, and
never add backwards compatibility unasked.

standards.md's Enforced table and testing.md's scattered checks collapse into
one Definition of done in testing.md. CONTRIBUTING.md's Code style and
Reviewing-your-own-diff sections were a third copy of standards.md and are
now links.

* Adopt swift-format and SwiftLint; no source touched

Formatting was deliberately absent because a shared config had been harmful
once (decisions.md entry 26). Two things make it safe now: the config is
committed, and swift-format runs with respectsExistingLineBreaks so it never
re-flows a break a person placed.

swift-format over SwiftFormat because it ships in the Xcode toolchain — no
Homebrew dependency, and the official swiftlang.swift extension drives it, so
format-on-save needs no third-party editor extension. Measured on this tree it
also changed 575 lines against SwiftFormat's 2,208.

SwiftLint carries the two comment rules that were awk/grep one-liners pasted
into three docs. Scoped to Tinycast/, which is what those checks covered.

Also replaces the manual xcode-build-server setup with Scripts/setup-editor.sh,
drops the hardcoded /opt/homebrew path from tasks.json so a non-Apple-silicon
Homebrew works, and deletes dependabot.yml.

* Keep SwiftLint, drop the formatter, and index Tests/ for SourceKit

Both formatters were adopted, configured and measured on this tree, and both
wanted to change code rather than lay it out.

swift-format restructured 74 files identically at lineLength 120 and 1000 —
splitting signatures, moving braces, adding trailing commas SwiftLint then
flagged. SwiftFormat could be tuned to leave structure alone, but only after
disabling eight rules, and a rule left on introduced a semantic error:
`--enable isEmpty` rewrote `$0.count > 0` to `!$0.isEmpty` in
LauncherRankingStore and PaletteRowIndex, whose `count` is a hit count, not a
collection count. Two harnesses stopped compiling. SwiftLint's own empty_count
flags the same two lines and is disabled for the same reason.

So decisions.md entry 26 keeps its conclusion and gains the evidence.
Scripts/lint.sh is the single command; errors block, warnings do not. The
baseline is 0 errors and 35 warnings, all real signal.

Also fixes the gap that nothing in Tests/ resolved in the editor: xcodebuild
never compiles the harnesses, so .compile had no entry for them. run-tests.sh
gains a --compile-db mode and setup-editor.sh merges it in, so the source lists
stay in one place. Re-run after adding a harness; the build task does it too.

* Drop the editor setup script, and make the Tests/ index actually work

Scripts/setup-editor.sh and .editorconfig are gone: which editor a contributor
uses, and how they configure it, is not the repo's business. Generating
buildServer.json goes back to a documented one-time command in development.md,
framed as an optional note rather than a setup step.

The Tests/ indexing it carried was also broken — verified by driving
sourcekit-lsp directly. It emitted relative paths, which sourcekit-lsp does not
resolve against the entry's `directory`, and no -sdk. Both fail silently. Now
absolute throughout, and the merge moved into run-tests.sh --index, which
already owns the source lists, so no separate script is needed.

Measured on fuzz-test.swift: 60 errors without an entry, 0 with one.

Also shortens eight ui.md headings that embedded file paths, which had made
four cross-document anchors dead (#dialogs--hud, #liquid-glass). The paths move
to a Source: line under each heading.

* Run lint in CI, and stop re-running CI on main

The lint step goes in the existing job rather than a second one, so there is no
extra runner spin-up. It shells out to Scripts/lint.sh with
SWIFTLINT_REPORTER=github-actions-logging, so violations annotate the PR diff
inline instead of being buried in the log, and the workflow still names no rule
itself. `if: always()` means a failing harness does not hide the annotations.
Warnings annotate only; lint errors fail the job, matching a local run.

The push trigger on `main` is gone. `pull_request` already builds the merge
result, so a post-merge run re-tests content CI has seen. workflow_dispatch is
the escape hatch for a direct push to main, which now gets no run otherwise.
2026-08-07 07:07:54 +06:00
Abue Ammar c1bcbb84da Retire the compatibility machinery, behind scheduled migrations (#198)
Phase 35 of docs/refactor/. The legacy KeyboardShortcuts_ key namespace
and HotKeyBinding's hand-written Codable seam are gone: bindings persist
under hotkey.<action> with the synthesised conformance, and
HotKeyAction.defaultsKey stays the one place a key is computed.

POLICY.md assumed there are no existing users. v0.7.5 is a shipped stable
release, and both of the phase's deletions would have destroyed real user
data on update: every hotkey silently unbound, and the entire clipboard
history discarded, since v0.7.5 predates pinned_at and the store deletes
a database it cannot open. Each cleanup therefore ships behind a one-time
migration instead of a plain deletion.

- LegacyHotKeyRecords adopts the shipped records once, reading both old
  shapes and never overwriting a key the user has already rebound.
- ClipboardStore's two ALTER TABLE guards stay exactly as they are.

Both are recorded in POLICY.md as scheduled deletions, to come out two
stable releases after this one ships. Neither persists a flag, so each
removal is a pure deletion. Net line count is positive as a result, which
the phase document forbids; that is the deliberate cost of the migrations.

AGENTS.md's refactor banner is removed and its hotkey clause amended.
Raycast import and the snippet Markdown serializer are untouched: another
application's format, not our legacy.
2026-08-07 03:44:41 +06:00
Abue Ammar 946c5907b4 Set a checkable comment budget and trim the codebase to it (#197)
One line, never two consecutive; 100-character hard cap; longer
explanations belong in docs/<subsystem>.md. The existing rule was
satisfiable in letter by writing one 588-character line — 51% of comments
in the codebase exceeded 100 characters against 158 code lines that long.
The two grep commands ship alongside the rules, so the budget is
checkable rather than aspirational.

Stacked blocks 181 -> 0 and over-cap comment lines 953 -> 0 across every
editable hand-written file. The 24 that remain are 23 in EdgeDissolve and
ThinScrollbar and 1 in CurrencyData.generated.swift, all on the
must-not-change list.

No code changed: the Swift half of this diff contains only comment lines,
verified by grep rather than argued.

Rationale was relocated, not deleted. docs/hotkeys.md gains a Hyper Key
section it never had — the Caps Lock to F18 HID remap and why it is
unavoidable, toggle semantics versus a key-state read that races the
release, the left-side device bits, the self-marker and the watchdog.
docs/clipboard.md gains the two-indexed-branch load query, docs/emoji.md
the per-row interaction cost, docs/calculator.md the four date grammars.
docs/architecture.md gains the pure/effect/view layering and the tree.

Debug and Release clean at 0 -> 0 warnings; all 18 harnesses pass.
regression.md and review.md not run — operator waived further testing.
2026-08-07 02:55:01 +06:00
Abue Ammar 778ce71390 Add a SettingsBackup completeness harness (#196)
The mirror stays hand-written on purpose: the explicit omission of
snippetsEnabled is a security control, and any reflection-based scheme
would silently make the next consent flag backup-restorable. Instead the
harness fails when a setting is added and neither backed up nor listed in
deliberatelyExcluded with a reason.

SettingsBackup.swift cannot compile standalone -- its gather/apply
extension needs AppCore -- so the coverage tables live in a
Foundation-only SettingsBackupCoverage.swift, and AppSettings' key enum
is extracted to a CaseIterable AppSettingsKey. Named that way because
SettingsKey already exists and is referenced inside gather/apply, which
this phase must not touch. Every raw value is spelled out, so renaming a
case cannot rename a persisted key.
2026-08-07 02:10:38 +06:00
Abue Ammar 937a5726db Close the AppCore residue left by phase 32 (#195)
Three survivors, one finding: AppCore still owned what comparable types
own for themselves. QuicklinkCoordinator now takes a paletteCoordinator
like its six siblings, so the showPalette/hidePalette forwarders go.
runWindowCommand becomes WindowCommandCoordinator, the eleventh, so no
feature action is implemented on AppCore. SettingsBackup and
BackupActions take core as a required parameter, retiring a default
argument no caller ever supplied.

Deletes handleReopen and showSettings alongside the two the phase names,
so AppCore carries no palette forwarder at all. That requalifies four
call sites in TinycastApp and AppDelegate, both on the phase's
must-NOT-change list, on an explicit operator decision: the phase
deletes showPalette while forbidding its only caller. Both files still
reach AppCore.shared and the count is still six, so the boundary's
stated rationale -- no environment in a Scene's menu content -- holds.

AppCore 319 -> 290; AC7 wanted under 280, and the phase's -45 estimate
does not match the ~28 lines of methods it names. All 17 harnesses pass.
The manual verification pass was waived.
2026-08-07 01:48:19 +06:00
Abue Ammar 401632ee8e Stop overloading Coordinator in SnippetRepository (#190)
* Stop overloading Coordinator in SnippetRepository

The file carried three meanings of one word: the vocabulary's ten feature
action surfaces, NSFileCoordinator at line 342, and a private NSLock wrapper.
The mutex is now a DirectoryLock interned by canonical channel-directory path
in a DirectoryLockTable, so Coordinator means the action surface and nothing
else.

Four private identifiers, no API surface, no logic: normalising the rename
pairs makes both sides of the diff identical. Registry and ViewModel are now
absent from Tinycast/ entirely, so the AGENTS.md exception that carved out
CoordinatorRegistry is gone with it.

* Trim the naming-vocabulary exceptions to live ones

The Coordinator bullet described a collision that the previous commit
removed, and the Registry/ViewModel bullet carried a grep claim nobody will
re-verify. The prohibition stays; the history and the claim go.
2026-08-06 02:52:48 +06:00
Abue Ammar 277e96ff62 Write down the naming vocabulary (#189)
The suffix table goes into AGENTS.md with its membership lists, its seven
documented exceptions and the rule that a new suffix means a new row in the
same commit. Every membership was re-derived with git grep rather than
copied from the phase document.

Two renames make the table true: CommandRegistry -> CommandCatalog, which is
a static namespace over a built-in list like the three Catalogs it now joins,
and PaletteViewModel -> PaletteState, which is shared state read by the
window controller and the panel rather than a per-view VM. Registry and
ViewModel are now absent as top-level suffixes.

Manager stays as a closed set of two. ClipboardManager owns the capture
policy a Monitor would not, and HotKeyManager drives Carbon registration and
double-tap dispatch rather than merely holding bindings. No persisted key,
raw value or column name is touched -- normalising the rename pairs makes
both sides of the Swift diff identical.
2026-08-06 02:47:04 +06:00
Abue Ammar 810771e604 Move every feature into Features/<Name>/ and delete Core/ (#188)
Co-locate each feature's Model (pure, harness-compiled), Service (effects),
UI and Settings under one folder, for thirteen features. Settings/ is reduced
to the shell — SettingsRootView, SettingsTab, AppSettings — plus Panes/ for
the two panes no feature owns. Tinycast/Core/ is gone: all 88 files rehomed,
Features/ 51 -> 139, Settings/ 25 -> 5.

Contents unchanged. 133 of 134 moves are 100% similarity; two of the three
permitted splits (LauncherView, SettingsRootView) are cut-and-paste, proven
byte-identical against HEAD. A file under Model/ imports neither AppKit nor
SwiftUI, which is the checkable form of the purity invariants.

Split 2 (ClipboardView) is reverted: AsyncThumbnail is private with callers
on both sides of the boundary, so the split would need to widen it.

All 17 harness command lines updated in docs/development.md, AGENTS.md,
checklists/testing.md, checklists/build.md and ci.yml, plus both
Tools/gen-*.js output paths — those write the generated sources and would
otherwise recreate Core/ on their next run.
2026-08-06 02:04:22 +06:00
Abue Ammar e03bdfd653 Move the window surfaces, the palette shell and the composition root (#187)
Windows/ takes the dialog, HUD, aux-window and About code — including
AuxWindowController, which had been living at the bottom of AboutView.
Palette/ takes the panel, controller, root view, screen protocol and the
palette state types extracted from AppCore. Contents unchanged.
2026-08-06 01:34:10 +06:00
Abue Ammar 2a240436d8 Move the design-system primitives and platform shims out of Core/ (#186)
Pure file moves. Core/ mixed design tokens, eleven view modifiers, window
plumbing, stores and pure algorithms in one 50-file namespace. Contents
unchanged; EdgeDissolve and ThinScrollbar are moved but not opened, per
AGENTS.md. KeyCapChip and IconCache are cut and pasted out of Theme.swift
and AppIndex.swift, byte-identical to their source hunks.

The two harness command lines that name a moved file are updated in the
same commit, in all four files that carry one: docs/development.md,
docs/snippets.md, checklists/testing.md and ci.yml.
2026-08-06 01:17:32 +06:00
Abue Ammar 0eb506da42 Add the PaletteScreen protocol and the selection harness (#176)
Scaffold for the palette split. The harness lands first, deliberately:
the flat-selection invariant is currently maintained by eight independent
index computations that phases 20–23 rewrite, and a violation is silent.
Nothing conforms to the protocol yet.

PaletteRowIndex is the pure map from the flat index onto visible row
order, in its own Foundation-only file so the harness can compile it —
PaletteScreen itself needs SwiftUI. Proven to fail on a deliberate break.
2026-08-05 11:24:51 +06:00
Abue Ammar f8d9b4dc9d Migrate the remaining ObservableObject types to @Observable (#175)
The seven phases 11-18 left behind: LauncherRankingStore,
CustomCommandStore, SnippetKeywordListener, HyperKeyTap, DoubleTapMonitor,
OnboardingModel and ArgumentValues. Combine's observation machinery is now
gone from the app; the two surviving imports drive Timer publishers.

Caches and event-time state stay untracked: LauncherRankingStore.lookup is
written from a launcher render, and the two taps' hold state is written from
CGEvent callbacks. Both isolated deinits and every retained handle are
untracked for the same reason as the SQLite stores in phase 17.
2026-08-05 09:10:25 +06:00
Abue Ammar 5e81f64b28 Refactor plan (#156)
* arch review

* updated review

* Add the refactor execution playbook

Turns docs/architecture-review.md into 34 small, independently shippable
and independently reversible phases, sequenced so that stopping after any
completed phase leaves a shippable app.

- ROADMAP.md — execution order, dependency graph, effort, risk register
- DAILY.md — the operating loop and the instruction to give per phase
- phases/01..34 — one complete spec each: objectives, file boundaries,
  acceptance criteria, verification, rollback, reviewer notes
- prompts/ — the standing contract plus a per-phase kickoff
- checklists/ — build, testing, regression, review; reusable every phase
- progress/template.md — status, measurements, follow-ups, rollback notes

Documentation only. No Swift source touched.

* Adopt a no-migration compatibility policy

Treat the app as brand new: no backward compatibility, no migration code,
no legacy support. Persisted keys, enum raw values, storage locations and
serialisation formats are all free to change; local data is disposable and
a clean install is the only supported scenario.

- POLICY.md — authoritative; overrides the phases, the checklists, the
  architecture review and AGENTS.md
- system-prompt.md — the policy is now part of the standing contract
- phases 05, 06, 15, 16, 17, 27, 30, 31, 33 — compatibility requirements
  removed; risk levels dropped on 05, 06, 16 and 30
- regression.md — "Data safety" becomes "Clean install"; the storage test
  is now a wiped Dev channel, not a preserved one
- review.md — migration code is a defect; renamed keys must move every
  producer and consumer together
- phase 35 (new) — delete the machinery the policy makes dead: the legacy
  KeyboardShortcuts_ namespace, HotKeyBinding's Codable seam, and
  ClipboardStore's ALTER TABLE migrations

Three carve-outs are documented and still enforced, because they look like
compatibility and are not: fresh-install defaults (the absence-vs-false
idiom in AppSettings), internal consistency within one build, and external
formats (Raycast import, snippet Markdown).

* format fix

* Fix the precedence contradiction in the standing prompt

The compatibility policy was inserted between the operator's "Read before
you write" and "Refactor Context" sections, splitting one argument in two,
and it claimed to override the phase document — the opposite of the rule
those sections establish.

- The operator's two sections are restored to their original wording and
  put back together.
- One explicit precedence ladder replaces the competing claims: behavioral
  invariants > POLICY (compatibility only) > phase document > this prompt >
  the architecture review, which is rationale and never an instruction.
- POLICY.md's authority is scoped to compatibility and migration questions
  so the two documents cannot disagree.
- The prompt now states why conflicts are expected: AGENTS.md is loaded
  automatically before anything the operator pastes, and it describes the
  architecture the refactor is changing. Four concrete examples are listed
  so a phase is not mistaken for an error.

* Flag the in-progress refactor at the top of AGENTS.md

AGENTS.md is loaded into every session before anything the operator
pastes, and it describes the architecture the refactor is changing. The
correction lived only in the standing prompt, so it arrived fourth — after
an agent had already read the rules a phase is about to contradict.

The banner scopes itself: structural rules are in flux and a phase
overrides them; behavioral invariants always hold and a phase that
contradicts one is wrong. Work outside docs/refactor/ ignores the box
entirely.

Phase 35 now owns removing it — it is the phase that amends AGENTS.md, and
a banner announcing a finished refactor would be its own kind of lie.

* Compress AGENTS.md from 279 to 181 lines

Auto-loaded context drops from ~6,400 to ~3,655 tokens per request. No
rule removed — all 41 were checked individually against the old file.

The problem was altitude, not content: rationale was duplicated at full
length from docs/*.md into the file that loads on every single request,
so 30 invariants competed for attention with the paragraphs explaining
them. Same triage as the H-1 comment finding — keep the rule, point at the
doc that explains it. All 18 relocated explanations were verified present
in docs/ before anything was cut.

- The purity list becomes a table (file → harness → what is injected).
  It was a 25-line prose blob; it is the most operationally load-bearing
  section here and is now scannable.
- Invariants grouped: ownership · purity · safety and permissions · data
  and formats · UI.
- "Additional Documentation" merged into Architecture — it was a second
  copy of the same link list.
- Philosophy keeps only what is Tinycast-specific; the rest duplicated
  the global CLAUDE.md.

* Restore the detailed invariants; trim only the duplication

Reverts the 279→181 compression. The per-invariant detail is what makes
those rules stick, and compressing them to one-liners traded that for
tokens that are cached anyway.

Two removals, both verifiably duplicate, nothing relocated:

- Project Philosophy: five of its six bullets restated the global
  CLAUDE.md (simple over clever, declarative views, Swift 6 isolation,
  remove dead code, production quality). Only the Tinycast-specific
  comment rule remains, with a pointer saying where the rest lives.
- Additional Documentation: every one of its fourteen links already
  appeared in the Architecture subsystem list or the Project build
  bullet. Verified all fourteen are still reachable after removal.

~5,730 tokens auto-loaded, down from ~6,400 before this session.
2026-08-04 17:49:32 +06:00
Abue Ammar 1012e2f8da Add Quicklinks (#154)
* implemented quicklink

* finally fixed the god damn layout shift on placeholder
2026-08-04 00:20:47 +06:00
Abue Ammar 248cdfed6c Search apps by their macOS alternate names (#153)
* Search apps by their macOS alternate names

Reads Spotlight's kMDItemAlternateNames during the existing background
scan, so an app renamed by its vendor stays findable by the old name:
Codex -> ChatGPT, iBooks -> Books, iCal -> Calendar, System Preferences
-> System Settings, browser -> Safari.

Searchable fields stay separate rather than being flattened into one
string, because which field matched is what picks the ranking band:

    5/3  display name (plus a snippet keyword)  literal / subsequence
    4/2  Spotlight alternate names              literal / subsequence
    1    bundle identifier                      literal only
    0    executable name                        literal only

A literal hit on a weaker field outranks a subsequence hit on a stronger
one, so a declared alias beats incidental letter soup, while a real
prefix hit on a display name still wins outright. Bands sit one stride
apart, 10x FuzzyMatch's range and 200x the frecency cap, so a learned
boost still reorders within a tier and can never cross one.
LauncherRankingStore is untouched.

FuzzyMatch moves to a Foundation-only Core/SearchRelevance.swift that
Tools/fuzz-test.swift now compiles for real, retiring the hand-mirrored
copy invariant.

Two things the real metadata forced:

- Spotlight ships junk. Every bundle lists its own "<Name>.app", and
  Home/Journal/Maps/Passwords/Weather ship untranslated ALTERNATE_NAME_1
  placeholders. Unfiltered, "app" matched most of the index.
- Identifier fields take literal matches only, against the bundle id
  with its leading reverse-DNS component stripped. Measured, matching
  the full string made "com" return 90 of 97 apps.

A Spotlight round trip costs ~0.8 ms per bundle and the scan reruns on
every launcher open, so SpotlightNames.Cache re-reads only bundles whose
modification date moved: 76 ms cold, 0.2 ms after.

Closes #141

* CI: compile the real scorer into the fuzz harness

fuzz-test.swift no longer runs as a single-file script — it compiles
Core/SearchRelevance.swift instead of carrying a copy of FuzzyMatch, so
`swift Tools/fuzz-test.swift` can no longer resolve SearchFields or
SearchRelevance. docs/development.md was updated for this; the workflow
was not.
2026-08-03 22:16:19 +06:00
Abue Ammar 66d6d19b64 Uninstall Application: remove an app and its leftovers (#145)
* implemented uninstaller

* uninstaller indexing increased

* fixed inconsistency

* fixed layout shift

* implemented built in app uninstaller
2026-08-03 02:40:05 +06:00
Abue Ammar c641455b2e Support double-tap modifier shortcuts (⌘⌘, ⌃⌃, ⌥⌥, ⇧⇧) (#143)
* implemented double tap shortcut record

* Fix recorder width jump; drop unused status enum and title

The shortcut recorder sized to its content, so its box resized as it moved
through "Record Shortcut" to held modifiers to keycaps to "Used by ...".
Pin it to Theme.Size.shortcutRecorder (160pt), which fits both the widest
chip row and the longest built-in conflict message; a longer third-party
app name truncates rather than resizing the box.

Also drop DoubleTapMonitor.Status, whose .off/.active cases were written
but never read, in favour of a needsAccessibility flag, and remove the
unused DoubleTapModifier.title.

* fixed inconsistency

* fix hotkey register popover

* fix popover for shortcut

* fix pop over
2026-08-02 23:53:11 +06:00
Abue Ammar 10c64c1217 Auto-detect and import Raycast v1 (.rayconfig) exports (#140)
* feat: auto-detect and import Raycast v1 (.rayconfig) exports

Raycast ships two export formats and Tinycast only understood the newer
one, so importing from Raycast 1.104.x failed at the first gunzip with
"This doesn't look like a Raycast export".

The formats come from different generations of a rewritten app and share
essentially no data shape, so they get separate readers rather than one
branching mapper:

  RaycastFormat.detect  gzip magic -> v2, otherwise v1. The only branch.
  RaycastImportV1       v1 decrypt + v1 mapper (new)
  RaycastImportV2       v2 decrypt + v2 mapper (moved verbatim)
  RaycastImport         facade: Result, selecting(_:), read(file:passphrase:)

Neither reader is ever tried as a fallback for the other, so a wrong
passphrase now reports a wrong passphrase instead of collapsing into
"not a Raycast export".

v1 wire format, reverse-engineered and verified against a real export:

  file = IV(16) || AES-256-CBC(gzip(JSON), PKCS#7)
  key  = EVP_BytesToKey(SHA-256, salt: none) = SHA-256(passphrase)

The IV that derivation would also produce is discarded; Raycast writes a
random IV as the first 16 bytes. Reading it from the file puts the gzip
header at offset 0, which is the real integrity check -- a wrong key
unpads cleanly roughly 1 in 256 times.

Detection needs no passphrase, so the Backup pane runs it when a file is
chosen: it labels the row and disables categories the format cannot
carry. A v1 export has no launch-at-login preference and no global
palette hotkey, so neither is offered or mapped.

Also fixes a latent trap in Gunzip.decompress, which indexed a zero-based
byte copy but sliced with Data.subdata(in:) -- a hard crash for any Data
whose startIndex is non-zero, which the v1 path would have hit.
Credit: huyixi/tinycast@4107731.

Tools/raycast-test.swift covers detection, the decrypt, and the JSON
mapping (75 checks). It builds its own v1 files in-process from an
embedded gzip fixture, so no real .rayconfig is committed.

* exta

* fixed lint out dated data
2026-08-02 03:19:55 +06:00
Abue Ammar 63a3188750 Cleanups (#138)
* refactored dialogs

* fix volume and message hud

* fixed volume hud

* fix comments

* decoupled settings

* decoupled settings

* removed unnecessary stuffs
2026-08-02 01:57:37 +06:00
Abue Ammar 79256d1cd3 Revert "feat: In-app updates with Sparkle, off by default (#137)" (#139)
This reverts commit d753d1f5c4.
2026-08-02 01:57:00 +06:00
Marius d753d1f5c4 feat: In-app updates with Sparkle, off by default (#137)
* feat: In-app updates with Sparkle, off by default

Sparkle 2.9.4 as an SPM binary target, driven by a custom SPUUserDriver so
none of Sparkle's own AppKit windows ever appear: confirmations go through
AppCore.askConfirmation and reports through showNotice, like the rest of
the app.

UpdateStore is owned by AppCore and started from start(). Background checks
are consent-gated and ship off. SUEnableAutomaticChecks = NO in Info.plist
does double duty: its presence permanently suppresses Sparkle's own
permission prompt, and it makes startUpdater() early-return before arming a
timer, so an untouched install performs no network I/O at all. SUHost
prefers the user default over the plist, so the Settings toggle flips it
live and there is no second flag to keep in sync -- which also keeps it out
of AppSettings, where a SettingsBackup import could have granted network
access.

SUFeedURL is deliberately absent. The feed comes from the updater delegate,
which returns nil for com.tinycast.app.dev, so the dev channel has no URL to
fall back to and is provably un-updatable rather than merely unconfigured.
Beta and stable get separate feeds instead of sparkle:channel: Sparkle
always allows the default channel, so a beta host would still be offered the
stable DMG, whose app name and bundle id match neither and whose install is
then rejected.

* ci: Sign the release DMG and publish a per-channel appcast

The DMG the workflow already builds is the Sparkle update archive, so the
new step runs generate_appcast over it -- EdDSA-signing the archive and
rewriting the channel's appcast in one pass. The key arrives on stdin from
the SPARKLE_ED_PRIVATE_KEY secret, so it never lands in a keychain, a file
or the log, and the step warns and exits 0 when the secret is missing, the
same way the cask bump does.

It lands the appcast on main by replaying that one file onto origin/main
rather than pushing HEAD: the workflow can be dispatched from any ref, and
pushing HEAD would drop that ref's whole history onto main. The commit
touches website/, which is exactly what website.yml watches, so the feed
reaches Pages through the existing site deploy instead of a second
publishing mechanism.

Both appcasts are committed empty. Zero items is the correct answer until
the first Sparkle-era release exists, and it keeps the very first update
check from 404ing.

* docs: Document the update subsystem and its key material

docs/updates.md covers why designated-requirement validation works for a
self-signed, non-notarized app, the two-feed channel model and why
sparkle:channel is wrong here, the consent model, and Homebrew coexistence
(the casks need auto_updates true, and CFBundleVersion must never be 0 or
Homebrew silently disables the comparison that protects a self-updated
install).

signing.md gains the EdDSA key setup and corrects what losing a key costs:
Sparkle's Developer-ID rotation fallback hardcodes anchor apple generic and
can never fire for us, so the .p12 and the EdDSA key are each independently
enough to recover the channel, and losing both strands every install.

README's 'around 3 MB on disk' and 'zero dependencies' are no longer true --
the Release bundle measures 7.1 MB with Sparkle embedded.
2026-08-02 01:53:10 +06:00
Mathieu SOUFLIS 660beb07ec Fix modal button order, Return/Escape binding, and expand ModalKind (#126)
* Bind Return to a modal's primary action and Escape to Cancel

* Render Cancel on the leading edge of modal buttons

* Split ModalKind into warning and error, and let custom carry its own color

* Replace pill icons with a colored status dot

* feat(modals): Highlight severe confirmations in red

* feat(ui): Add modal tooltips and refine tones

Use hover labels for dialog shortcuts, align warning and error styling, and
default report dialogs to their settings action when available.
2026-08-01 01:06:02 +06:00
Abue Ammar a5a7fdb58f feat: Window management commands in the launcher (#127)
Adds 29 Rectangle-style window actions — halves, quarters, thirds,
sizing, nudging, display moves and native fullscreen — searchable in
the palette and bindable to global shortcuts. No new dependencies and
no new permission: they reuse the Accessibility grant paste already
needs.

Split along the existing pure/platform seam so the geometry is
testable headlessly: WindowCommand, WindowLayout and WindowActionMemory
stay Foundation + CoreGraphics and pure, while every AXUIElement call
and the Cocoa<->AX coordinate flip live in WindowMover.

Ships off, behind a Settings pane with a per-command shortcut recorder
and the existing VisibilityStore launcher checkbox. Cycle-on-repeat
covers the four halves and is off by default.

Tools/window-command-test.swift adds 310 assertions covering tiling,
gaps, cycling, restore, display moves and the memory's reset rules,
plus a fuzz sweep whose off-screen check caught a real bug in
Maximize Height/Width.
2026-07-31 20:03:43 +06:00
Mathieu SOUFLIS 15d4d6dcaa System commands: macOS actions in the launcher (#107)
* Add Tinycast's own modal and HUD surface

* Add the system command catalog and launcher category

* Add Sleep system command

* Add Sleep Displays system command

* Add Restart system command

* Add Shut Down system command

* Add Log Out system command

* Add Show Screen Saver system command

* Add Play / Pause system command

* Add Next Track system command

* Add Previous Track system command

* Add Toggle Mute system command

* Add Turn Volume Up system command

* Add Turn Volume Down system command

* Add Set Volume system command

* Add Set Volume to 0% system command

* Add Set Volume to 25% system command

* Add Set Volume to 50% system command

* Add Set Volume to 75% system command

* Add Set Volume to 100% system command

* Add Show Desktop system command

* Add Toggle System Appearance system command

* Add Toggle Stage Manager system command

* Add Open Trash system command

* Add Empty Trash system command

* Add Eject All Disks system command

* Add Toggle Hidden Files system command

* Add Hide All Apps Except Frontmost system command

* Add Unhide All Hidden Apps system command

* Move Quit All Applications to system commands

* Add Dismiss Notifications system command

* Add Toggle Bluetooth system command
2026-07-31 05:11:14 +06:00
ElAlecsandabue-ammar c6d936f243 Add production-ready snippets support (#98)
* feat: Add Snippets support with markdown storage and keyword expansion

- Introduce Snippets engine with markdown file persistence in ~/.config/tinycast/snippets/
- Support YAML Frontmatter for snippet metadata (name, keyword, category, enabled, show_in_launcher)
- Implement dynamic template engine with placeholders ({cursor}, {clipboard}, {date}, {time}, {argument name=...})
- Add interactive prompt for missing template arguments
- Auto-expansion via global event tap (CGEventTap) listening for keywords across apps
- Live file system directory watching for external snippet updates using DispatchSource
- Integrate snippets into Launcher palette search with dedicated Snippets section
- Add Snippets tab in Settings with uniform macOS switch controls and template variable toolbar
- Update Onboarding and Permissions Settings to check and request Input Monitoring access

* Key snippet launcher entries on their file path

`SnippetMarkdownSerializer.parse` never restores `id`, so `Snippet.id` is a
fresh UUID on every parse — and `scanSnippets()` re-parses on every launcher
open. Keying the entry on that UUID made `AppEntry.id` change each scan, which
`preferenceKey` feeds to learned ranking, favorites and visibility: a snippet
launch wrote records under a key nothing would ever read again, and those
count-1 rows evict genuine app learning from the store's 1000-record table.

The file path is the stable identity the entry already carries, and it matches
what `AppEntry.id` documents ("file path … always unique"). Both snippet
lookups keep working — they resolve by name, since the UUID halves of their
`||` conditions could never match `SnippetsStore`'s independently-parsed set.

* Keep Accessibility prompting, and main's paste path, intact

`ensureAccessibility()` had been redefined as a bare trust check, with the
prompt moved to a new `requestAccessibilityPrompt()` that nothing ever called.
That silently removed the only Accessibility prompt in the app: `postCommandV`
covers clipboard paste, emoji paste and pin-in-place, and
`GeneralSettingsView` prompts there when a Hyper Key is assigned — a call site
this branch never touched, so it lost its prompt invisibly. On a fresh install
paste and Hyper Key would do nothing, with no way to discover why. Restore the
prompting semantics and drop the unused wrapper; `isAccessibilityTrusted()`
was already the non-prompting check for the permission UIs.

Restore main's delivery in `paste`/`pasteString` too: activate the target, let
the ⌘V go through the HID tap to the frontmost app, and keep the 0.08 settle.
Retargeting them at a pid, trimming the delay and swapping the tap changed the
load-bearing paste path with nothing covering it. Snippet injection keeps its
own `injectString` path, and the self-event tag still stamps every synthetic
⌘V so the keyword listener ignores Tinycast's own events.

Also restore the comments recording the `internalType` marker invariant, the
single promotion point and the never-clear-to-empty guarantee, and wrap the
app-switch observer's `clearBuffer()` in `MainActor.assumeIsolated` — matching
the health timer just below it — which clears the last Swift 6 warning.

* Refine snippets architecture and settings

* Match snippet editor to custom commands

* Harden snippets against review findings

- elect a single instance with a bundle-id-scoped flock instead of racing
  process enumeration, so simultaneous launches cannot both quit
- run the throwing snippet import before committing settings and clipboard
- clear the keyword buffer on mouse-down, since a click moves the caret
- reject blank frontmatter names so the filename fallback still applies
- skip non-regular *.md entries while keeping symlinked files loadable
- retry watcher setup whenever any desired path is still unwatched
- never resolve a reference to a disabled snippet
- validate AX attribute types instead of force casting, and bound the AX
  messaging timeout so a hung target cannot stall the main actor
- keep an open editor's draft when its record disappears, instead of
  swapping in another record or crashing on an emptied library
- compose the Raycast import summary from independent parts
- make the harness report failures instead of trapping, and cover the new
  storage, naming, and reference contracts

* Trim snippets to the app's existing settings patterns

Scope creep outside the feature is reverted, and the snippets UI now reuses
what the other panes already do.

- revert AboutView (its close-guard machinery existed only to prompt about
  unsaved snippet edits) and AppDelegate (single-instance election and the
  terminate review are unrelated to snippets); onboarding keeps main's step
  and only renames what it grants
- delete SnippetEditingSession: the editor sheet is now the Custom Commands
  sheet — local @State draft, Cancel discards, Save writes, inline error. An
  external edit under an open editor surfaces as the repository's revision
  conflict instead of a bespoke reconciliation state machine
- the sheet no longer scrolls: no ScrollView, no pinned minHeight, and the
  template box scrolls internally at the shared editor height
- give each permission to the pane whose feature needs it — Accessibility in
  Clipboard, Input Monitoring in Snippets — through one PermissionCard, and
  delete the now-empty Permissions pane and its tab
- drop the global insertion-HUD switch; the per-snippet option is the only
  gate, so it also leaves settings backups
- fold three overlapping editor size tokens into two documented ones, and
  hoist the duplicated status capsule into SettingsStatusBadge

* Make Accessibility the only snippet permission, and cover Raycast's placeholders

Input Monitoring was never needed. A listen-only CGEventTap is authorized by
the Accessibility grant — the same grant main's HyperKeyTap uses for its
*modifying* tap — so CGPreflightListenEventAccess() reported success purely
because Accessibility was granted, and nothing ever registered Tinycast in that
System Settings pane. The card was permanently green, pointed at an empty list
and could not be revoked.

- delete the Input Monitoring concept: Permissions.swift, the listener's second
  probe, the lifecycle policy's parameter and the injector's gate clause. The
  listener now speaks HyperKeyTap's vocabulary (off / needsAccessibility /
  active) and offers the same Grant Access fix-it row
- restore Settings → Permissions from main and drop the per-pane cards, so the
  permissions UI is a zero diff again
- add every Raycast dynamic placeholder that maps onto Tinycast: {datetime},
  {day}, {uuid}, {clipboard offset=N}, {snippet name="…"}, date offset= and
  locale=, the {x | trim | uppercase} modifier pipeline, and argument default=
  and options=. Unparseable tokens stay literal; {browser-tab} and {calculator}
  are out of scope
- score snippet keywords and categories in the same tiers as a name instead of
  flooring them 200 above it, so a snippet no longer outranks every app
- move the argument prompt out of AppCore into a SwiftUI form, with a picker for
  options= and design tokens instead of hand-built AppKit metrics
- paste through Paster's ⌘V instead of rebuilding it, and stamp the temporary
  pasteboard write with the internalType marker so the poller can never record
  snippet text as a clipboard entry
- fold the one-expression fallback policy onto the result enum it describes,
  make the never-matched RestoreResult.failed load-bearing, and stop
  re-checking the delivery gate AppCore had already run
- drop dead state and conformances: AppIndex.scannedEntries,
  SnippetSourceRevision's RawRepresentable/Codable, lease internals

* Add a shared confirmation HUD, a placeholder picker, and drop snippet categories

- fix a latent data-loss bug before adding to CustomCommand: its synthesized
  Codable used `decode`, not `decodeIfPresent`, and the store reads the whole
  array with `try? … ?? []`. Any new field would have thrown for the array,
  emptied the library and been persisted over on the next edit — and failed
  every older backup import outright, since SettingsBackup has no `try?`.
  It now decodes like Snippet does, with a test that fails without it
- generalize SnippetHUDWindowController into HUDWindowController, taking a
  message, symbol and tint; snippets confirm "Inserted <name>", custom commands
  confirm "Ran <name>" from the single run funnel that already covers both the
  palette and the global hotkey
- rename the concept: "Show insertion HUD" becomes "Show confirmation",
  Snippet.showHUD becomes showsConfirmation, and the frontmatter key becomes
  show_confirmation
- replace the placeholder toolbar, which could never fit the sheet's 440pt and
  fell back to an indicator-less horizontal scroller, with a grouped Insert…
  menu covering every token. Inserting now lands at the caret instead of
  appending to the end
- size the argument prompt's form to 220pt so its alert keeps AppKit's natural
  260pt width and its buttons sit exactly where the command confirmation's do,
  and give ↵ to Expand rather than Cancel — it is a form, not a warning
- remove `category` from snippets: model, codec, frontmatter, editor, launcher
  aliases, samples and docs. The CRLF-injection test moves its carrier to
  `keyword` so that coverage survives
- cut the Custom Commands callout and toggle details to one short sentence
  each, and stop explaining the shell environment twice on one screen

* Start snippets empty, and make the confirmation a proper toast

The feature never shipped, so there is nothing to migrate from and nothing to
be compatible with.

- delete both legacy migrations (~/.config/tinycast/snippets and snippets.json),
  the starter samples, the staging directory, the initialization marker and the
  migrationFailed error. Initialization is now one mkdir, and a new channel
  starts with an empty library — SnippetRepository loses 119 lines
- drop the two other accommodations for a format that never shipped: Snippet's
  Codable conformance, which only existed for the JSON migration, and the
  showInLauncher/launcher frontmatter aliases. An unknown key now names itself
  in the error instead of being quietly remapped
- the custom command confirmation gives ↵ to Run and Esc to Cancel, replacing
  the deliberate ↵-to-Cancel inherited from the quit-all prompt
- both confirmation toggles say what they confirm: "after the command succeeds"
  and "after this snippet is inserted"
- redesign the toast per docs/ui.md rule 5, which puts floating controls on
  Liquid Glass rather than the main surface's black-over-blur: a glass capsule
  that sizes to its message (154×31 for "Ran Sleep Displays", down from a fixed
  320×56, capped at 420), bottom-centre, with no second shadow under the glass

* Drop the custom-command compatibility decoder

Custom commands have not shipped either: #82 merged two days after v0.7.5, and
CustomCommand.swift does not exist in that tag. So there is no released build
whose stored commands need decoding, and the hand-written CodingKeys and
init(from:) added for that case are the same kind of unshipped-format support
just removed from snippets.

Codable goes back to synthesized, and the migration checks go with it. The new
flag stays covered where it matters: "commands survive a reload with their
flags" is a real encode/UserDefaults/decode round-trip, and "import preserves
every flag" covers the sanitizer.

* Start snippets lazily, section custom commands, and soften the import

- point the onboarding caption back at Settings › Permissions: the pane
  was restored from main but the caption still named Clipboard, which no
  longer holds the Accessibility control
- report a snippet write failure in the Raycast import summary instead of
  throwing: it must not abort the settings and clipboard categories the
  user also selected
- drop the Search Snippets command — it opened Settings, unlike every
  other Search command, and the Settings window already covers it
- give custom commands their own launcher section ahead of the built-ins,
  as snippets have, keeping each published slice aligned with its section
  so the flat selection invariant holds
- start SnippetsStore lazily: at launch only when snippet files exist on
  disk or keyword expansion is consented, otherwise on demand from the
  Snippets pane or a Raycast import, so a user who never touches snippets
  pays for no load and no directory watcher; covered in the harness

* Give custom commands and snippets feature switches, and align their panes

Both panes now open with the same FeatureSwitchCard: a master switch and a
"Show in launcher" companion, with the rest of the pane dimmed and locked
while the feature is off.

- off means fully off: no launcher section, no keyword expansion, and
  runCustomCommand — the single palette/hotkey funnel — refuses to run, so
  a still-registered global shortcut is inert. Carbon bindings stay put
  and every shortcut returns on re-enable
- disabling snippets is a full teardown (listener, injector, store and
  its watchers), so an unused feature costs no RAM; the flags live in
  AppSettings, travel in settings backups, and AppCore's settings sinks
  re-project on every change, so an import behaves like the switches
- launcher presence funnels through two AppCore appliers; the launch path,
  the store snapshot, the pane toggles and a backup import all share them
- drop the per-snippet show_in_launcher frontmatter key and editor
  checkbox — the pane-level switch replaces it, and a file still carrying
  the key is reported through the unknown-key error (nothing shipped, so
  there is nothing to migrate)
- the "Commands run with your user account" callout folds into the enable
  row's subtitle

* Fold keyword expansion into the snippets switch, and ship both features off

One switch is the whole snippets feature: launcher search and keyword
expansion together. Enabling funnels through AppCore.setSnippetsEnabled,
which confirms with the Accessibility explanation before flipping the flag;
the settings sink then starts the store and listener. A callout with a
Grant Access fix-it appears while the permission is missing, replacing the
old Automatic Expansion card.

- both feature switches now default to off, so a fresh install pays
  nothing until the user opts in — which also retires startIfLibraryExists:
  an enabled feature always wants the store, a disabled one never does
- snippetsEnabled now carries keyword-expansion consent, so it leaves
  settings backups (an import must not enable keystroke listening);
  snippetsShowInLauncher still travels
- delete the snippetKeywordExpansion flag, its setter, the expansion card
  and the pane's redundant .task — no shipped build persisted any of it

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-07-31 03:29:17 +06:00
Abue Ammar 322459b736 Keep palette lists stationary while the selection moves (#109)
* Keep palette lists stationary while the selection moves

Arrow keys recentered every list on `scrollToken`, so the viewport jumped
even when the selected row was already visible (#100). The emoji grid
already solved this with a reset/follow scroll intent — generalise that
one type to all four modes instead of a second parallel mechanism.

- `ScrollIntent` (`Core/ScrollIntent.swift`) replaces `scrollToken` and
  `EmojiScrollIntent`. `.follow` is a minimal scroll-to-visible, so the
  list only advances a row at the viewport edges; `.top` restores the
  content origin. Modes never coexist, so one intent state serves all.
- `scrollOriginAnchor()` marks content offset 0 with a zero-height
  overlay, making `.top` exact: scrolling to the first row instead left
  the content's top padding hidden under the header.
- Landing on flat index 0 restores the origin so that row's section
  header comes back into view.
- The clipboard follow-the-moved-row reset now follows the selection
  rather than recentering it.

The edge dissolve is deliberately untouched.

* Make the edge dissolve and thin scrollbar off-limits

* Fix the origin-anchor name in the UI docs
2026-07-30 19:45:17 +06:00
Jason Underhillandabue-ammar 7f5f06e90b Add custom commands (#82)
* Add custom launcher commands

* Add shell environment and confirmation options to custom commands

zsh reads ~/.zshrc only for interactive shells, so the runner's `zsh -lc`
never sees the user's aliases, functions or PATH and exits 127. Two reviewers
hit this with a .zshrc alias. Add a per-command "Load shell environment" flag
that switches to `zsh -ilc`, off by default so the fast path stays the default
and nobody pays for a slow shell config they didn't ask for.

Measured with no controlling terminal and stdin on /dev/null, as a GUI app
spawns a child: 9 ms for -lc, ~65 ms for -ilc against a real .zshrc (~11 ms
against a minimal one, so the interactive shell itself is ~2 ms and the rest is
the user's own config). Interactive prompts still cannot block -- a read gets
EOF and /dev/tty fails with "device not configured" -- and TINYCAST=1 is now
exported so a shell config can skip slow sections via
`[[ -n $TINYCAST ]] && return`. When a run exits 127 with the flag off, the
failure alert names the likely cause and offers to open Settings.

Add a per-command "Needs confirmation" flag, gated in runCustomCommand(id:) --
the single funnel both palette activation and the global hotkey reach, so
neither path can bypass it. The palette hides before the alert (floating panel
would cover it) and Return is bound to Cancel, as in confirmQuitAll: the
command is one Return away in the palette. NSAlert.runModal spins a nested run
loop where Carbon hotkeys keep firing, so a re-entrancy flag stops a held
shortcut stacking alerts.

Move the blocking waitUntilExit off the Swift cooperative pool onto a private
concurrent queue; a long `brew upgrade` would otherwise hold a pool thread for
minutes while the app scan and image decode compete for the same pool.

Take a CustomCommand draft in the store's add/update rather than a parameter
per field, and normalize a copy in validated/sanitized instead of rebuilding
from fields, so a future option cannot be silently dropped on backup import.

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-07-29 23:04:56 +06:00
Abue Ammar d949ba9595 Add configurable application search scopes (#73)
The launcher scanned five hardcoded directories, so users could not point
it at their own folders and two stock apps were unreachable: Finder lives
in /System/Library/CoreServices, and Safari only really exists in the
cryptex — /Applications/Safari.app is a symlink flagged hidden, which
.skipsHiddenFiles drops before the .app test.

Scopes now live in AppSettings.searchScopes and are editable in
Settings > General. A scope is a folder or a single .app bundle, stored
tilde-abbreviated so backups stay portable. Enumeration stays flat.

Closes #58. Closes #48.
2026-07-29 05:20:48 +06:00
0e8e624161 Add adaptive launcher ranking (#54)
* Add adaptive launcher ranking

* Resolve review findings from #54

Key learned ranking on the locale-independent canonical form. `normalize`
produces a persisted lookup key, so it must not read ambient state: a
locale-sensitive fold maps "I" to "ı" under Turkish and would orphan every
record keyed on the dotted form. Cover the fold in the harness, including a
locale check that asserts against the Turkish fold so it can't go vacuous.

Regenerate the project with XcodeGen rather than keeping the hand-written
object IDs — `project.yml` already globs `Tinycast/`, so the generator picks
the new file up on its own and the tree stays idempotent under regeneration.

* Resolve ranking review findings: per-query boosts, gated reset

Resolve the learned table once per ranking pass. `boosts(query:)` replaces
`boost(itemKey:query:)`, so `rank` folds the query and reads the clock once
instead of per candidate — one instant now scores a whole pass, where before
each candidate re-read the clock.

Only offer "Reset Ranking" on a result that has learned ranking. The item was
appended unconditionally, so it showed on every result and did nothing on most
of them; gating it also gives `hasRanking(for:)` its first caller outside the
harness.

Pin the frecency curve with golden values through the new API, and note the
standalone-compile constraint on `LauncherRankingStore` in AGENTS.md so a
future AppKit import can't quietly break `Tools/ranking-test.swift`. The
documented harness command now passes `-swift-version 6`, matching the
clipboard one, so it actually exercises strict concurrency.

---------

Co-authored-by: Tinycast Contributor <tinycast-contributor@localhost>
Co-authored-by: abue-ammar <iabueammar@gmail.com>
2026-07-28 23:39:59 +06:00
Abue Ammar 99257956d3 Order clipboard pins by pin time, and harden the pin paths (#68)
* Harden clipboard pin ordering, load and pruning

Three follow-ups from the review of #63:

- A pinned entry could fall outside the FTS statement's LIMIT 200 and
  vanish from a filtered search. Every pinned row is resident in `items`
  by construction, so the pinned block is now matched in memory and the
  FTS result contributes only unpinned rows.
- `load`'s `pinned = 1 OR rowid >= ?` could not be driven from an index
  while holding row order, so it scanned the whole table on every launch
  (~12ms at 200k rows, on the main actor). Split into two indexed
  branches over a partial index on the pin flag: ~1ms for the same data.
- `prune`'s cheap guard tested the oldest row, which a retention-exempt
  pin at the tail made permanently true, re-scanning the window on every
  capture. It now tests the oldest unpinned row.

* Order the Pinned section by pin time, Raycast-style

Pins were a boolean, so the Pinned section stayed in recency order: pinning
two entries interleaved them by when they were copied rather than keeping
them in the order they were pinned, and unpinning dropped a row back into
whatever date bucket it came from, jumping the list under the selection.

`pinned` becomes `pinned_at`, a stamp:

- The Pinned section is ordered newest pin first, so pinning a row never
  reshuffles the pins already there.
- Unpinning rejoins the history as its newest entry (the same delete +
  re-insert `promote` uses), so the row stays where the eye already is.
- `promote` skips pinned rows: a pasted pin holds its place instead of
  rewriting the row and its FTS entry for no visible change.

The boolean column never shipped, so nothing migrates it.

Adds `Tools/clipboard-test.swift` (23 checks: pin order, unpin recency,
paste, retention exemption, the FTS limit, persistence, and migrating a
shipped pre-pin database), which is why the store gained an injectable
directory and dropped its unused AppKit import.

* Order pins oldest first, so a new pin joins the end of the section
2026-07-28 22:16:46 +06:00
Abue Ammar 1fa17517e8 Add contributor, license and security guidelines (#61)
* Add contributor, license and security guidelines

Adds CONTRIBUTING.md, CONTRIBUTOR_LICENSE_AND_FEEDBACK_AGREEMENT.md and
SECURITY.md, and renames CLAUDE.md to AGENTS.md so every coding agent reads
the same instructions regardless of platform. CLAUDE.md stays as a stub that
imports it.

CONTRIBUTING.md states the non-negotiables up front: the 100 MB RAM ceiling,
zero leaks, design taken from the existing tokens, and no bloat. Visual
changes require a side-by-side before/after video in the PR.

README.md gains Discord and hire-me badges, a Contributing pointer and a
Contributors section. The contributor image is capped to one row so it can't
grow as people join.

* Add minimal GitHub issue templates

Bug Report and Feature request as YAML forms, plus a config that keeps blank
issues enabled and points questions at Discord and vulnerabilities at private
advisories.

Three required fields on bugs (what happened, version + channel, macOS
version) matching what CONTRIBUTING already asks for; two on features, the
second asking why it earns its place so scope is settled at filing time.

* Update CLAUDE.md to clarify instructions
2026-07-28 15:55:29 +06:00