* Stream spawned child output to extensions as it arrives
* Keep a Detail's markdown image on screen while it updates
* Arm spawn timeouts at launch and keep streamed UTF-8 characters whole
* Emit spawn before a child's output and stream it in the runtime fixtures
* Keep SwiftUI's named keys out of the ASCII recovery
* Cover the shortcut caller path, not just the recovery
* Skip the caller-path cases when the layout has no trap to spring
* Keep the recovery's contract in Platform, without the test seam
* feat(ai): add an AI Chat window with a conversations sidebar
A Raycast-style AI Chat window: saved conversations on the left, the open
one on the right, and a composer that picks the model, reasoning effort and
tools per chat. The palette's chat becomes Quick AI; ⌘J moves a Quick AI
conversation into the window.
- Each chat keeps its model, effort and tool scope; reopening it restores them
- Reasoning shows as collapsible thinking blocks, one per stretch of thinking
- Image paste and attachments for vision-capable models (Claude, Codex)
- Model lists come from each CLI's own catalog, so new Claude models appear
- Context gauge with a hover card: history, budget, files, tools, tokens
- Choices render as buttons above the composer; a bare "choices" list counts
- Sources list with citation numbers at the end of the citing sentence
- Find in Chat (⌘F) steps word by word and marks each match in place
- A reply's text is one selectable NSTextView, so a drag spans paragraphs,
lists, code and tables
- Chats are titled by their harness as soon as the first question is sent
- Several Codex chats can answer at once, each on its own thread
- Actions menu, pin, rename, copy and delete from the sidebar
* Refactor AI chat components and enhance functionality
- Updated `ChatTitle` to improve string sanitization.
- Modified `InstalledAI` to enhance name extraction logic.
- Renamed database tables from `conversation_meta` to `conversation_details` and `message_meta` to `message_details` for clarity.
- Added export functionality for chat transcripts in `AIChatCoordinator`.
- Improved UI elements in `AIChatDetailView` and `AIChatSidebarView` for better user experience.
- Enhanced `AIChatWindowChrome` to streamline toolbar actions and improve navigation.
- Updated keyboard shortcuts for new chat, settings, and other actions across various components.
- Improved chat transcript rendering in `ChatMarkdownText` and `ChatTranscriptView`.
- Enhanced `QuickAICoordinator` to support regeneration of responses.
- Updated documentation to reflect changes in database structure and new features.
* feat(ai): enhance AI chat sidebar and history functionality with new continue action
* feat(ai): update AI chat window and sidebar dimensions for improved layout
---------
Co-authored-by: abue-ammar <iabueammar@gmail.com>
* Offer Tinycast's MCP servers on the Codex and Claude routes
* Give Codex's local servers their own variable names, and run each once
Codex forwards a variable only under the name it already has, so a local
server read TC_MCP_<HANDLE>_<KEY> instead of its own key. A server with
variables now starts through /bin/sh, which moves each value to the name
the server reads and execs it; the script carries names, never values.
While Codex or Claude is the chat model, Tinycast keeps no connection of
its own to a local server, since the CLI starts its own copy. AppCore
re-applies that whenever the chat model changes.
* Name Codex's MCP variables by position, so no two secrets share one
The derived TC_MCP_<HANDLE>_<KEY> upper-cased and flattened both halves,
so github-x + TOKEN and github + X_TOKEN, token and TOKEN, a remote
header and a local key, or two non-Latin handles of one length all met
in one variable, and one server received another's secret. Each value
now lives under TC_MCP_<server>_<key>, positions in the launch's own
list, and a name that repeats anyway refuses the launch.
A key the shell cannot export is no longer forwarded under a name its
server never reads.
* Ask before every Claude MCP call, whatever the reader's settings allow
The armed Claude turn relied on the CLI's own permission system to raise
can_use_tool, and the reader's settings could answer first: an allow rule
for their own server of the same name, or a bypassPermissions default,
ran the tool with Ask Each Chat never asked. The turn now pins
--permission-mode default and passes --settings with a permissions.ask
rule for every armed server, which outranks an allow rule from any
source while leaving the rest of the reader's settings in force.
* Give Tinycast's servers their own names on Codex, and disable all of yours
-c sets single keys, so a Tinycast server named like one of the reader's
own Codex servers merged into it: a local one inherited their env table,
literal secrets included, their cwd and any per-tool approval_mode that
skips consent, and a remote one over their stdio one made Codex refuse
the whole config. Tinycast's servers now go by tinycast-<handle>, every
one of the reader's is disabled with no exception, and a launch whose
armed name the reader already uses is refused. Elicitations and tool-call
items map back to a handle only through that prefix, so a question about
any other server is declined without asking.
* Refuse to start Codex when the reader's MCP servers cannot be kept out
A failed or unparseable `codex mcp list --json` launched the app-server
with nothing disabled, so every server in the reader's Codex config
started inside the Tinycast thread: the boundary failed open. It now
fails closed with an error that says why, and so does a reader's server
whose name holds a dot or an equals sign, which `-c` splits and so
cannot switch off. Names with spaces or non-Latin letters are
addressable and stay as they were.
* Launch the Codex app-server once for concurrent starts
A status refresh racing a turn, or two quick sends, each passed the
isRunning check, read the list and launched; the second overwrote the
first's process and pipe, and when the first exited its handler tore
down the live one and failed the turn. A launch is now one shared Task
that every caller for the same list awaits, handshake included, a stop
that lands while the list is read keeps the launch from starting after
it, and an exit is acted on only when it is the current process's.
* Cover the Codex relaunch on a changed server list
Two turns with the same list run in one app-server; a third whose
server carries a different secret, as a refreshed token would, starts
a second one with the new value in its environment.
* Ask about one CLI tool call at a time
Each Codex elicitation and each Claude can_use_tool is answered on its
own Task, and DialogController refuses a second dialog while one is up,
so a call arriving during the first question was told the reader had
declined it. AIToolServerSession now asks one question at a time, in
arrival order; the next is decided only after the dialog before it
closes, so it sees the grant that dialog made. Questions still waiting
when their turn ends are cancelled rather than asked.
* Offer credential-free HTTP servers to the CLIs, and lend tokens as Authorization
MCPServer.toolServer dropped any HTTP server without a header value, so
one that needs no credential worked on the API route and was silently
missing on Codex and Claude. And a lent OAuth token went out under the
stored header name, so a server switched to OAuth from X-Api-Key sent
"X-Api-Key: Bearer <token>" and got a 401. Only an OAuth server with no
session is left out now; a Header server with an empty value is offered
with no header, which both encoders omit, and a lent token always goes
as Authorization, as Tinycast's own transport sends it.
* Delete a crashed turn's private files at the next launch
A Claude turn's MCP configuration, which carries the servers' secrets,
and Grok's prompt file were removed only when the turn ended, so a crash
mid-turn left them in the workspace indefinitely. InstalledAIManager now
removes any tinycast-mcp-*.json and tinycast-prompt-*.txt older than the
launch when it starts.
* Stop the Codex helper when a server it runs is withdrawn
The helper re-reads its server list only on the next turn or after ten
idle minutes, so switching MCP off, removing a server, setting it to
Never Allow or signing out of it left that server's process, and any
token lent to it, running inside the helper until then. MCPCoordinator
now tells ChatGPTSubscriptionManager which servers are still offered,
and a helper launched with any other stops if no turn is running.
* Route a Claude tool name at its first separator
ClaudeMCPLaunch.route split mcp__<handle>__<tool> at the last "__" on
the premise that a handle may hold one. It cannot: MCPSlug emits only
letters, digits and "-". A tool name can, so mcp__files__read__file
routed to a handle "files__read", permit found no server, and the call
was refused without a question.
* Escape every control character in Codex's TOML strings
quoted() escaped only backslash, quote, \n, \r and \t, matching each as a
Swift Character, and CRLF is one Character, so it matched neither and
went through raw; so did every other control character. Codex then
refused the whole config. Each Unicode scalar is now considered, and
everything below U+0020 and U+007F goes out as \uXXXX, which tomllib and
the real codex both read back as the original.
* Refresh an OAuth token before lending it when under ten minutes remain
A token lent to Codex or Claude had only to last 60 seconds past the
turn's start, since that is when Tinycast's own requests refresh, but the
CLI holds it for the whole turn and cannot ask for another. Lending now
refreshes within ten minutes of expiry; a token with no refresh token,
or whose refresh cannot be served, is lent as it is.
* Name a Codex consent question after its own call
The runner named the tool in an elicitation from the latest mcpToolCall
item started on that server, which is another call whenever two run at
once. CodexElicitation now keeps _meta.tool_name, and the question uses
it whenever Codex sends one. A stub turn with two calls started and
asked about together pins both names, and that the second question
waits for the first.
* Answer a Claude control request that is not a tool question
A control_request whose subtype Tinycast does not know, or a
can_use_tool for a tool on none of its servers, decoded to nothing and
was never answered, so the CLI waited on it for the rest of the turn.
Each now gets the SDK's error control_response.
* Create Claude's per-turn MCP file private from the start
FileManager.createFile writes its data to a temporary file at the
default 0644 and applies the 0600 attribute afterwards, so the file
carrying the servers' secrets was briefly readable by other accounts;
only the 0700 workspace stood in the way. It is now opened with
O_CREAT | O_EXCL at 0600 and written through that descriptor.
* Keep each comment the CLI routes added to one line
The CLI-routes change added 32 runs of two or more comment lines and
several over the 100-character cap. Each is now one line, and the
reasoning they carried lives in docs/features/mcp.md: why the listing
and the launch share their flags, why a check keeps the running list,
why the Claude file is per turn, and what the consent channel is. That
paragraph also stops overstating the --allowedTools fallback: an allow
list denies nothing by itself, --permission-mode dontAsk does.
* Say which servers a CLI route is not handed, and what argv can carry
The MCP doc promised the same server list on every route and that no
secret reaches argv. An OAuth server nobody is signed into is left out
on Codex and Claude, and a credential typed into a server's URL is part
of the URL, which Codex takes as a launch argument; both are now said.
* Tell readers what is different about MCP on Codex
The website said the servers on the CLI routes are the same as
everywhere else and that only adding, removing or re-authorizing a
server restarts Codex's helper. It now also says that an OAuth server
nobody is signed into is left out, that moving between an @handle
message and an unaddressed one restarts the helper too, that
withdrawing a server stops the helper, and which environment variable
names reach a server Codex starts.
* Run Codex and Claude with no round cap on Unlimited
The CLI routes took their cap from AIToolRounds.rawValue, which is -1
for Unlimited: Claude would have been passed --max-turns -1, and Codex
interrupted at its first call saying it stopped after -1 rounds.
AIToolServerSession.rounds is now optional and comes from
toolRounds.limit. On Unlimited, Claude is given no --max-turns, since it
has no cap without one, and Codex counts no calls, so only the model or
Stop ends the turn. A step still stops both, and the sentence names it.
A turn with no tool servers stays a separate case. Claude with nothing
to call still passes --max-turns 1, and the sentence for a cap Claude
reports now names the number it was actually given; a max-turns result
under no cap says Claude could not finish the response instead of
naming one Tinycast never set. Codex with no session keeps its cap of
one call.
* Let the two-call consent test accept either order of questions
* Tell readers that Codex reads a server's resources without asking
* Read every forwarded value before exporting any in the Codex MCP shim
* Let a Codex status check join a turn's pending launch instead of relaunching without its servers
* Refactor code for improved readability and maintainability
- Adjusted formatting in various Swift files to enhance code clarity.
- Updated MCP OAuth handling to improve error messaging and flow.
- Enhanced UI components for better user experience in settings and chat transcripts.
- Improved handling of asynchronous tasks in CodexAppServerClient and InstalledCLITurnRunner.
- Refined documentation for AI features and MCP integration.
---------
Co-authored-by: abue-ammar <iabueammar@gmail.com>
* Add OAuth authentication for HTTP MCP servers
* Escape a plus sign in the OAuth authorization URL
* Drop an issuer's terminating slash before inserting its well-known path
* Keep the issuer comment under the 100-character cap
* Keep a saved server signed in when Test Connection tries an edited URL
* Let a token refresh finish when the server editor closes or saves
* Say why an MCP server was kept when Remove cannot delete its credentials
* Cover supplied OAuth client credentials in the harness
* Rebuild root search ranking and add Suggestions
Root search priced each match as a cell of a role-by-tier table plus a
boost learned per submitted query, and indexed Spotlight's alternate
names, which merge every language a bundle ships. On an English Mac `ll`
and `sap` found Safari, `settings` ranked Tinycast's own command above
System Settings, and loose subsequence hits crowded the list.
Each field now gets an alignment score that rewards word starts, Search
sensitivity decides how loose a hit may be and still show, and an
ordered comparator settles two entries by exact hits, past search
terms, match score, frecency and kind. Learning is one frecency record
per entry: an open adds 100 to a score that halves every ten days. With
the field empty, a Suggestions section offers fresh installs, what the
user opens most and a few built-in commands; Show suggestions turns it
off.
The old learned table does not decode, so learned ranking starts empty.
* Enhance fuzz-test with a seeded random number generator for reproducibility; update ExtensionCatalog and ExtensionManager to include installation date; refine AppIndex to track usage order and improve launcher documentation.
The lazy-stub Proxy behind every resolve-but-refuse module has a `get` trap
and nothing else. esbuild's `__toESM` never gets a property: it snapshots
`Object.getOwnPropertyNames(mod)` and copies those, so with no `ownKeys` trap
the own keys fall through to the literal target and every manufactured member
is dropped. That is what every namespace or named import compiles to, so the
intended "… is not supported in Tinycast extensions" refusal never fires.
What the extension sees instead is `undefined`, and a `class X extends
<undefined>` turns that into `TypeError: The superclass is not a constructor`
at import time, in a stack that is all `__commonJS` frames and names nothing.
Give the Proxy `ownKeys` and `getOwnPropertyDescriptor`, backed by Node's own
function-valued export names per module. Constants stay out: a member made
here is always a throwing callable, which is the wrong value for one. `http`,
`https` and `stream` stay out too — they are partially supported, their real
classes are already own properties, and phantom keys would flip a working
`typeof stream.isReadable === "function"` fallback into a throw.
`async_hooks` was a plain object literal rather than one of these proxies, so
`AsyncResource` was undefined outright and undici's `class … extends
AsyncResource` produced the same error with no message at all. It gets a real
one — a single synchronous context means the scope is just the call.
Closes#852
* Prepare extension runtime for menu bar commands
* Host Raycast menu bar commands with transient runtimes
* Stabilize extension menu layout and lifecycle
* Restore menu dismissal and release runtime temporaries
* Preserve pending menu actions and explicit launches
* Render menu actions before loading icons
* Promote menu interactions and retry returning icons
* Keep cached menu actions responsive during reload
* Prepare extension menus before opening from bottom edge
* Match native status menu spacing on every display
* Release extension HTTP sessions and reuse private transport
* Record extension HTTP retention investigation and validation
* Keep background refreshes out of the foreground runtime
Rebasing onto main brought in scheduled no-view refresh, which shares the one
JSContext with the palette. Four ways that went wrong:
- A refresh that finished before its waiter registered recorded a timeout, so a
fast command backed its own schedule off as though it had failed.
- Boot and bundle reads suspend. A foreground launch in that gap aborted the
refresh, but the refresh then started inside the context that replaced it.
- Disabling extensions left a running "Refresh Now" alive, and a queued one could
still start afterwards.
- A scheduled command could not launch a sibling without naming its extension,
since ownership resolved only from the foreground session.
ExtensionBackgroundSession now owns one run: the outcome is buffered rather than
signalled, and the first writer wins, so neither an early finish nor a late
callback can be lost or overwritten. A preempted run is cancelled rather than
recorded, leaving its schedule where it found it, and every suspension point
rechecks session identity before touching the runtime.
Also drops the duplicate ExtensionLaunchType the merge left in ExtensionBootConfig.
All 64 harnesses pass; Debug builds with no new warnings. Verified against the
real OpenCodex Usage and Port Manager menu commands.
* Let AppKit own the extension status menu
Clicking a second extension menu bar item while one was open only closed the first: the button drove a manual popUp, whose modal tracking loop swallowed the click instead of handing it to the other item. Two native menus hand off, and ours did not.
Attach the menu to the status item and let AppKit position and track it, which restores that handoff along with Escape, outside clicks and click-to-close. The manual anchor maths and its two spacing constants go with it, since AppKit places the menu itself.
The menu is attached once at init rather than per snapshot, so the first click opens it before any render has arrived.
* Key menu icons by value instead of scanning for them
A menu rebuilds every row on each React commit, and each row searched two arrays for its icon and a third for the failures. Small menus make that cheap, but it is linear work on a path that runs several times a second while a menu is open.
RenderValue and RenderNode gain Hashable, so the cache becomes a dictionary and the failure list a Set. Same behaviour, no per-row scans.
Also restores the guard that a menu bar extra with no rows detaches its menu, which the switch to AppKit tracking had dropped: without it an extension rendering nothing would open an empty menu.
* Cut every menu bar comment back to one line
* Drop the menu memory investigation log and table the shortcut keys
The benchmark file recorded how one HTTP retention bug was found, which the feature doc already states as a rule. No other investigation is kept this way, so it goes rather than starting a convention.
The named-key switch becomes the table it always was.
* Reuse the existing refresh, metadata and icon paths for menu bar commands
The branch was written against an older main and grew its own copies of
machinery main already ships. Menu-bar activation and the saved button now
live on the command's own ExtensionCommandMetadata record, so
ExtensionMenuBarStore and extension-menu-bars.json go away and the writes
coalesce on the metadata store's debounce instead of hitting the disk on
every React commit. Menu-bar refresh cadence comes from
ExtensionRefreshPolicy.nextDue, which adds the failure backoff and the
per-command phase the hand-rolled scan never had.
ExtensionBackgroundSession and its rewrite of the manager's background
internals are gone: main's continuation path does the same work, so the
scheduler is main's again. Manifest intervals parse once through
ExtensionRefreshPolicy.parse, which takes a floor and now rejects an
amount that overflows to infinity. Menu-bar icons load through
ExtensionImage.load rather than restating its four bitmap cases, which
also fixes fileIcon to draw fitted like every other icon here.
Settings reads the manager directly, mirroring the background-refresh row,
so the coordinator no longer threads through four views.
* Reach the menu bar toggle through the extension coordinator
A feature action belongs on its coordinator, with AppCore only locating it. The
Show in menu bar toggle read and wrote ExtensionManager directly, past the
coordinator the enable switch two hundred lines above already goes through.
Organize Colors builds each tile as a bare {color} swatch whose string comes
from getPreviewColor(), which formats in oklch() while the row label keeps the
user's hex preference. The extensions layer carried its own colour parser that
read #rrggbb and nothing else, so every swatch resolved to nil and fell through
to the icon placeholder: correct hex beside a grid of grey boxes.
ColorValue is already the one CSS parser, and ColorSpaces already held the
Oklab matrices one way round, so the inverse initialiser lands there and
parseFunctional gains an oklch case that reuses its own argument, percentage
and angle helpers. ExtensionImage now calls that parser and drops its hex
reader, which also gives extension tints rgb() and hsl() for free, and lets the
clipboard read back the oklch() it could already write.
* Hand an app-picker preference to an extension as an Application
An appPicker preference reached JS as the bare path it is stored as, but
Raycast hands one over as an Application — { name, path, bundleId }. Project
Manager reads vscodeApp?.name.replace(...) at module scope, so the command
threw before its first render.
ExtensionPreferenceValue gained an application case whose JSON form is that
object, resolved from the bundle at the path — the shape
ExtensionHostBridge.describe(application:) already sends for getApplications().
ExtensionPreferenceSchema.runtimeValue converts app pickers only, and returns
nothing for an empty path, so an unset picker arrives as an absent key and the
extension's own optional chain short-circuits instead of throwing. Storage is
untouched: the picker still writes a path, so Settings, backups and the
required-preference check read what they always did.
* Cover an app-picker preference surviving a storage reload
Also applies swift-format to the new ext-test checks.
---------
Co-authored-by: abue-ammar <iabueammar@gmail.com>
* Run extensions that speak WebSocket
A bundled `ws` handshakes through `http.request` and wants a raw socket back,
which the fetch-backed shim had none of. Sockets are `URLSessionWebSocketTask`
now, and the upgrade path hands `ws` one that re-frames RFC 6455 both ways.
Home Assistant is the reference case.
* Resolve a .local host without joining a multicast group
Home Assistant's default `homeassistant.local` is resolved by the extension
itself with multicast-dns, which died on `dgram.createSocket`. `dgram` now
answers an address query out of `getaddrinfo` — mDNSResponder handles
`.local` — so nothing multicasts and no entitlement is needed.
* Harden the WebSocket and dgram shims
A rejected host call poisoned the send queue, so every later send and the
close after it rejected too; the queue now recovers while the caller still
sees the failure. A ping is answered by the peer through
`URLSessionWebSocketTask`, not by a pong the adapter invents. An upgrade no
listener claims destroys the socket, the way Node does, so the native task
goes with it. `dgram` answers address questions only. And a socket id that
is not a whole number falls back instead of trapping.
* Collect a custom command's arguments inline in root search
A custom command that declares arguments now shows its fields beside the
search field when its row is selected, the way a quicklink already does,
instead of replacing the screen with a one-at-a-time form. The form,
`PaletteMode.customCommandArguments` and `CustomCommandArgumentSession`
are gone.
Handled the way Raycast handles script-command arguments:
- At most three. `CustomCommandArgument.sanitized` enforces it on every
path in, so a stored command carrying more keeps its first three; the
editor's Add stops there.
- ↵ with a required field empty focuses that field rather than running.
- A hotkey, favorite slot or Run Again with values missing opens root
search onto that one row, seeded with its name, first empty field
focused. The row is listed even when hidden from the launcher, since
its shortcut still has to be answered.
Fields are keyed by position (`$1`–`$3`), not name, because two arguments
may share a name. `runCustomCommand(id:values:)` stays the one funnel and
`positionalValues(from:)` restores `$n` order, so values still reach zsh
as positional parameters and never as command text.
The field strip moves from Quicklinks to `DesignSystem/InlineArgumentFields`
so both features draw the same control rather than a copy of it.
* Carry a clicked row's inline values into its launch
Clicking a launcher row launched it without the values typed into its
inline fields, so a custom command reopened its prompt empty and a
quicklink lost what was typed. The click now goes through
`argumentValues(for:)`, as ↵ already did.
Also names what `argumentKey`'s second half is for each feature.
* Parse Markdown notes into ranged lines
* Add the Notes Markdown edit planner
NoteMarkdownEditing turns a NoteEditAction into a single NoteEditPlan: one
range, one replacement, one resulting selection. Every list, indent and
inline-style gesture resolves through it, so each is one undo step and none
of it needs a text view to test.
NoteRevealPolicy decides which lines show their raw syntax for a given
selection, widened to whole fenced blocks.
* Render Markdown in the Notes editor
* Edit Markdown notes with shortcuts, tasks and links
* Add a Render Markdown setting to Notes
* Document Markdown rendering in Notes
* Add code block and quote edit planning
Extends NoteMarkdownEditing with fenced code and block quote toggles.
NoteFormatting is the report the caret's context produces, decided by the
same span and line rules the toggles use, so a lit button always undoes.
* Add code block and quote shortcuts to Notes
* Add a formatting bar to Notes
* Simplify the Notes Markdown engine
Scan inline spans on demand instead of storing them on every parsed line:
only the styler, the editing rules and NoteTitle read them, and each reads
one line at a time. NoteMarkdown keeps its UTF-16 units and vends
inlines(of:) on request.
Adopt NSTextStorageDelegate in NoteMarkdownRenderer, which reports the
edited range and length delta for every mutation including undo and marked
text. That replaces a full shadow copy of the document and the prefix and
suffix diff run against it on every edit, selection change and read.
Emit the trailing empty line as a real zero-length line when the source
ends in a terminator, so the caret after a final newline sits on a line
like any other. Five restatements of that special case go away.
Delete NoteTask, superseded by NoteMarkdownParser and no longer referenced.
At 100,000 characters typing drops from 6.8 to 5.6 ms at the end of the
note, 5.2 to 4.0 in the middle and 2.8 to 1.6 at the start.
* Move the Notes formatting bar to the trailing edge
Mirror the band under the editor: the character count leads, the formatting
capsule trails. The expand transition anchors trailing so the buttons grow
out of the round button leftwards, and the tooltip alignments swap with it.
Anchor the heading menu to the heading button's own frame, reported by the
laid-out view, rather than re-deriving the capsule's geometry in AppKit.
The old anchor guessed from the window edge and a capsule height composed
from two tokens, which put the menu under the wrong end of the bar once the
capsule moved.
* Keep indented rules literal and renumber wide markers
A four-space indent already keeps a heading and a quote literal, but the
rule check ran before that guard, so ` ---` drew a horizontal rule and
hid its own text.
An ordered marker's stored number drops leading zeros, so `007.` was read
as one digit. Continuing that list took the second zero for its delimiter
and renumbering rewrote only the first digit. Both now take the digit run
from the source.
Also corrects two doc lines the formatting bar's move left behind.
* Drop Carbon from the Notes editor's chords
NoteTextView only needed HIToolbox for the digit key codes that keep the
list and heading chords working on a non-US layout. Those are seven
constants, so it states them itself.
Carbon stays where it earns its place: the global hotkey registration and
the TIS input-source APIs.
* Add native dictionary define fallback
* Make dictionary lookup a core launcher command
* feat(dictionary): add dictionary functionality with lookup and display
- Introduced DictionaryEntry model to parse and store dictionary entries.
- Implemented DictionaryProvider to fetch definitions from Dictionary Services.
- Created DictionaryCoordinator to manage dictionary-related actions.
- Developed DictionaryScreen to display definitions in the palette.
- Added fallback command for "define" to trigger dictionary lookups.
- Updated UI components to integrate dictionary features, including copy and open actions.
- Enhanced documentation to cover new dictionary functionality and usage.
* feat(dictionary): enhance dictionary command and fallback functionality
* Render Define Word entries as a structured dictionary page
Read the record's XHTML through Dictionary Services' record calls,
resolved with dlsym so a macOS without them falls back to the public
plain text. DictionaryMarkup turns the span classes into headword,
part of speech, numbered senses, notes and sections, and
DictionarySession looks terms up off the main actor.
---------
Co-authored-by: abue-ammar <iabueammar@gmail.com>
* Add search to action menus
* Fix action menu keyboard handling
* Refactor symbol name resolution to simplify handling of dark mode and improve code clarity
* Close the palette when a menu action opens a window
An action ran before its menu closed, so a window it opened took key while
the menu's callbacks were still live and `menuOpen` was still mirrored true
a cycle later. Both dismissal guards then bailed and the palette stayed on
screen behind Settings, About and Support.
Close the menu first, and state `menuOpen` in `open`/`closeMenus` rather
than mirroring it from `onChange`, so the window delegate reads it within
the same turn.
Also tidies the header menu symbol initializer's formatting.
* Fix action menu presentation
* Refactor menu height calculations for improved clarity and consistency
* Refactor menu animation durations for improved responsiveness
---------
Co-authored-by: abue-ammar <iabueammar@gmail.com>
Deploy the Next.js export to Cloudflare as an assets-only Worker and serve it
from the domain root, so `basePath` and `src/lib/asset.ts` both go away.
The old GitHub Pages URL keeps working: `website/redirect/` is now a lone CNAME
file, and a custom domain on a project site is what makes GitHub 301
`abue-ammar.github.io/tinycast/<path>` to `tinycast.dev/<path>` from its own
edge — repo prefix stripped, path, query and fragment carried, no HTML parsed.
Verified against a live project site before relying on it.
Workers caps a single asset at 25 MiB and the 26.5 MiB tour video breaks it, so
media that size moves to `website/media/`, out of the export, and is served from
an R2 bucket behind cdn.tinycast.dev. Its own workflow mirrors the folder so a
docs typo never re-uploads it.
Every docs page inherited the root layout's `canonical: "/"`, which told Google
the homepage was the real version of all 37 of them. Canonicals are per page
now, with the trailing slash the host actually serves, and the sitemap matches.
Drops the unmaintained macOS 15 Sequoia cask from the docs, the README, the
issue templates and the release-notes tag filter.
* Add custom window sizes (#734)
User-defined window commands: a name, a width and height in points or
percent, and a 3x3 position, applied to the focused window on its own
display. Listed with the window commands, bindable to a global shortcut,
undone by Restore, and carried in settings backups.
* Address review on custom window sizes
Fold imported names without a locale, matching the store's own duplicate
check. Reach the coordinator through the environment instead of AppCore,
and let it decide between add and update. Drop formatting-only edits to
unrelated files that slipped into the first commit.
* Drop unrelated formatting edits from the custom sizes branch
Downloads Manager lists its folder with opendirSync and a readSync loop,
so every command threw and showed an empty list. Dir now walks a snapshot
from the host readdir, in sync, callback and promise forms.
`opencode --version` prints `opencode2 v0.0.0-beta-19271`, and the row
showed `Version 0.0.0`. The version match now includes the SemVer
prerelease and build suffixes.
Google Search failed with "Cannot call a class constructor without new":
safer-buffer copies Buffer's statics with for…in, found none on our class
shim, and fell back to calling Buffer bare.
Shortcuts from the Shortcuts app become their own launcher section,
read through Apple's /usr/bin/shortcuts tool on every launcher open
and run headless with `shortcuts run <uuid>`. Each row carries an
alias, a global hotkey and a hide checkbox, like any launcher item.
The feature ships off behind one switch in Settings > Apple Shortcuts.
AppleShortcutCoordinator.run(id:) is the single funnel for rows and
hotkeys. A successful, non-empty read sweeps the hotkey, alias,
visibility, favorite and ranking of any shortcut no longer listed; a
failed or empty read frees nothing.
LauncherItemsSection's table half is now LauncherItemsList, shared
with the new pane, and ToolRunner accepts a nil timeout.
* Polish the emoji and symbol picker
* Split the emoji observers out of the palette's state chain
* Give each header menu its own width and simplify emoji pins and zoom
- Replace fitted menu widths with a stated width per header menu
- Route emoji zoom chords through the panel's command shortcut path
- Count pin positions over the pins the catalog can show
- Move PinnedEmojiStore into its own file
- Restore SwiftUI menu symbols and leave the extension chevron untouched
---------
Co-authored-by: abue-ammar <iabueammar@gmail.com>
fsPath degraded any URL to its decoded pathname, so fs.existsSync accepted
a vscode-remote:// workspace whose stripped pathname exists locally — an
SSH host opened at / always does — and Raycast's Visual Studio Code
extension handed that URI to fileURLToPath, crashing Search Recent
Projects with "The URL must be of scheme file". URL arguments now go
through fileURLToPath: a non-file scheme throws ERR_INVALID_URL_SCHEME
like Node, existsSync counts that as absence, and the remote entries
render as remote items.
Hide My Email failed at load with "The superclass is not a constructor".
It hands axios a cookie jar through axios-cookiejar-support, whose
http-cookie-agent and agent-base extend http.Agent when the bundle loads.
The http shim had no Agent, and esbuild's namespace interop copies only
the Proxy target's own keys, so the superclass was undefined.
http.Agent is now a real class whose addRequest does nothing, because
the bridge owns every socket. ClientRequest calls it only for an
http.Agent subclass, exposes Node's protocol/host/path, and runs
_implicitHeader in end(), which is where the cookie agent sets Cookie.
Any other agent shape is still ignored, as before. url.format now takes
a parts object, which the cookie agent builds for each request.
URLSession folds repeated Set-Cookie headers into one comma-joined line,
so a jar kept only the first cookie and iCloud login could not finish.
IncomingMessage splits it back into Node's array without cutting an
Expires date, and rawHeaders repeats the name per cookie.
`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.
Timers pauses a countdown by storing the pid `exec` returns and later calling
`process.kill` on it. The shim had no `process.kill`, and async `exec` always
reported pid 0, so pausing threw a TypeError.
Async `exec`, `execFile` and `spawn` now launch their child synchronously on the
JS queue, so the handle carries the real pid, and a new `proc.wait` host call
collects the output off that queue. `process.kill` and `ChildProcess.kill`
signal the child for real, and refuse any target that would signal Tinycast
itself (0, -1, its own pid or process group). `os.constants.signals` now lists
every Darwin signal.
Two process bugs made worse by launching on the JS queue are fixed too: stdin
is written after launch on a background thread, so input over 64 KB no longer
wedges the runtime (nor SIGPIPEs Tinycast when the child exits early), and env
values are stringified like Node, so `{ FOO: 1 }` no longer drops the override.
Closes#687
Bitwarden derives its session hash with `crypto.pbkdf2` after `bw unlock`, and the shim had no such
function, so the TypeError surfaced as a credentials failure and the vault re-prompted on every
launch. Its vault cache and Easy Dictionary's Caiyun config also need `createCipheriv` and
`createDecipheriv`.
The shim now provides `pbkdf2`, `pbkdf2Sync`, `createCipheriv` and `createDecipheriv` for AES-128,
-192 and -256 in CBC or ECB, with `setAutoPadding`, backed by CommonCrypto. A cipher buffers its
updates and runs one host call in `final`. Decryption strips PKCS#7 padding itself, because
CommonCrypto accepts padding OpenSSL rejects and a wrong key would otherwise return garbage instead
of `ERR_OSSL_BAD_DECRYPT`.
Closes#643
* Let palette screens answer their own shortcuts
RootPaletteView had grown back to 1,513 lines, and much of that was feature code: ten key handlers
that cast `screen` to a concrete type, the AI model menus, and views other files use.
- `PaletteShortcut` recognises each row chord (⌘⌫, ⌃X, ⇧⌘C, ⌘Y, ⇧⌘F, ⌘R, …) and carries its
compact-bar and open-menu guards; a screen acts on it through `PaletteScreen.perform(_:at:)`.
One handler replaces the ten, and ⌘. and ⌘1…⌘0 go through the same call.
- The AI model and reasoning menus move to `AIModelMenu`, next to the rest of AI.
- `ArmedHover`, `EmptyResults`, `PaletteBackground` and `CompactFavoritesRow` get their own files.
- The rule for what saving an AI connection does to its Keychain key moves out of the view into
`AIConnectionKeyPolicy`, and the connection editor sheet gets its own file.
No behaviour change. RootPaletteView is 1,204 lines; AISettingsView is 640.
* Refactor AppSettings to improve readability of palettePositions assignment
* Fix Node CPU metrics for system monitor extensions
* Complete system monitor OS metrics and helper support
* Forward extension list selection changes
* Extract extension selection forwarding
* Seed extension selection from selectedItemId
---------
Co-authored-by: Jonas List <Jonas.List.1289@gmail.com>
run-tests.sh now numbers each result, prints each harness's run and compile
time, names what is still running during a quiet stretch, kills a harness
that passes TINYCAST_TEST_TIMEOUT (default 300s), and ends on a clear
PASSED or FAILED line.
custom-command-test hung forever in a real terminal: an interactive zsh found
the inherited controlling terminal and was stopped by SIGTTOU. The harness now
calls setsid(), matching the app, which has no terminal.
fuzz-test runs its 100k-query property loop across cores over queries drawn in
one seeded sequence, cutting it from ~25s to ~1.4s.
clipboard-worker-test is removed: it compiled fixtures with xcrun at run time
and hung in 9 of 40 runs, stuck in Process.waitUntilExit after the helper had
exited.
RESOURCE_ENTITLEMENTS listed only the three resources Tinycast asks for, so a
feature that later declared a new usage string without its entitlement would
pass the release check and ship a prompt tccd refuses to show — the silent
denial #635 fixed for the camera and calendars.
The table now maps every hardened-runtime resource's usage string to its
entitlement. It grants nothing: a row is skipped unless Info.plist declares
that usage string, and only Tinycast.entitlements decides what the app gets.
* Add the web APIs Google Calendar needs to the extension runtime
Every Google Calendar command failed before sending a request. The bundled
gaxios client prepares each request with `data instanceof Blob || … ||
data instanceof FormData`, and the runtime defined neither, so the check
itself threw `Can't find variable: Blob`. Defining Blob only moved the throw
one term right.
Behind that were two more. gaxios builds every error with `instanceof
DOMException`, so a non-2xx response threw instead of surfacing Google's own
message. And the extension's token requests send a URLSearchParams body with
no header, relying on fetch to derive `application/x-www-form-urlencoded` as
the Fetch spec says; without it Google read the form as JSON and rejected
sign-in and refresh alike.
FormData serialises as multipart with one boundary per instance, since the
header and the body have to agree on it, and a Blob entry becomes a File. An
explicit Content-Type still wins over a derived one.
Checked with new runtime fixtures, against the live Google API with a fake
token — every command reaches Google and shows its 401 message, and refresh
gets a parsed invalid_grant — and by loading the generated runtime into
JavaScriptCore through jsc. The Swift harnesses, lint and an app build were
not run here.
Closes#618
* Let a stored OAuth token expire from when it was stored
A signed-in extension stopped working an hour after sign-in and never
recovered. setTokens saved whatever it was given, and @raycast/utils hands it
the provider's raw token response, which carries no timestamp. getTokens then
rebuilt the TokenSet with the current time as its creation date, so
isExpired() was false on every read, the refresh never ran, and Google
rejected the stale access token with "invalid authentication credentials".
setTokens now stamps updatedAt, the field Raycast's API documents as the time
the set was stored, and isExpired() counts from it. A stored token with no
timestamp is treated as expired, so the extension refreshes it rather than
sending it again.
Checked with a round-trip fixture that stores a raw token response and reads
it back two hours later, and on a Debug build signed in to a real Google
account: List Calendars, List Events, Search Contacts and Create Quick Event
all complete.
Refs #618
Under the hardened runtime a usage string is not enough. tccd checks the
matching entitlement before it prompts, and without it logs "requires
entitlement com.apple.security.personal-information.calendars but it is
missing" and denies on the spot. requestFullAccessToEvents() returns false in
milliseconds, the status stays .notDetermined, no dialog appears, and Tinycast
never shows up under System Settings › Calendars. The camera has the same gap.
#620 turned the hardened runtime on and entitled JIT and Apple events, but
missed these two. A grant saved before that build keeps working, because tccd
does not re-check it, which is why only fresh installs broke.
verify-signature.sh now fails a release whose Info.plist declares a usage
string without its entitlement, so the next protected resource cannot ship
the same silent denial. The table lists only what Tinycast asks for.
* Play media the moment Quick Look opens it, and stop one from stretching the panel
File Search's ⌘Y overlay now starts a movie or a track as it appears. `FileSearchSurface`
carries one `autoplays` parameter; the preview pane leaves it off, because arrow-keying a
list must not start a movie, while opening Quick Look on one is the ask itself.
The clipboard's media preview asked for a fixed 260 pt whatever the pane had. With the
Information block's 175 pt under it the column wanted 435 pt of a 359 pt content area, and
the overflow pushed the bottom bar out and the panel taller than it is anywhere else.
`clipboardMediaHeight` is now the cap it should always have been, so the player shrinks the
way the image and text previews beside it already do.
* Add keyboard focus handling and related tests for media playback
* Refactor drag handling for empty text fields in the palette search field
* Turn on the hardened runtime, and teach the updater the identity it will switch to
Notarization needs two things Tinycast doesn't have: the hardened runtime, and
an Apple Developer ID signature. This does the first and prepares for the second.
The updater compares signatures before it installs, byte-for-byte against the
leaf the running app carries. A Developer ID leaf is a different certificate, so
switching identities outright would make every installed copy reject every future
update. `BundleSignature` therefore learns the Developer ID requirement now, while
releases are still signed with `Tinycast Self-Signed` — the code that trusts the
new identity has to reach people before the first build carrying it does.
The requirement pins the team, not the certificate, so a renewal strands nobody.
It omits the `notarized` keyword on purpose: that resolves a ticket through
syspolicyd or the network, and the updater verifies inside a cache directory
Gatekeeper has never assessed, so an offline Mac would refuse a bundle the chain
already proves is ours.
Hardened runtime needs two entitlements. JavaScriptCore compiles every extension
command, and without `allow-jit` it falls back to the interpreter. Without
`automation.apple-events` every Apple event is refused with -1743 and no prompt,
which silently kills Get Info, the Finder selection extensions read, and the
System Events-driven system actions. Nothing else is required: the only dlopen is
Apple's own IOBluetooth, so library validation stays on.
The flag is not part of the designated requirement, so no Accessibility grant is
lost here. `project.yml` keeps signing with `Tinycast Self-Signed`, so nothing
changes for a contributor building locally.
`Scripts/verify-signature.sh` asserts what notarization will check — the runtime
flag on the app and on the embedded helper, an intact nested seal, and no
get-task-allow. Both release jobs run it before packaging, because a nested binary
missing the runtime flag is the most common notarization rejection there is.
* Keep the hardened runtime out of Debug, where library validation refuses the debug dylib
Hardened runtime turns on library validation, and a Debug build links
`Tinycast Dev.debug.dylib`. The self-signed identity carries no Team ID, so the
loader sees a team mismatch and aborts at launch — every local Debug build died
with a DYLD "Library missing" termination.
Notarization only ever sees Release, so the flag belongs in that config alone.
* feat: Enhance file search functionality with type filtering and Quick Look support
- Introduced FileSearchFilter to allow users to filter search results by type (All Types, Folders, Documents, Images, Audio, Video, Archives).
- Updated FileSearchScreen to display Recently Used files when the search query is empty.
- Implemented Quick Look functionality within the file search panel, allowing users to preview files without leaving the search interface.
- Enhanced the UI to include a preview pane alongside search results, displaying relevant file information.
- Added keyboard shortcuts for file actions (copy, paste, trash) and Quick Look toggle.
- Improved the handling of empty states and error messages during file searches.
- Updated documentation to reflect new features and usage instructions.
* Read one attribute per Spotlight result, not four
Opening Search Files sat for half a second before its Recently Used rows
appeared, and a broad query took the best part of a second. Neither was
Spotlight: `MDQueryExecute` returns in 15–75 ms. It was `MDItemCopyAttribute`,
which costs about half a millisecond per attribute per result — reading the
content type, the invisible flag and both date stamps over the ~430 candidates
a recents query matches was 430 ms of the 550.
`kMDItemPath` is the exception: `MDQuery` hands it back from its own cache, so
a thousand of them read in 2.8 ms. Everything else a row needs — is it a
folder, is it hidden, is it an application — now comes from one `resourceValues`
stat, taken only for the candidates the ignore list did not already drop. Two
hundred URLs stat in 13 ms where two hundred metadata fetches cost 200 ms.
Recents no longer date every candidate either. Spotlight sorts on one
attribute, so the service runs one sorted query per stamp and merges their
heads; only the first twenty rows of each list can reach the merged one, and
only those are dated. The sort attribute has to be named in `MDQueryCreate` —
set afterwards through `MDQuerySetSortOrder` it is ignored, which is why the
first version had to sort locally. The blank screen also skips the typing
debounce, having no next keystroke to coalesce with.
Measured on the developer home, release-optimized: recents 41 ms on a repeat
against ~550 ms, and the five benchmark queries 54–107 ms against 192–831 ms.
Building the expressions moved with the queries into the service, where the
policy that shapes them already is; the session now owns only when a search
runs. Move to Trash takes ⌃X, the chord the clipboard's delete already uses,
and Paste File moves up beside Copy File in the Actions menu.
* Let the preview play, in the pane as well as the overlay
The overlay's dismissing tap gesture was laid over the whole card, preview
included, so the click that should have hit a movie's play button closed the
overlay instead — the transport drew, took the press and never saw it. Only
the margin around the card dismisses now; anything over the preview belongs to
the preview.
The pane beside the list shows the file itself for the same reason it was
worth having Quick Look at all: a still says nothing a movie or a long document
needs said. `QuickLookSurface` moves out of the overlay into its own file and
mounts in both, and the pane's copy waits 180 ms for the selection to hold, so
arrow-keying a list of two hundred rows opens no preview it is about to drop.
The thumbnail stands in until then.
The live view is torn down whenever the palette is ordered out, the overlay
covers it, or a folder is selected — one key over all three, so no `onChange`
races the task, and never two preview extensions running for one file.
* Give the preview its own player, 16:9, and an Escape AVKit cannot eat
Three things the preview pane still got wrong.
A movie never played. `QLPreviewView` draws a first frame and hands out a
transport, but inside a non-activating panel it does not play, so movies and
audio now go to an `AVPlayerView` of File Search's own. It is a copy of the
shape the clipboard's preview uses rather than a share of it: that pane is a
different surface with its own sizing and its own lifetime, and forty lines of
teardown is the cheaper trade against coupling the two.
The stage is 16:9 and sized before the block beneath it. Without the layout
priority the aspect ratio fits itself into whatever height the Information rows
left over, which is backwards — it shrank the preview to 328×173 instead of
filling the pane's width at 436×234. The rows now scroll in what is left,
through the plain scroll view the clipboard's preview uses, since a thin
scrollbar over a document is chrome on chrome.
Escape did not close Quick Look once a player was in it: a focused
`AVPlayerView` answers the key window's Escape before SwiftUI's handler ever
sees it. `PalettePanel.sendEvent` is the one place ahead of the responder
chain, so the panel now owns that press while the overlay is up, and
`PaletteEscapeAction` goes back to the shape it had. The Close chip is a button
now too, for the pointer.
* feat: Add FileSearchFileView for unified file preview handling in search results
* feat: Replace FileSearchFileView with FileSearchSurface for unified file handling in search results
* Hand the preview another file rather than building it a new one
Clicking a row went blank before it went live. The stage gated the whole
surface on a flag that flipped false on every selection, so each move
dismantled the `QLPreviewView`, closed it, showed `Color.clear`, then built a
fresh one — a teardown and a rebuild to show the next file.
The surface now outlives the selection: only the settled URL changes, so a
move hands the same view another item. The settle drops to 80 ms, which still
coalesces a held arrow key and no longer reads as a wait on a click. Measured
against the real QuickLook machinery: the first load in a process is ~130 ms
and every load after it ~10 ms, so what a click waits for is the settle, not
the preview.
Text goes back to QuickLook with it. Drawing it here fixed the wrong thing —
its scroll view is configured exactly like the palette's own, overlay style and
autohiding, and QuickLook renders a document better than a monospaced `Text`
in a `ScrollView` does.
* Say in the feature doc what the preview surface now does
* Answer a click on the press, and drop the timer in front of the preview
The second a click took was SwiftUI waiting: `.onTapGesture(count: 2)` cannot
deliver the single tap until the system's double-click interval has passed
without a second press, so the selection — and the preview that follows it —
sat still for that whole window. The clipboard's rows never felt this because
they select in `mouseDown`. File Search now does the same, through an
`onRowClick` catcher beside the right-click one already in `DesignSystem`:
select on the press, open when the press is the second.
The settle goes with it, and the state it needed. It was guarding a cost that
is not there: measured against the real QuickLook machinery inside a panel
shaped like the palette's — borderless, floating, non-activating, never key —
handing a live `QLPreviewView` another file paints in about 8 ms, and the
first load in a process in about 130 ms. Debouncing 8 ms of work bought
nothing and spent 80.
What is left is a surface mounted while it should be on screen and unmounted
when it should not, which is also the whole of its teardown: no duration, no
flag, no task.
Adds a Navigation feature: one settings pane, one switch, two commands
that move you somewhere rather than changing something.
Switch Windows sweeps every regular app's standard windows over AX,
minimized ones included, orders them most-recently-used and raises the
chosen one. Recency comes from a single CGWindowListCopyWindowInfo call
for per-app front rank — public API, no Screen Recording grant and no
long-lived activation observer.
Search Menu Bar Items is today's Search Menu Items, moved out of
Settings > Commands into the new pane and renamed. Its raw id is
unchanged, so recorded shortcuts, aliases and visibility keys survive.
It also gains a Disabled Applications list: MenuSearchTarget.classify
answers .excluded before the menu-bar test, so an excluded app starts no
walk at all rather than having a read menu filtered afterwards.
The exclusion list is the Clipboard section lifted into a shared
DisabledApplicationsSection, and AppPickerPopover moves to
Features/Launcher/Settings/ where its three consumers already pointed.
Port Manager's Open Ports listed nothing on a machine with plenty of
listening sockets. It spawns netstat and lsof with `detached: true` to get a
process group it can kill, and `stdio: ["ignore", "pipe", "pipe"]` because it
reads their output — but the runtime forwarded `detached` alone, and the host
answers a detached child at launch with empty output. Both parses saw "" and
the command fell through to its empty state.
`detached` only makes the child its own process group; `stdio` is what says
nobody reads it. So the fire-and-forget path now needs both: a child whose
stdout is piped is waited for, while `caffeinate -t 300 &` with
`stdio: "ignore"` still answers as soon as it is running.
* Spell out the Array conversion in the OCR search tail
Left open, the type checker reads .prefix as the Sequence overload, types
the result as PrefixSequence and stops resolving the whole + chain. The
Swift 6.2.1 toolchain in the Command Line Tools rejects the line outright,
which takes clipboard-test, clipboard-search-test and every other harness
that compiles the store down with it. Behaviour is unchanged.
* Drag an image or file out of clipboard history
An image or file row becomes a drag source for the file it already is, so
reaching another app costs one gesture instead of Reveal in Finder plus a
second drag.
The drag is AppKit rather than SwiftUI's onDrag for one reason: imagesDir
sits on the boot volume, where a file-URL drop defaults to a move, and a
move carries the blob out of the history and strands its row. Only an
NSDraggingSource can answer sourceOperationMaskFor, so ClipDragView
answers .copy everywhere. The handle claims mouse-down the way
WindowDragHandle does, since the hosting view eats the click first, and
forwards anything under 4pt of slop as the click it was.
Text rows are untouched: dragURL is nil for them, so no overlay is
installed and their gestures stay as they were.
* Drag a link and plain text out too, not only a file
A text row had no payload and so no overlay, which left every entry that
is not an image or a file undraggable. dragPayload now answers for all
three: the file URL for anything on disk, and for a text row the
classification the store already derives.
A link writes two pasteboard types from one item. A browser reads
public.url, a text field reads the string, and neither has to settle for
the other's flavour. A bare domain gets https:// so the URL is one a
browser accepts, which is the same assumption the address bar makes.
The drag preview is now the row as drawn, since a text row has no
thumbnail to fall back on.
* Fix the drag preview, and move the payload off the store
Four things the review caught.
The row snapshot never drew anything. SwiftUI renders into layers, so
cacheDisplay hands back a transparent bitmap and the drag carried no
image at all. Previews are drawn per payload now: the cached tile for a
file, a rounded text tile for a link or a copy. The dragging frame is
sized to that image and centred on the cursor, and a refused drop
animates back, so a drag that achieved nothing says so.
dragPayload touched no store state and pushed ClipboardStore past 1000
lines. It is a computed property on ClipboardItem now, in its own Model
file beside the ClipDragPayload it returns, which is where textForm and
colorValue already live.
The bespoke bare-domain helper is gone. QuicklinkDestination.detect
already parses schemes, network shares and deeplinks. textForm stays the
one answer to whether an entry is a link, so the drag and the type filter
cannot disagree; the detector only builds the URL.
A vanished file is reported rather than dragged out as a dead path, which
is what Reveal and Open already do. The payload resolves on mouse-down
instead of on every row render, so the stat costs one call per drag.
Also deletes the row's onTapGesture and double-tap gesture. The overlay
claims mouse-down on every row, so both were unreachable duplicates of
the closures the handle already calls.
* Rewrite the drag docs against the code that shipped
The section still described dragURL on the store, a nil payload for text
and the onTapGesture the overlay replaced, all three of which went in the
last two commits. It now covers what is there: dragPayload on the item,
the mouse-down resolution and its stat, the QuicklinkDestination reuse,
and why previews are drawn rather than snapshotted.
* Let a right click through, and drop the deprecated lockFocus
Three things from the review.
The drag overlay sat above the actions catcher and answered every event
it was offered, right-mouse included. NSView forwards an unhandled
rightMouseDown up the superview chain, never to a sibling, so the catcher
underneath was unreachable and the row's actions menu silently stopped
opening. ClipDragView now declines right events in hitTest, the mirror of
what RightClickCatcher already does with the left button, so neither
overlay can claim what the other needs. Verified against a real event
loop: the right click lands on the catcher and the left one still starts
the drag.
lockFocus and unlockFocus are deprecated, and the SDK names
NSImage(size:flipped:drawingHandler:) as the replacement. The text tile
draws through that instead.
The comments were stacked two and three lines deep. Each is one line now,
and the ordering invariant the overlay depends on is written down in the
feature doc rather than argued in the file.
---------
Co-authored-by: abue-ammar <iabueammar@gmail.com>
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.