Merge main into feature/notes

Reconciles the Notes feature with the Raycast-extensions merge (#235), the
currency refactor (#243) and the clipboard work (#237, #247).

- CommandID moved to its own file on main; the three Notes commands and
  isNotesCommand move with it.
- AppEntry.hotKeyAction now delegates to CommandID.hotKeyAction, so the Notes
  actions are declared there instead of inline in AppIndex.
- The Miscellaneous settings pane was removed by #243; Notes keeps its own pane
  and does not resurrect it.
- project.pbxproj is a union merge and needs xcodegen generate on a Mac.
This commit is contained in:
Abue Ammar
2026-08-16 15:57:43 +06:00
151 changed files with 19615 additions and 925 deletions
+3
View File
@@ -50,6 +50,9 @@ Carthage/Build/
# Tuist (if ever added)
Tuist/.build/
# Extension runtime toolchain (Scripts/raycast-runtime)
node_modules/
# Temporary files
*.swp
*.tmp
+19 -8
View File
@@ -2,7 +2,8 @@
A native macOS menu-bar launcher — a minimal Raycast: fuzzy app launcher, global and per-app hotkeys, a
text/image clipboard history, an inline calculator, a floating note, snippets, quicklinks, window
management and an emoji picker. SwiftUI + AppKit, running as an accessory with no Dock icon (`LSUIElement`). Zero third-party
management and an emoji picker. It also **runs Raycast extensions** natively, in JavaScriptCore.
SwiftUI + AppKit, running as an accessory with no Dock icon (`LSUIElement`). Zero third-party
dependencies.
## Posture: latest-only, always
@@ -69,16 +70,26 @@ feature's doc, under its own `## Invariants`.
surface; light mode is not a switch, it is a second design.
- **Tinycast presents its own dialogs — never `NSAlert`, `NSSlider` or a system popover.** A question
goes through `DialogController`, a report through a HUD via `HUDPresenter`.
- **Consent is structural, not a checkbox.** Every networked feature ships off behind a dialog naming
the provider, the cadence and what leaves the machine; the owning store re-checks consent on both
sides of every `await` and fetches on a private `.ephemeral`, `urlCache = nil` session. Consent flags
live on that store, **never** in `AppSettings`, and `snippetsEnabled` is excluded from settings
backups so an import cannot grant keystroke listening. `CurrencyRateStore` is the reference — copy it
rather than inventing a second shape.
- **A networked feature fetches on a private `.ephemeral`, `urlCache = nil` session**, never
`URLSession.shared`, so its own cache file stays the only copy on disk. `CurrencyRateStore` is the
reference — copy it rather than inventing a second shape. A flag that grants a capability is never
carried by a backup: `snippetsEnabled` is excluded from settings backups so an import cannot grant
keystroke listening.
- **Extensions stay inside `Features/Extensions/`.** Every view, row, menu, geometry and sizing
constant an extension needs is written and owned there — never added to `DesignSystem/`, never bolted
onto `Theme`, and never shared with another feature. An extension renders untrusted third-party code
whose shape we do not control, so it must never be able to force a change on a launcher surface.
**Duplicating a view or a piece of layout maths to keep it here is the correct trade**, and the one
place the no-duplication rule yields. What *is* shared: `Theme`'s base tokens (spacing, radius,
colour), `PopoverMenuItem` as a data shape, and `Platform/`. What is never shared: anything with
"how an extension looks or moves" in it. `ExtensionActionsPanel` and `ExtensionGridGeometry` exist
precisely because the palette's own menu and the emoji grid must stay free to change without them.
- **`AppEntry.Kind` is the only thing that says what an entry is.** One case per launcher section, per
`VisibilityStore` category and per Settings pane — never re-derive a category by sniffing an entry ID.
- **Generated files are never hand-edited.** `EmojiData.generated.swift` comes from
`node Scripts/gen-emoji.js`, `CurrencyData.generated.swift` from `node Scripts/gen-currencies.js`.
`node Scripts/gen-emoji.js`, `CurrencyData.generated.swift` from `node Scripts/gen-currencies.js`, and
`Resources/RaycastRuntime.generated.js` from `Scripts/raycast-runtime/build.mjs` — the runtime is
committed so building the app never needs Node.
- **`DesignSystem/Scrolling/EdgeDissolve.swift` and `ThinScrollbar.swift` are off-limits.** Both are
tuned by eye against the palette's floating bars, so any edit is a visual regression. Needing to touch
one to fix a scroll bug means the real fix belongs elsewhere.
+8 -1
View File
@@ -21,17 +21,21 @@ A tiny, fully native macOS launcher — the essentials, without the bloat.
Around **3 MB on disk** and **under 100 MB of RAM** — no Electron, no telemetry, no background
CPU churn. Just SwiftUI + AppKit with zero dependencies. It's fast because there's nothing to it.
It also **runs Raycast extensions** — the real ones, rendered as native SwiftUI. No Node.js, no
browser: JavaScriptCore ships with macOS, so that costs no extra binary size.
## Features
- **App launcher** — fuzzy-search and launch anything, pin favorites, see what's running, quit an app
or every app at once.
- **Custom commands** — run named shell commands through fuzzy search or their own global hotkeys.
- **Calculator** — do math, unit and live currency conversions inline, right in the palette.
- **Calculator** — do math, unit, live currency and crypto conversions inline, right in the palette.
- **Clipboard history** — text and images, searchable, pasted back into the app you were using.
- **Snippets** — reusable Markdown templates with dynamic placeholders, arguments, nested references
and optional keyword expansion.
- **Global hotkey** — one shortcut summons the palette from anywhere.
- **Per-app hotkeys** — bind a key to an app; press it to toggle (focus/hide).
- **Raycast extensions** — run the ones you already have natively, rendered as SwiftUI.
## Install
@@ -40,8 +44,11 @@ brew trust --tap abue-ammar/tinycast # required for third-party taps
brew tap abue-ammar/tinycast
brew install --cask tinycast # stable
brew install --cask tinycast@beta # beta (installs side-by-side)
brew install --cask tinycast-sequoia # stable channel (macOS 15 Sequoia)
```
Tinycast also runs on macOS 15 Sequoia — install the `tinycast-sequoia` cask above.
Each channel is a separate app (`Tinycast.app`, `Tinycast Beta.app`) with its own settings and
permissions, so you can run stable next to the beta.
+69 -37
View File
@@ -1,33 +1,37 @@
#!/usr/bin/env node
// Generate Tinycast/Features/Calculator/Model/CurrencyData.generated.swift.
//
// Usage: node Scripts/gen-currencies.js [currencies.json cldr-currencies.json]
// Usage: node Scripts/gen-currencies.js [rates.json cldr-currencies.json cldr-currency-data.json]
// Downloads the sources when paths aren't given. Run occasionally, commit the output.
//
// Two sources, joined on the ISO code:
// - Frankfurter decides *which* currencies exist — it's the same feed CurrencyRateStore pulls
// Three sources, joined on the ISO code:
// - The fiat rate feed decides *which* currencies exist — the same feed CurrencyRateStore pulls
// rates from, so the table can never list a currency the app can't price.
// - CLDR decides what humans *call* them: display name, currency sign, and the singular/plural
// noun. Read from the pinned cldr-json checkout rather than the host's `Intl`, whose output
// shifts with the local ICU version and would make this file unreproducible.
// - CLDR's supplemental currency data decides which of those are still *in use*. The feed carries
// no retirement dates and happily quotes codes their countries abandoned years ago.
// - CLDR's `en` numbers data decides what humans *call* them: display name, currency sign, and the
// singular/plural noun. Read from the pinned cldr-json checkout rather than the host's `Intl`,
// whose output shifts with the local ICU version and would make this file unreproducible.
//
// Only unambiguous data is emitted. A sign or noun claimed by more than one currency is left out
// and decided by hand in CalcCurrency.swift, because picking one is a product call, not a lookup.
// Crypto is hand-written in CalcCurrency.swift too — no standards body names it.
"use strict";
const fs = require("fs");
const path = require("path");
const CURRENCIES = "https://api.frankfurter.dev/v2/currencies";
const CLDR =
"https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-numbers-full/main/en/currencies.json";
const RATES = "https://backend.raycast.com/api/v1/currencies";
const CLDR = "https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json";
const CLDR_NAMES = `${CLDR}/cldr-numbers-full/main/en/currencies.json`;
const CLDR_CURRENCY_DATA = `${CLDR}/cldr-core/supplemental/currencyData.json`;
// The feed serves ~165 live currencies; a collapse well below that means a bad response, not a real change.
const MIN_EXPECTED = 120;
// `scope` defaults to live currencies, so retirement filtering is only a backstop. Thin markets and
// weekends leave a real currency a few days stale (4 is the observed worst case), so the cutoff has
// to be generous — a genuinely retired code trails by years (ATS ended 2002, BGN 2025).
const MAX_STALE_DAYS = 90;
// The feed serves ~170 codes and ~159 survive the filter; well below that is a bad response.
const MIN_EXPECTED = 150;
// The crypto table in CalcCurrency.swift owns this one, and prices it from the dedicated feed.
const CRYPTO_OWNED = new Set(["BTC"]);
// CLDR carries no name for the Crown Dependencies' pounds, and the feed supplies no names at all.
const UNNAMED = { GGP: "Guernsey Pound", IMP: "Isle of Man Pound", JEP: "Jersey Pound" };
async function load(url, argPath) {
if (argPath) return JSON.parse(fs.readFileSync(argPath, "utf8"));
@@ -36,7 +40,7 @@ async function load(url, argPath) {
return response.json();
}
const fold = (s) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const fold = (s) => s.normalize("NFD").replace(/[̀-ͯ]/g, "");
// A sign has to be punctuation the tokenizer can recognise on sight. CLDR also lists bare Latin
// letters ("P" for BWP, "L" for HNL); those are indistinguishable from a word and must not appear.
const isSign = (s) => [...s].length === 1 && !/[\p{L}\p{N}]/u.test(s);
@@ -74,20 +78,51 @@ function swiftString(value) {
return `"${value}"`;
}
/// Codes CLDR knows at all, and codes it still shows some region using. A code CLDR has never heard
/// of stays in: absence of evidence isn't retirement, and it is what keeps CNH, XAU, XDR and the
/// Crown Dependencies' pounds, none of which are any region's tender.
function inUse(currencyData) {
const known = new Set();
const live = new Set();
for (const entries of Object.values(currencyData.region)) {
for (const entry of entries) {
for (const [code, meta] of Object.entries(entry)) {
known.add(code);
if (!("_to" in meta)) live.add(code);
}
}
}
return (code) => live.has(code) || !known.has(code);
}
async function main() {
const currencies = await load(CURRENCIES, process.argv[2]);
const cldr = await load(CLDR, process.argv[3]);
if (!Array.isArray(currencies) || currencies.length === 0)
throw new Error("expected a non-empty array of currencies");
const feed = await load(RATES, process.argv[2]);
const cldr = await load(CLDR_NAMES, process.argv[3]);
const supplemental = await load(CLDR_CURRENCY_DATA, process.argv[4]);
const base = feed?.source;
const quotes = feed?.quotes;
// An error body arrives with HTTP 200, so the flag is the only thing that says the table is real.
if (feed?.success !== true) throw new Error("the rate feed reported failure");
if (!base || !quotes || typeof quotes !== "object")
throw new Error("unexpected feed shape: source/quotes missing");
const names = cldr?.main?.en?.numbers?.currencies;
if (!names) throw new Error("unexpected CLDR shape: main.en.numbers.currencies missing");
if (!supplemental?.supplemental?.currencyData?.region)
throw new Error("unexpected CLDR shape: supplemental.currencyData.region missing");
const latest = currencies.reduce((max, c) => (c.end_date > max ? c.end_date : max), "");
const cutoff = new Date(Date.parse(latest) - MAX_STALE_DAYS * 86400_000).toISOString().slice(0, 10);
const live = currencies
.filter((c) => c.end_date >= cutoff)
.sort((a, b) => a.iso_code.localeCompare(b.iso_code));
if (live.length < MIN_EXPECTED) throw new Error(`suspiciously few currencies: ${live.length}`);
// Quotes are keyed "<base><code>" and omit the base's own row, so it has to be added back.
const quoted = new Set([base]);
for (const pair of Object.keys(quotes)) {
if (pair.length === 6 && pair.startsWith(base)) quoted.add(pair.slice(3));
}
const stillInUse = inUse(supplemental.supplemental.currencyData);
const codes = [...quoted]
.filter((code) => /^[A-Z]{3}$/.test(code) && !CRYPTO_OWNED.has(code) && stillInUse(code))
.sort();
const retired = quoted.size - CRYPTO_OWNED.size - codes.length;
const asOf = new Date((feed.timestamp || Date.now() / 1000) * 1000).toISOString().slice(0, 10);
if (codes.length < MIN_EXPECTED) throw new Error(`suspiciously few currencies: ${codes.length}`);
const signClaims = new Map();
const narrowClaims = new Map();
@@ -95,15 +130,11 @@ async function main() {
const rows = [];
let uncovered = 0;
for (const entry of live) {
const code = entry.iso_code;
if (!/^[A-Z]{3}$/.test(code)) throw new Error(`unexpected ISO code ${JSON.stringify(code)}`);
for (const code of codes) {
const cldrEntry = names[code];
if (!cldrEntry) uncovered += 1;
// CLDR's label is the one people read ("US Dollar"); the feed's is the formal registry name
// ("United States Dollar"). Fall back to the feed for codes CLDR doesn't carry (GGP/IMP/JEP).
rows.push([code, displayName(cldrEntry, entry.name)]);
rows.push([code, displayName(cldrEntry, UNNAMED[code] || code)]);
if (!cldrEntry) continue;
if (cldrEntry.symbol && isSign(cldrEntry.symbol)) claim(signClaims, cldrEntry.symbol, code);
@@ -131,10 +162,11 @@ async function main() {
fs.writeFileSync(
out,
"// Generated by Scripts/gen-currencies.js — do not edit by hand.\n" +
`// Codes from ${CURRENCIES} (live as of ${latest}); names, signs and nouns from Unicode CLDR (en).\n` +
"// Ambiguous signs and nouns are deliberately absent — CalcCurrency.swift decides those.\n" +
`// Codes from the fiat rate feed (as of ${asOf}), minus those Unicode CLDR records as retired;\n` +
"// names, signs and nouns from CLDR (en). Ambiguous signs and nouns are deliberately absent,\n" +
"// and so is crypto — CalcCurrency.swift decides both.\n" +
"enum CurrencyData {\n" +
" /// Every currency the rate feed still publishes, as (ISO 4217 code, display name).\n" +
" /// Every fiat currency the rate feed prices, as (ISO 4217 code, display name).\n" +
" static let all: [(code: String, name: String)] = [\n" +
rows.map(([c, n]) => ` (${swiftString(c)}, ${swiftString(n)}),`).join("\n") +
"\n ]\n\n" +
@@ -157,8 +189,8 @@ async function main() {
"}\n",
);
console.log(
`wrote ${out} — ${rows.length} currencies as of ${latest} ` +
`(${currencies.length - live.length} retired, ${uncovered} without CLDR names), ` +
`wrote ${out} — ${rows.length} currencies ` +
`(${retired} retired, ${uncovered} without CLDR names), ` +
`${signs.size} signs, ${aliases.size} aliases`,
);
}
+44
View File
@@ -0,0 +1,44 @@
// Bundles the embedded extension runtime into Tinycast/Resources/RaycastRuntime.generated.js.
//
// pnpm install && node gen-enums.mjs && node build.mjs
//
// The output is committed (like EmojiData.generated.swift) so building Tinycast never needs Node.
import { build } from "esbuild";
import { mkdirSync, statSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const outFile = resolve("../../Tinycast/Resources/RaycastRuntime.generated.js");
const dev = process.argv.includes("--dev");
mkdirSync(dirname(outFile), { recursive: true });
const result = await build({
entryPoints: ["src/index.js"],
bundle: true,
format: "iife",
platform: "neutral",
// JavaScriptCore on macOS 26 is fully modern; no downlevelling needed.
target: ["es2022"],
minify: !dev,
sourcemap: false,
legalComments: "none",
write: false,
define: {
"process.env.NODE_ENV": dev ? '"development"' : '"production"',
__DEV__: dev ? "true" : "false",
},
banner: {
js: "// Generated by Tools/raycast-runtime — do not edit. Regenerate with `node build.mjs`.",
},
});
for (const warning of result.warnings) console.warn(warning.text);
const [output] = result.outputFiles;
// `__tinycastCompile` has to compile in the *global* scope so the extension bundle it evaluates can't
// see the runtime's own module locals. It is installed by Swift, not here.
writeFileSync(outFile, output.text);
const { size } = statSync(outFile);
console.log(`RaycastRuntime.generated.js — ${(size / 1024).toFixed(1)} KB${dev ? " (dev)" : ""}`);
+350
View File
@@ -0,0 +1,350 @@
// Self-contained checks for the embedded runtime: each fixture is a tiny extension command compiled
// with esbuild exactly the way a real one is (CJS, JSX automatic, @raycast/api + react external).
//
// node fixtures.mjs
import { transformSync } from "esbuild";
import { bootConfig, createHarness, describeTree } from "./test.mjs";
function compile(source) {
return transformSync(source, { loader: "jsx", jsx: "automatic", format: "cjs", target: "es2022" }).code;
}
const wait = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
let failures = 0;
function check(label, condition, detail) {
if (condition) {
console.log(` ✓ ${label}`);
} else {
failures += 1;
console.log(` ✗ ${label}${detail ? ` — ${detail}` : ""}`);
}
}
async function run(name, source, mode, body) {
console.log(`\n▶ ${name}`);
const harness = createHarness();
harness.boot(bootConfig());
harness.start("s1", compile(source), "/fixtures/cmd.js", "/fixtures", mode, {});
await wait();
if (harness.state.failures.length) {
failures += 1;
console.log(` ✗ threw:\n${harness.state.failures.join("\n")}`);
}
await body(harness);
harness.stop("s1");
}
// ─── Fixtures ───────────────────────────────────────────────────────
const listSource = `
import { List, ActionPanel, Action, Icon, Color } from "@raycast/api";
import { useState } from "react";
export default function Command() {
const [count, setCount] = useState(0);
return (
<List navigationTitle="Fixtures" searchBarPlaceholder="Type to filter…" isLoading={false}
searchBarAccessory={<List.Dropdown tooltip="Scope" value="all"><List.Dropdown.Item title="All" value="all" /></List.Dropdown>}>
<List.Section title="Numbers" subtitle="two">
<List.Item
title={"Count is " + count}
subtitle="tap to bump"
icon={{ source: Icon.Circle, tintColor: Color.Green }}
accessories={[{ text: String(count) }, { tag: { value: "live", color: Color.Blue } }]}
actions={
<ActionPanel title="Row">
<Action title="Bump" onAction={() => setCount((value) => value + 1)} />
<ActionPanel.Section title="More">
<Action.CopyToClipboard title="Copy" content="hello" />
</ActionPanel.Section>
</ActionPanel>
}
/>
<List.Item title="Second" keywords={["two"]} />
</List.Section>
</List>
);
}
`;
const detailSource = `
import { Detail, ActionPanel, Action } from "@raycast/api";
export default function Command() {
return (
<Detail
markdown={"# Title\\n\\nSome **body** text."}
navigationTitle="Doc"
metadata={
<Detail.Metadata>
<Detail.Metadata.Label title="Author" text="Ada" />
<Detail.Metadata.Separator />
<Detail.Metadata.TagList title="Tags">
<Detail.Metadata.TagList.Item text="swift" />
</Detail.Metadata.TagList>
<Detail.Metadata.Link title="Home" target="https://example.com" text="example.com" />
</Detail.Metadata>
}
actions={<ActionPanel><Action.OpenInBrowser url="https://example.com" /></ActionPanel>}
/>
);
}
`;
const formSource = `
import { Form, ActionPanel, Action } from "@raycast/api";
export default function Command() {
return (
<Form actions={<ActionPanel><Action.SubmitForm title="Save" onSubmit={(values) => { globalThis.__submitted = values; }} /></ActionPanel>}>
<Form.TextField id="name" title="Name" defaultValue="Ada" />
<Form.TextArea id="bio" title="Bio" />
<Form.Checkbox id="agree" label="Agree" defaultValue={true} />
<Form.Dropdown id="role" title="Role" defaultValue="dev">
<Form.Dropdown.Item value="dev" title="Developer" />
<Form.Dropdown.Item value="ops" title="Operator" />
</Form.Dropdown>
<Form.TagPicker id="tags" title="Tags" defaultValue={["a"]}>
<Form.TagPicker.Item value="a" title="A" />
</Form.TagPicker>
<Form.DatePicker id="due" title="Due" />
<Form.Separator />
<Form.Description text="All fields optional." />
</Form>
);
}
`;
const navigationSource = `
import { List, Detail, ActionPanel, Action } from "@raycast/api";
import { useNavigation } from "@raycast/api";
function Second() {
const { pop } = useNavigation();
return <Detail markdown="second" actions={<ActionPanel><Action title="Back" onAction={pop} /></ActionPanel>} />;
}
export default function Command() {
return (
<List>
<List.Item title="Go" actions={<ActionPanel><Action.Push title="Push" target={<Second />} /></ActionPanel>} />
</List>
);
}
`;
const nodeSource = `
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
import { Detail } from "@raycast/api";
export default function Command() {
const parts = [
path.join("/a/b", "../c", "d.txt"),
path.extname("x/y/file.tar.gz"),
path.basename("/a/b/c.md", ".md"),
os.platform(),
new URL("/next?q=1", "https://example.com/base/page").href,
new URLSearchParams({ a: "1", b: "two words" }).toString(),
crypto.createHash("sha256").update("abc").digest("hex").slice(0, 8),
Buffer.from("hello").toString("base64"),
Buffer.from("aGVsbG8=", "base64").toString("utf8"),
new TextDecoder().decode(new TextEncoder().encode("héllo")),
];
return <Detail markdown={parts.join("\\n")} />;
}
`;
const noViewSource = `
import { Clipboard, showHUD } from "@raycast/api";
export default async function Command() {
await Clipboard.copy("from no-view");
await showHUD("done");
globalThis.__ranNoView = true;
}
`;
const asyncSource = `
import { List } from "@raycast/api";
import { useEffect, useState } from "react";
export default function Command() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setItems(["alpha", "beta"]);
setLoading(false);
}, 20);
return () => clearTimeout(timer);
}, []);
return (
<List isLoading={loading}>
{items.map((item) => <List.Item key={item} title={item} />)}
</List>
);
}
`;
const errorSource = `
export default function Command() {
throw new Error("kaboom");
}
`;
// ─── Runner ─────────────────────────────────────────────────────────
export async function runFixtures() {
await run("List with sections, actions and a dropdown", listSource, "view", async (harness) => {
const tree = harness.state.trees.at(-1);
const dump = tree ? describeTree(tree) : "";
check("renders a screen", dump.includes("<__screen active=true>"));
check("renders the List", dump.includes("<List"));
check("keeps searchBarAccessory as a prop", dump.includes("searchBarAccessory=<List.Dropdown>"));
check("renders a section with items", dump.includes("<List.Section") && dump.includes("<List.Item"));
check("serializes accessories", dump.includes("accessories=[2]"));
check("hoists actions into a prop", dump.includes("actions=<ActionPanel>"));
check("defaults filtering to true", dump.includes("filtering=true"));
// Bump the counter through the action's handler and confirm the re-render.
const item = findNode(tree, "List.Item");
const panel = item.props.actions;
const bump = panel.children.find((child) => child.type === "Action");
check("action carries a dispatchable handler", !!bump?.props?.onAction?.$fn, JSON.stringify(bump?.props));
harness.dispatch("s1", bump.props.onAction.$fn);
await wait();
check("re-renders after the action", describeTree(harness.state.trees.at(-1)).includes("Count is 1"));
});
await run("Detail with metadata", detailSource, "view", async (harness) => {
const dump = describeTree(harness.state.trees.at(-1));
check("renders Detail", dump.includes("<Detail"));
check("hoists metadata", dump.includes("metadata=<Detail.Metadata>"));
const metadata = findNode(harness.state.trees.at(-1), "Detail").props.metadata;
const kinds = metadata.children.map((child) => child.type);
check(
"metadata children in order",
JSON.stringify(kinds) ===
JSON.stringify([
"Detail.Metadata.Label",
"Detail.Metadata.Separator",
"Detail.Metadata.TagList",
"Detail.Metadata.Link",
]),
JSON.stringify(kinds),
);
});
await run("Form fields and submit", formSource, "view", async (harness) => {
const tree = harness.state.trees.at(-1);
const form = findNode(tree, "Form");
const types = form.children.map((child) => child.type);
check(
"all field types render",
["Form.TextField", "Form.TextArea", "Form.Checkbox", "Form.Dropdown", "Form.TagPicker", "Form.DatePicker", "Form.Separator", "Form.Description"].every(
(type) => types.includes(type),
),
JSON.stringify(types),
);
const field = form.children.find((child) => child.type === "Form.TextField");
check("field exposes its value", field.props.value === "Ada", JSON.stringify(field.props));
check("field has a change handler", !!field.props.onTinycastChange?.$fn);
harness.dispatch("s1", field.props.onTinycastChange.$fn, ["Grace"]);
await wait();
const submit = findNode(harness.state.trees.at(-1), "Action");
harness.dispatch("s1", submit.props.onAction.$fn);
await wait();
const values = harness.call("globalThis.__submitted");
check(
"submit collects every field value",
values?.name === "Grace" && values.agree === true && values.role === "dev" && Array.isArray(values.tags),
JSON.stringify(values),
);
});
await run("Navigation push and pop", navigationSource, "view", async (harness) => {
const push = findNode(harness.state.trees.at(-1), "Action");
harness.dispatch("s1", push.props.onAction.$fn);
await wait();
let screens = harness.state.trees.at(-1).children.filter((child) => child.type === "__screen");
check("two screens after push", screens.length === 2, String(screens.length));
check("the pushed screen is active", screens[1].props.active === true);
check("the first screen is inactive but mounted", screens[0].props.active === false);
check("navigation depth reported", harness.state.navigationDepth === 2, String(harness.state.navigationDepth));
harness.call('__tinycast.popNavigation("s1")');
await wait();
screens = harness.state.trees.at(-1).children.filter((child) => child.type === "__screen");
check("one screen after pop", screens.length === 1, String(screens.length));
});
await run("Node shims and web globals", nodeSource, "view", async (harness) => {
const markdown = findNode(harness.state.trees.at(-1), "Detail").props.markdown.split("\n");
const expected = [
"/a/c/d.txt",
".gz",
"c",
"darwin",
"https://example.com/next?q=1",
"a=1&b=two+words",
"ba7816bf",
"aGVsbG8=",
"hello",
"héllo",
];
expected.forEach((value, index) => check(`shim ${index}: ${value}`, markdown[index] === value, markdown[index]));
});
await run("no-view command", noViewSource, "no-view", async (harness) => {
check("ran to completion", harness.state.finished === true);
check("ran the body", harness.call("globalThis.__ranNoView") === true);
check(
"used the clipboard and HUD host calls",
harness.state.hostCalls.includes("clipboard.copy") && harness.state.hostCalls.includes("feedback.showHUD"),
harness.state.hostCalls.join(", "),
);
});
await run("timers drive an async render", asyncSource, "view", async (harness) => {
check("starts loading", describeTree(harness.state.trees[0]).includes("isLoading=true"));
await wait(120);
const dump = describeTree(harness.state.trees.at(-1));
check("finishes loading", dump.includes("isLoading=false"), dump);
check("renders the resolved items", dump.includes("alpha") && dump.includes("beta"));
});
console.log("\n▶ Errors surface instead of crashing");
const harness = createHarness();
harness.boot(bootConfig());
harness.start("s1", compile(errorSource), "/fixtures/cmd.js", "/fixtures", "view", {});
await wait();
check("a throwing component reports a failure", harness.state.failures.some((message) => message.includes("kaboom")), harness.state.failures.join("|"));
harness.stop("s1");
console.log(failures === 0 ? "\nAll runtime fixtures passed." : `\n${failures} check(s) failed.`);
if (import.meta.url === `file://${process.argv[1]}`) process.exit(failures === 0 ? 0 : 1);
return failures;
}
function findNode(tree, type) {
const stack = [...(tree?.children ?? [])];
while (stack.length) {
const node = stack.shift();
if (node.type === type) return node;
stack.push(...(node.children ?? []));
// Slot props hold real nodes too (actions, metadata, detail).
for (const value of Object.values(node.props ?? {})) {
if (value && typeof value === "object" && value.type) stack.push(value);
else if (Array.isArray(value)) stack.push(...value.filter((entry) => entry && entry.type));
}
}
return undefined;
}
if (import.meta.url === `file://${process.argv[1]}`) await runFixtures();
+110
View File
@@ -0,0 +1,110 @@
// Emits src/api/enums.generated.js from the real @raycast/api type definitions, so Icon / Color /
// Toast.Style / Keyboard and friends carry the exact string values extensions compare against.
//
// node gen-enums.mjs
//
// Run it after bumping the @raycast/api devDependency, then `node build.mjs`.
import { readFileSync, writeFileSync } from "node:fs";
const dts = readFileSync("node_modules/@raycast/api/types/index.d.ts", "utf8").replace(/\r\n/g, "\n");
/// Walk the declaration file tracking `namespace`/`enum` nesting by brace depth, collecting every
/// enum member. Namespaced enums are keyed by their dotted path (`Image.Mask`).
function collectEnums(source) {
const lines = source.split("\n");
const scopes = [];
const enums = new Map();
let depth = 0;
let current = null;
for (const line of lines) {
const namespaceMatch = /^\s*(?:export\s+)?declare\s+namespace\s+([A-Za-z0-9_]+)\s*\{/.exec(line);
const enumMatch = /^\s*(?:export\s+)?(?:declare\s+)?enum\s+([A-Za-z0-9_]+)\s*\{/.exec(line);
if (!current && enumMatch) {
const path = [...scopes.map((scope) => scope.name), enumMatch[1]].join(".");
current = { path, members: [], startDepth: depth };
enums.set(path, current.members);
depth += 1;
continue;
}
if (!current && namespaceMatch) {
scopes.push({ name: namespaceMatch[1], depth });
depth += 1;
continue;
}
if (current) {
const member = /^\s*([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/.exec(line);
if (member) current.members.push([member[1], member[2]]);
}
for (const char of line) {
if (char === "{") depth += 1;
else if (char === "}") {
depth -= 1;
if (current && depth === current.startDepth) current = null;
while (scopes.length && depth <= scopes[scopes.length - 1].depth) scopes.pop();
}
}
}
return enums;
}
const rawEnums = collectEnums(dts);
const enums = new Map();
for (const [path, members] of rawEnums) {
if (!members.length) continue;
// `Foo_2` duplicates are the declaration file's way of disambiguating the List/Grid variants of
// the same public enum; they carry identical members, so the first one wins.
const clean = path.replace(/_\d+(\.|$)/g, "$1");
if (!enums.has(clean)) enums.set(clean, members);
}
const scopeNames = new Map();
for (const path of enums.keys()) {
const parts = path.split(".");
if (parts.length > 1) {
const parent = parts.slice(0, -1).join(".");
if (!scopeNames.has(parent)) scopeNames.set(parent, []);
scopeNames.get(parent).push(parts[parts.length - 1]);
}
}
function literal(members) {
return `Object.freeze({\n${members.map(([name, value]) => ` ${JSON.stringify(name)}: ${JSON.stringify(value)},`).join("\n")}\n})`;
}
const lines = [
"// Generated by gen-enums.mjs from @raycast/api's type definitions — do not edit by hand.",
"",
];
const roots = [...enums.keys()].filter((path) => !path.includes("."));
for (const path of roots) {
lines.push(`export const ${path} = ${literal(enums.get(path))};`, "");
}
const nested = {};
for (const [path, members] of enums) {
if (!path.includes(".")) continue;
const [scope, ...rest] = path.split(".");
nested[scope] = nested[scope] || [];
nested[scope].push([rest.join("."), members]);
}
lines.push("export const nestedEnums = {");
for (const scope of Object.keys(nested).sort()) {
lines.push(` ${JSON.stringify(scope)}: {`);
for (const [name, members] of nested[scope]) {
lines.push(` ${JSON.stringify(name)}: ${literal(members).replace(/\n/g, "\n ")},`);
}
lines.push(" },");
}
lines.push("};", "");
writeFileSync("src/api/enums.generated.js", lines.join("\n"));
const total = [...enums.values()].reduce((sum, members) => sum + members.length, 0);
console.log(`enums.generated.js: ${enums.size} enums, ${total} members`);
console.log([...enums.keys()].join(", "));
+16
View File
@@ -0,0 +1,16 @@
{
"name": "tinycast-raycast-runtime",
"private": true,
"version": "0.1.0",
"description": "Builds Tinycast's embedded Raycast-extension JS runtime (React + reconciler + @raycast/api shim) into a single file consumed by JavaScriptCore.",
"type": "module",
"scripts": {
"build": "node build.mjs"
},
"devDependencies": {
"@raycast/api": "^1.104.23",
"esbuild": "^0.25.0",
"react": "^19.2.0",
"react-reconciler": "^0.33.0"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,496 @@
// The @raycast/api component surface. Every component is a plain React function component that
// renders one host node; element-valued props (`actions`, `detail`, `metadata`, …) are moved into
// `__slot` children so the reconciler actually renders them and Swift receives them as structure.
import {
createContext,
createElement as h,
useCallback,
useContext,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { SLOT_TYPE } from "../reconciler.js";
import { Icon, ActionStyle } from "./enums.generated.js";
/// `single: true` marks a slot whose parent expects one node rather than a list.
function slot(name, element, single = true) {
if (element === undefined || element === null || element === false) return null;
return h(SLOT_TYPE, { key: `__slot_${name}`, name, single }, element);
}
function omit(props, keys) {
const out = {};
for (const key of Object.keys(props)) if (!keys.includes(key)) out[key] = props[key];
return out;
}
// ─── Navigation ─────────────────────────────────────────────────────
const NavigationContext = createContext(null);
export function useNavigation() {
const navigation = useContext(NavigationContext);
if (navigation) return navigation;
// A no-view command has no stack; make push/pop inert rather than a crash.
return { push: () => {}, pop: () => {} };
}
export const Navigation = { useNavigation };
/// The mounted root: keeps every pushed screen mounted (so popping back restores its state) and
/// marks the top one active. Swift renders whichever `__screen` is active. `controls` is filled with
/// push/pop so the session can pop from Escape without going through a rendered handler.
export function NavigationRoot({ initial, onStackChange, controls }) {
const [stack, setStack] = useState(() => [{ key: 0, element: initial }]);
const nextKey = useRef(1);
const push = useCallback(
(element) => {
setStack((current) => {
const next = [...current, { key: nextKey.current++, element }];
onStackChange?.(next.length);
return next;
});
},
[onStackChange],
);
const pop = useCallback(() => {
setStack((current) => {
if (current.length <= 1) return current;
const next = current.slice(0, -1);
onStackChange?.(next.length);
return next;
});
}, [onStackChange]);
const value = useMemo(() => ({ push, pop }), [push, pop]);
if (controls) {
controls.push = push;
controls.pop = pop;
}
return h(
NavigationContext.Provider,
{ value },
stack.map((entry, index) =>
h("__screen", { key: entry.key, active: index === stack.length - 1 }, entry.element),
),
);
}
// ─── Form state ─────────────────────────────────────────────────────
const FormContext = createContext(null);
function useFormRegistration(id, value, setValue) {
const form = useContext(FormContext);
if (form && id) form.fields.set(id, { value, setValue });
return form;
}
/// A field's local value, or the controlled `value` prop when the extension supplies one.
function useFieldValue(props, fallback) {
const [local, setLocal] = useState(() => (props.value !== undefined ? props.value : props.defaultValue ?? fallback));
const controlled = props.value !== undefined;
const value = controlled ? props.value : local;
const setValue = useCallback(
(next) => {
if (!controlled) setLocal(next);
},
[controlled],
);
useFormRegistration(props.id, value, setLocal);
return [value, setValue];
}
/// React 19 passes `ref` as an ordinary prop, so no component here needs `forwardRef` — which also
/// keeps every component directly callable (`List.Dropdown({...props})`), a pattern real extensions
/// use to share one code path between List and Grid.
function useFieldRef(ref, { setValue, initial, id }) {
useImperativeHandle(ref ?? null, () => ({
focus: () => hostFieldCommand("focus", id),
reset: () => setValue(initial),
}));
}
let hostFieldCommand = () => {};
export function setFieldCommandHandler(handler) {
hostFieldCommand = handler;
}
// ─── List ───────────────────────────────────────────────────────────
function List(props) {
const rest = omit(props, ["children", "actions", "searchBarAccessory"]);
// Raycast filters client-side unless the extension takes over the search text.
if (rest.filtering === undefined) rest.filtering = props.onSearchTextChange === undefined;
return h(
"List",
rest,
slot("actions", props.actions),
slot("searchBarAccessory", props.searchBarAccessory),
props.children,
);
}
function ListItem(props) {
return h(
"List.Item",
omit(props, ["children", "actions", "detail"]),
slot("actions", props.actions),
slot("detail", props.detail),
props.children,
);
}
function ListItemDetail(props) {
return h("List.Item.Detail", omit(props, ["children", "metadata"]), slot("metadata", props.metadata), props.children);
}
function Section(type) {
return function SectionComponent(props) {
return h(type, omit(props, ["children"]), props.children);
};
}
function EmptyView(type) {
return function EmptyViewComponent(props) {
return h(type, omit(props, ["children", "actions"]), slot("actions", props.actions), props.children);
};
}
/// The search-bar dropdown for List and Grid. Deliberately hook-free: Swift owns the selection (it
/// renders the control), seeded from `defaultValue`, and reports changes through `onChange`. That
/// also makes it safe to invoke directly rather than through JSX.
function makeSearchDropdown(type) {
function Dropdown(props) {
return h(type, omit(props, ["children"]), props.children);
}
Dropdown.Item = Section(`${type}.Item`);
Dropdown.Section = Section(`${type}.Section`);
return Dropdown;
}
List.Item = ListItem;
List.Item.Detail = ListItemDetail;
List.Section = Section("List.Section");
List.EmptyView = EmptyView("List.EmptyView");
List.Dropdown = makeSearchDropdown("List.Dropdown");
// ─── Grid ───────────────────────────────────────────────────────────
function Grid(props) {
const rest = omit(props, ["children", "actions", "searchBarAccessory"]);
if (rest.filtering === undefined) rest.filtering = props.onSearchTextChange === undefined;
return h(
"Grid",
rest,
slot("actions", props.actions),
slot("searchBarAccessory", props.searchBarAccessory),
props.children,
);
}
function GridItem(props) {
return h("Grid.Item", omit(props, ["children", "actions"]), slot("actions", props.actions), props.children);
}
Grid.Item = GridItem;
Grid.Section = Section("Grid.Section");
Grid.EmptyView = EmptyView("Grid.EmptyView");
Grid.Dropdown = makeSearchDropdown("Grid.Dropdown");
// ─── Detail ─────────────────────────────────────────────────────────
function Detail(props) {
return h(
"Detail",
omit(props, ["children", "actions", "metadata"]),
slot("actions", props.actions),
slot("metadata", props.metadata),
props.children,
);
}
function DetailMetadata(props) {
return h("Detail.Metadata", omit(props, ["children"]), props.children);
}
DetailMetadata.Label = Section("Detail.Metadata.Label");
DetailMetadata.Link = Section("Detail.Metadata.Link");
DetailMetadata.Separator = Section("Detail.Metadata.Separator");
DetailMetadata.TagList = Section("Detail.Metadata.TagList");
DetailMetadata.TagList.Item = Section("Detail.Metadata.TagList.Item");
Detail.Metadata = DetailMetadata;
// List.Item.Detail shares Detail's metadata components.
ListItemDetail.Metadata = DetailMetadata;
// ─── Form ───────────────────────────────────────────────────────────
function Form(props) {
const fields = useRef(new Map()).current;
const context = useMemo(() => ({ fields, values: () => collectValues(fields) }), [fields]);
return h(
FormContext.Provider,
{ value: context },
h(
"Form",
omit(props, ["children", "actions", "searchBarAccessory"]),
slot("actions", props.actions),
slot("searchBarAccessory", props.searchBarAccessory),
props.children,
),
);
}
function collectValues(fields) {
const values = {};
for (const [id, field] of fields) values[id] = field.value;
return values;
}
function makeField(type, fallback) {
return function Field(props) {
const [value, setValue] = useFieldValue(props, fallback);
useFieldRef(props.ref, { setValue, initial: props.defaultValue ?? fallback, id: props.id });
const rest = omit(props, ["children", "ref", "value", "defaultValue", "onChange", "onBlur", "onFocus"]);
return h(
type,
{
...rest,
value,
onTinycastChange: (next) => {
const decoded = type === "Form.DatePicker" ? decodeDate(next) : next;
setValue(decoded);
props.onChange?.(decoded);
},
onTinycastBlur: props.onBlur ? () => props.onBlur({ target: { value } }) : undefined,
onTinycastFocus: props.onFocus ? () => props.onFocus({ target: { value } }) : undefined,
},
props.children,
);
};
}
function decodeDate(value) {
if (value === null || value === undefined || value === "") return null;
if (value instanceof Date) return value;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
Form.TextField = makeField("Form.TextField", "");
Form.PasswordField = makeField("Form.PasswordField", "");
Form.TextArea = makeField("Form.TextArea", "");
Form.Checkbox = makeField("Form.Checkbox", false);
Form.DatePicker = makeField("Form.DatePicker", null);
Form.DatePicker.Type = undefined; // filled in from the generated enums by index.js
Form.TagPicker = makeField("Form.TagPicker", []);
Form.TagPicker.Item = Section("Form.TagPicker.Item");
Form.FilePicker = makeField("Form.FilePicker", []);
Form.Separator = Section("Form.Separator");
Form.Description = Section("Form.Description");
Form.LinkAccessory = Section("Form.LinkAccessory");
// A form dropdown is a value-carrying field, unlike the search-bar dropdowns above.
Form.Dropdown = makeField("Form.Dropdown", "");
Form.Dropdown.Item = Section("Form.Dropdown.Item");
Form.Dropdown.Section = Section("Form.Dropdown.Section");
// The deprecated flat aliases (`Form.DropdownItem`, …) still appear in shipped bundles.
Form.DropdownItem = Form.Dropdown.Item;
Form.DropdownSection = Form.Dropdown.Section;
Form.TagPickerItem = Form.TagPicker.Item;
// ─── ActionPanel / Action ───────────────────────────────────────────
function ActionPanel(props) {
return h("ActionPanel", omit(props, ["children"]), props.children);
}
ActionPanel.Section = Section("ActionPanel.Section");
// Deprecated alias kept because installed extensions still ship it.
ActionPanel.Item = Action;
ActionPanel.Submenu = function Submenu(props) {
return h("ActionPanel.Submenu", omit(props, ["children"]), props.children);
};
/// Every convenience action funnels into this one host node, so Swift only ever renders "Action".
function Action(props) {
return h("Action", omit(props, ["children"]));
}
/// Host bindings the convenience actions need. Injected by index.js to avoid a circular import
/// between the components and the system APIs they drive.
let effects = {};
export function setActionEffects(next) {
effects = next;
}
function convenience(displayName, build) {
const Component = function ConvenienceAction(props) {
return h(Action, build(props));
};
Component.displayName = displayName;
return Component;
}
Action.CopyToClipboard = convenience("Action.CopyToClipboard", (props) => ({
title: props.title ?? "Copy to Clipboard",
icon: props.icon ?? Icon.CopyClipboard,
shortcut: props.shortcut,
style: props.style,
autoFocus: props.autoFocus,
onAction: async () => {
await effects.copy({ content: props.content, concealed: props.concealed });
props.onCopy?.(props.content);
},
}));
Action.Paste = convenience("Action.Paste", (props) => ({
title: props.title ?? "Paste in Active App",
icon: props.icon ?? Icon.Clipboard,
shortcut: props.shortcut,
style: props.style,
autoFocus: props.autoFocus,
onAction: async () => {
await effects.paste({ content: props.content });
props.onPaste?.(props.content);
},
}));
Action.OpenInBrowser = convenience("Action.OpenInBrowser", (props) => ({
title: props.title ?? "Open in Browser",
icon: props.icon ?? Icon.Globe,
shortcut: props.shortcut,
style: props.style,
autoFocus: props.autoFocus,
onAction: async () => {
await effects.open({ target: props.url, application: props.application });
props.onOpen?.(props.url);
},
}));
Action.Open = convenience("Action.Open", (props) => ({
title: props.title,
icon: props.icon ?? Icon.Document,
shortcut: props.shortcut,
style: props.style,
autoFocus: props.autoFocus,
onAction: async () => {
await effects.open({ target: props.target, application: props.application });
props.onOpen?.(props.target);
},
}));
Action.OpenWith = convenience("Action.OpenWith", (props) => ({
title: props.title ?? "Open With",
icon: props.icon ?? Icon.AppWindow,
shortcut: props.shortcut,
onAction: async () => {
await effects.openWith({ path: props.path });
props.onOpen?.(props.path);
},
}));
Action.ShowInFinder = convenience("Action.ShowInFinder", (props) => ({
title: props.title ?? "Show in Finder",
icon: props.icon ?? Icon.Finder,
shortcut: props.shortcut,
onAction: async () => {
await effects.showInFinder({ path: props.path });
props.onShow?.(props.path);
},
}));
Action.Trash = convenience("Action.Trash", (props) => ({
title: props.title ?? "Move to Trash",
icon: props.icon ?? Icon.Trash,
style: props.style ?? ActionStyle.Destructive,
shortcut: props.shortcut,
onAction: async () => {
await effects.trash({ paths: props.paths });
props.onTrash?.(props.paths);
},
}));
Action.Push = function ActionPush(props) {
const { push } = useNavigation();
return h(Action, {
title: props.title,
icon: props.icon,
shortcut: props.shortcut,
style: props.style,
autoFocus: props.autoFocus,
onAction: () => {
push(props.target);
props.onPush?.();
},
});
};
Action.SubmitForm = function ActionSubmitForm(props) {
const form = useContext(FormContext);
return h(Action, {
title: props.title ?? "Submit Form",
icon: props.icon,
shortcut: props.shortcut,
style: props.style,
autoFocus: props.autoFocus,
onAction: () => props.onSubmit?.(form ? form.values() : {}),
});
};
Action.CreateSnippet = convenience("Action.CreateSnippet", (props) => ({
title: props.title ?? "Create Snippet",
icon: props.icon ?? Icon.Snippets,
shortcut: props.shortcut,
onAction: () => effects.createSnippet(props.snippet),
}));
Action.CreateQuicklink = convenience("Action.CreateQuicklink", (props) => ({
title: props.title ?? "Create Quicklink",
icon: props.icon ?? Icon.Link,
shortcut: props.shortcut,
onAction: () => effects.createQuicklink(props.quicklink),
}));
Action.ToggleQuickLook = convenience("Action.ToggleQuickLook", (props) => ({
title: props.title ?? "Quick Look",
icon: props.icon ?? Icon.Eye,
shortcut: props.shortcut,
onAction: () => effects.quickLook(props.target),
}));
Action.PickDate = function ActionPickDate(props) {
return h(Action, {
title: props.title,
icon: props.icon ?? Icon.Calendar,
shortcut: props.shortcut,
style: props.style,
// Rendered as a submenu-less action; Swift opens its own date picker and answers through onChange.
pickDate: { type: props.type, min: props.min, max: props.max },
onTinycastChange: (value) => props.onChange?.(decodeDate(value)),
});
};
Action.PickDate.Type = undefined; // filled in from the generated enums by index.js
Action.InstallMCPServer = convenience("Action.InstallMCPServer", (props) => ({
title: props.title ?? "Install MCP Server",
icon: props.icon ?? Icon.Plug,
shortcut: props.shortcut,
onAction: () => effects.unsupported("Action.InstallMCPServer"),
}));
// ─── Menu bar (not rendered — commands of mode "menu-bar" are reported unsupported) ──
function MenuBarExtra(props) {
return h("MenuBarExtra", omit(props, ["children"]), props.children);
}
MenuBarExtra.Item = Section("MenuBarExtra.Item");
MenuBarExtra.Submenu = Section("MenuBarExtra.Submenu");
MenuBarExtra.Section = Section("MenuBarExtra.Section");
MenuBarExtra.Separator = Section("MenuBarExtra.Separator");
export { List, Grid, Detail, Form, ActionPanel, Action, MenuBarExtra, FormContext };
@@ -0,0 +1,579 @@
// Generated by gen-enums.mjs from @raycast/api's type definitions — do not edit by hand.
export const ActionStyle = Object.freeze({
"Regular": "regular",
"Destructive": "destructive",
});
export const Color = Object.freeze({
"Blue": "raycast-blue",
"Green": "raycast-green",
"Magenta": "raycast-magenta",
"Orange": "raycast-orange",
"Purple": "raycast-purple",
"Red": "raycast-red",
"Yellow": "raycast-yellow",
"PrimaryText": "raycast-primary-text",
"SecondaryText": "raycast-secondary-text",
});
export const DatePickerType = Object.freeze({
"Date": "date",
"DateTime": "date_time",
});
export const GridAspectRatio = Object.freeze({
"One": "1",
"ThreeToTwo": "3/2",
"TwoToThree": "2/3",
"FourToThree": "4/3",
"ThreeToFour": "3/4",
"SixteenToNine": "16/9",
"NineToSixteen": "9/16",
});
export const GridFit = Object.freeze({
"Contain": "contain",
"Fill": "fill",
});
export const GridInset = Object.freeze({
"Zero": "zero",
"Small": "sm",
"Medium": "md",
"Large": "lg",
});
export const GridItemSize = Object.freeze({
"Small": "small",
"Medium": "medium",
"Large": "large",
});
export const Icon = Object.freeze({
"AddPerson": "add-person-16",
"Airplane": "airplane-16",
"AirplaneFilled": "airplane-filled-16",
"AirplaneLanding": "airplane-landing-16",
"AirplaneTakeoff": "airplane-takeoff-16",
"Airpods": "airpods-16",
"Alarm": "alarm-16",
"AlarmRinging": "alarm-ringing-16",
"AlignCentre": "align-centre-16",
"AlignLeft": "align-left-16",
"AlignRight": "align-right-16",
"AmericanFootball": "american-football-16",
"Anchor": "anchor-16",
"AppWindow": "app-window-16",
"AppWindowGrid2x2": "app-window-grid-2x2-16",
"AppWindowGrid3x3": "app-window-grid-3x3-16",
"AppWindowList": "app-window-list-16",
"AppWindowSidebarLeft": "app-window-sidebar-left-16",
"AppWindowSidebarRight": "app-window-sidebar-right-16",
"ArrowClockwise": "arrow-clockwise-16",
"ArrowCounterClockwise": "arrow-counter-clockwise-16",
"ArrowDown": "arrow-down-16",
"ArrowDownCircle": "arrow-down-circle-16",
"ArrowDownCircleFilled": "arrow-down-circle-filled-16",
"ArrowLeft": "arrow-left-16",
"ArrowLeftCircle": "arrow-left-circle-16",
"ArrowLeftCircleFilled": "arrow-left-circle-filled-16",
"ArrowNe": "arrow-ne-16",
"ArrowRight": "arrow-right-16",
"ArrowRightCircle": "arrow-right-circle-16",
"ArrowRightCircleFilled": "arrow-right-circle-filled-16",
"ArrowUp": "arrow-up-16",
"ArrowUpCircle": "arrow-up-circle-16",
"ArrowUpCircleFilled": "arrow-up-circle-filled-16",
"ArrowsContract": "arrows-contract-16",
"ArrowsExpand": "arrows-expand-16",
"AtSymbol": "at-symbol-16",
"BandAid": "band-aid-16",
"BankNote": "bank-note-16",
"BarChart": "bar-chart-16",
"BarCode": "bar-code-16",
"BathTub": "bath-tub-16",
"Battery": "battery-16",
"BatteryCharging": "battery-charging-16",
"BatteryDisabled": "battery-disabled-16",
"Bell": "bell-16",
"BellDisabled": "bell-disabled-16",
"Bike": "bike-16",
"Binoculars": "binoculars-16",
"Bird": "bird-16",
"BlankDocument": "blank-document-16",
"Bluetooth": "bluetooth-16",
"Boat": "boat-16",
"Bold": "bold-16",
"Bolt": "bolt-16",
"BoltDisabled": "bolt-disabled-16",
"Book": "book-16",
"Bookmark": "bookmark-16",
"Box": "box-16",
"Brush": "brush-16",
"Bubble": "speech-bubble-16",
"Bug": "bug-16",
"Building": "building-16",
"BulletPoints": "bullet-points-16",
"BullsEye": "bulls-eye-16",
"BullsEyeMissed": "bulls-eye-missed-16",
"Buoy": "buoy-16",
"Calculator": "calculator-16",
"Calendar": "calendar-16",
"Camera": "camera-16",
"Car": "car-16",
"Cart": "cart-16",
"Cd": "cd-16",
"Center": "center-16",
"Check": "check-16",
"CheckCircle": "check-circle-16",
"CheckList": "check-list-16",
"CheckRosette": "check-rosette-16",
"Checkmark": "checkmark-16",
"ChessPiece": "chess-piece-16",
"ChevronDown": "chevron-down-16",
"ChevronDownSmall": "chevron-down-small-16",
"ChevronLeft": "chevron-left-16",
"ChevronLeftSmall": "chevron-left-small-16",
"ChevronRight": "chevron-right-16",
"ChevronRightSmall": "chevron-right-small-16",
"ChevronUp": "chevron-up-16",
"ChevronUpDown": "chevron-up-down-16",
"ChevronUpSmall": "chevron-up-small-16",
"Circle": "circle-16",
"CircleDisabled": "circle-disabled-16",
"CircleEllipsis": "circle-ellipsis-16",
"CircleFilled": "circle-filled-16",
"CircleProgress": "circle-progress-16",
"CircleProgress100": "circle-progress-100-16",
"CircleProgress25": "circle-progress-25-16",
"CircleProgress50": "circle-progress-50-16",
"CircleProgress75": "circle-progress-75-16",
"ClearFormatting": "clear-formatting-16",
"Clipboard": "copy-clipboard-16",
"Clock": "clock-16",
"Cloud": "cloud-16",
"CloudLightning": "cloud-lightning-16",
"CloudRain": "cloud-rain-16",
"CloudSnow": "cloud-snow-16",
"CloudSun": "cloud-sun-16",
"Code": "code-16",
"CodeBlock": "code-block-16",
"Cog": "cog-16",
"Coin": "coin-16",
"Coins": "coins-16",
"CommandSymbol": "command-symbol-16",
"Compass": "compass-16",
"ComputerChip": "computer-chip-16",
"Contrast": "contrast-16",
"CopyClipboard": "copy-clipboard-16",
"CreditCard": "credit-card-16",
"CricketBall": "cricket-ball-16",
"Crop": "crop-16",
"Crown": "crown-16",
"Crypto": "crypto-16",
"DeleteDocument": "delete-document-16",
"Desktop": "desktop-16",
"Devices": "devices-16",
"Dna": "dna-16",
"Document": "blank-document-16",
"Dot": "dot-16",
"Download": "download-16",
"Droplets": "droplets-16",
"Duplicate": "duplicate-16",
"EditShape": "edit-shape-16",
"Eject": "eject-16",
"Ellipsis": "ellipsis-16",
"EllipsisVertical": "ellipsis-vertical-16",
"Emoji": "emoji-16",
"EmojiSad": "emoji-sad-16",
"Envelope": "envelope-16",
"Eraser": "eraser-16",
"ExclamationMark": "important-01-16",
"Exclamationmark": "exclamationmark-16",
"Exclamationmark2": "exclamationmark-2-16",
"Exclamationmark3": "exclamationmark-3-16",
"Eye": "eye-16",
"EyeDisabled": "eye-disabled-16",
"EyeDropper": "eye-dropper-16",
"Female": "female-16",
"FilmStrip": "film-strip-16",
"Filter": "filter-16",
"Finder": "finder-16",
"Fingerprint": "fingerprint-16",
"Flag": "flag-16",
"Folder": "folder-16",
"Footprints": "footprints-16",
"Forward": "forward-16",
"ForwardFilled": "forward-filled-16",
"FountainTip": "fountain-tip-16",
"FullSignal": "full-signal-16",
"GameController": "game-controller-16",
"Gauge": "gauge-16",
"Gear": "cog-16",
"Geopin": "geopin-16",
"Germ": "germ-16",
"Gift": "gift-16",
"Glasses": "glasses-16",
"Globe": "globe-01-16",
"Goal": "goal-16",
"Hammer": "hammer-16",
"HardDrive": "hard-drive-16",
"Hashtag": "hashtag-16",
"Heading": "heading-16",
"Headphones": "headphones-16",
"Heart": "heart-16",
"HeartDisabled": "heart-disabled-16",
"Heartbeat": "heartbeat-16",
"Highlight": "highlight-16",
"Hourglass": "hourglass-16",
"House": "house-16",
"Humidity": "humidity-16",
"Image": "image-16",
"Important": "important-01-16",
"Info": "info-01-16",
"Italics": "italics-16",
"Key": "key-16",
"Keyboard": "keyboard-16",
"Layers": "layers-16",
"Leaderboard": "leaderboard-16",
"Leaf": "leaf-16",
"LevelMeter": "signal-2-16",
"LightBulb": "light-bulb-16",
"LightBulbOff": "light-bulb-off-16",
"LineChart": "line-chart-16",
"Link": "link-16",
"List": "app-window-list-16",
"Livestream": "livestream-01-16",
"LivestreamDisabled": "livestream-disabled-01-16",
"Lock": "lock-16",
"LockDisabled": "lock-disabled-16",
"LockUnlocked": "lock-unlocked-16",
"Logout": "logout-16",
"Lorry": "lorry-16",
"Lowercase": "lowercase-16",
"MagnifyingGlass": "magnifying-glass-16",
"Male": "male-16",
"Map": "map-16",
"Mask": "mask-16",
"Maximize": "maximize-16",
"MedicalSupport": "medical-support-16",
"Megaphone": "megaphone-16",
"MemoryChip": "computer-chip-16",
"MemoryStick": "memory-stick-16",
"Message": "speech-bubble-16",
"Microphone": "microphone-16",
"MicrophoneDisabled": "microphone-disabled-16",
"Minimize": "minimize-16",
"Minus": "minus-16",
"MinusCircle": "minus-circle-16",
"MinusCircleFilled": "minus-circle-filled-16",
"Mobile": "mobile-16",
"Monitor": "monitor-16",
"Moon": "moon-16",
"MoonDown": "moon-down-16",
"MoonUp": "moon-up-16",
"Moonrise": "moonrise-16",
"Mountain": "mountain-16",
"Mouse": "mouse-16",
"Move": "move-16",
"Mug": "mug-16",
"MugSteam": "mug-steam-16",
"Multiply": "multiply-16",
"Music": "music-16",
"Network": "network-16",
"NewDocument": "new-document-16",
"NewFolder": "new-folder-16",
"Number00": "number-00-16",
"Number01": "number-01-16",
"Number02": "number-02-16",
"Number03": "number-03-16",
"Number04": "number-04-16",
"Number05": "number-05-16",
"Number06": "number-06-16",
"Number07": "number-07-16",
"Number08": "number-08-16",
"Number09": "number-09-16",
"Number10": "number-10-16",
"Number11": "number-11-16",
"Number12": "number-12-16",
"Number13": "number-13-16",
"Number14": "number-14-16",
"Number15": "number-15-16",
"Number16": "number-16-16",
"Number17": "number-17-16",
"Number18": "number-18-16",
"Number19": "number-19-16",
"Number20": "number-20-16",
"Number21": "number-21-16",
"Number22": "number-22-16",
"Number23": "number-23-16",
"Number24": "number-24-16",
"Number25": "number-25-16",
"Number26": "number-26-16",
"Number27": "number-27-16",
"Number28": "number-28-16",
"Number29": "number-29-16",
"Number30": "number-30-16",
"Number31": "number-31-16",
"Number32": "number-32-16",
"Number33": "number-33-16",
"Number34": "number-34-16",
"Number35": "number-35-16",
"Number36": "number-36-16",
"Number37": "number-37-16",
"Number38": "number-38-16",
"Number39": "number-39-16",
"Number40": "number-40-16",
"Number41": "number-41-16",
"Number42": "number-42-16",
"Number43": "number-43-16",
"Number44": "number-44-16",
"Number45": "number-45-16",
"Number46": "number-46-16",
"Number47": "number-47-16",
"Number48": "number-48-16",
"Number49": "number-49-16",
"Number50": "number-50-16",
"Number51": "number-51-16",
"Number52": "number-52-16",
"Number53": "number-53-16",
"Number54": "number-54-16",
"Number55": "number-55-16",
"Number56": "number-56-16",
"Number57": "number-57-16",
"Number58": "number-58-16",
"Number59": "number-59-16",
"Number60": "number-60-16",
"Number61": "number-61-16",
"Number62": "number-62-16",
"Number63": "number-63-16",
"Number64": "number-64-16",
"Number65": "number-65-16",
"Number66": "number-66-16",
"Number67": "number-67-16",
"Number68": "number-68-16",
"Number69": "number-69-16",
"Number70": "number-70-16",
"Number71": "number-71-16",
"Number72": "number-72-16",
"Number73": "number-73-16",
"Number74": "number-74-16",
"Number75": "number-75-16",
"Number76": "number-76-16",
"Number77": "number-77-16",
"Number78": "number-78-16",
"Number79": "number-79-16",
"Number80": "number-80-16",
"Number81": "number-81-16",
"Number82": "number-82-16",
"Number83": "number-83-16",
"Number84": "number-84-16",
"Number85": "number-85-16",
"Number86": "number-86-16",
"Number87": "number-87-16",
"Number88": "number-88-16",
"Number89": "number-89-16",
"Number90": "number-90-16",
"Number91": "number-91-16",
"Number92": "number-92-16",
"Number93": "number-93-16",
"Number94": "number-94-16",
"Number95": "number-95-16",
"Number96": "number-96-16",
"Number97": "number-97-16",
"Number98": "number-98-16",
"Number99": "number-99-16",
"NumberList": "number-list-16",
"Paperclip": "paperclip-16",
"Paragraph": "paragraph-16",
"Patch": "patch-16",
"Pause": "pause-16",
"PauseFilled": "pause-filled-16",
"Pencil": "pencil-16",
"Person": "person-16",
"PersonCircle": "person-circle-16",
"PersonLines": "person-lines-16",
"Phone": "phone-16",
"PhoneRinging": "phone-ringing-16",
"PieChart": "pie-chart-16",
"Pill": "pill-16",
"Pin": "pin-16",
"PinDisabled": "pin-disabled-16",
"Play": "play-16",
"PlayFilled": "play-filled-16",
"Plug": "plug-16",
"Plus": "plus-16",
"PlusCircle": "plus-circle-16",
"PlusCircleFilled": "plus-circle-filled-16",
"PlusMinusDivideMultiply": "plus-minus-divide-multiply-16",
"PlusSquare": "plus-square-16",
"PlusTopRightSquare": "plus-top-right-square-16",
"Power": "power-16",
"Print": "print-16",
"QuestionMark": "question-mark-circle-16",
"QuestionMarkCircle": "question-mark-circle-16",
"Quicklink": "quicklink-16",
"QuotationMarks": "quotation-marks-16",
"QuoteBlock": "quote-block-16",
"Racket": "racket-16",
"Raindrop": "raindrop-16",
"RaycastLogoNeg": "raycast-logo-neg-16",
"RaycastLogoPos": "raycast-logo-pos-16",
"Receipt": "receipt-16",
"Redo": "redo-16",
"RemovePerson": "remove-person-16",
"Repeat": "repeat-16",
"Replace": "replace-16",
"ReplaceOne": "replace-one-16",
"Reply": "reply-16",
"Rewind": "rewind-16",
"RewindFilled": "rewind-filled-16",
"Rocket": "rocket-16",
"Rosette": "rosette-16",
"RotateAntiClockwise": "rotate-anti-clockwise-16",
"RotateClockwise": "rotate-clockwise-16",
"Rss": "rss-16",
"Ruler": "ruler-16",
"SaveDocument": "save-document-16",
"Shield": "shield-01-16",
"ShortParagraph": "short-paragraph-16",
"Shuffle": "shuffle-16",
"Sidebar": "app-window-sidebar-right-16",
"Signal0": "signal-0-16",
"Signal1": "signal-1-16",
"Signal2": "signal-2-16",
"Signal3": "signal-3-16",
"Snippets": "snippets-16",
"Snowflake": "snowflake-16",
"SoccerBall": "soccer-ball-16",
"Speaker": "speaker-16",
"SpeakerDown": "speaker-down-16",
"SpeakerHigh": "speaker-high-16",
"SpeakerLow": "speaker-low-16",
"SpeakerOff": "speaker-off-16",
"SpeakerOn": "speaker-on-16",
"SpeakerUp": "speaker-up-16",
"SpeechBubble": "speech-bubble-16",
"SpeechBubbleActive": "speech-bubble-active-16",
"SpeechBubbleImportant": "speech-bubble-important-16",
"SquareEllipsis": "square-ellipsis-16",
"StackedBars1": "stacked-bars-1-16",
"StackedBars2": "stacked-bars-2-16",
"StackedBars3": "stacked-bars-3-16",
"StackedBars4": "stacked-bars-4-16",
"Star": "star-16",
"StarCircle": "star-circle-16",
"StarDisabled": "star-disabled-16",
"Stars": "stars-16",
"Stop": "stop-16",
"StopFilled": "stop-filled-16",
"Stopwatch": "stopwatch-16",
"Store": "store-16",
"StrikeThrough": "strike-through-16",
"Sun": "sun-16",
"Sunrise": "sunrise-16",
"Swatch": "swatch-16",
"Switch": "switch-16",
"Syringe": "syringe-16",
"Tack": "tack-16",
"TackDisabled": "tack-disabled-16",
"Tag": "tag-16",
"Temperature": "temperature-16",
"TennisBall": "tennis-ball-16",
"Terminal": "terminal-16",
"Text": "text-16",
"TextCursor": "text-cursor-16",
"TextInput": "text-input-16",
"TextSelection": "text-selection-16",
"ThumbsDown": "thumbs-down-16",
"ThumbsDownFilled": "thumbs-down-filled-16",
"ThumbsUp": "thumbs-up-16",
"ThumbsUpFilled": "thumbs-up-filled-16",
"Ticket": "ticket-16",
"Torch": "torch-16",
"Train": "train-16",
"Trash": "trash-16",
"Tray": "tray-16",
"Tree": "tree-16",
"Trophy": "trophy-16",
"TwoPeople": "two-people-16",
"Umbrella": "umbrella-16",
"Underline": "underline-16",
"Undo": "undo-16",
"Upload": "upload-16",
"Uppercase": "uppercase-16",
"Video": "video-16",
"VideoDisabled": "video-disabled-16",
"Wallet": "wallet-16",
"Wand": "wand-16",
"Warning": "warning-16",
"Waveform": "waveform-16",
"Weights": "weights-16",
"Wifi": "wifi-16",
"WifiDisabled": "wifi-disabled-16",
"Wind": "wind-16",
"Window": "app-window-16",
"Windsock": "windsock-16",
"WrenchScrewdriver": "wrench-screwdriver-16",
"WristWatch": "wrist-watch-16",
"XMarkCircle": "x-mark-circle-16",
"XMarkCircleFilled": "x-mark-circle-filled-16",
"XMarkCircleHalfDash": "x-mark-circle-half-dash-16",
"XMarkTopRightSquare": "x-mark-top-right-square-16",
"Xmark": "xmark-16",
"TwoArrowsClockwise": "arrow-clockwise-16",
"EyeSlash": "eye-disabled-16",
"SpeakerArrowDown": "speaker-down-16",
"SpeakerArrowUp": "speaker-up-16",
"SpeakerSlash": "speaker-off-16",
"TextDocument": "blank-document-16",
"XmarkCircle": "x-mark-circle-16",
});
export const LaunchType = Object.freeze({
"UserInitiated": "userInitiated",
"Background": "background",
});
export const PopToRootType = Object.freeze({
"Default": "default",
"Immediate": "immediate",
"Suspended": "suspended",
});
export const nestedEnums = {
"Alert": {
"ActionStyle": Object.freeze({
"Default": "default",
"Cancel": "cancel",
"Destructive": "destructive",
}),
},
"Image": {
"Mask": Object.freeze({
"Circle": "circle",
"RoundedRectangle": "roundedRectangle",
}),
},
"OAuth": {
"RedirectMethod": Object.freeze({
"Web": "web",
"App": "app",
"AppURI": "appURI",
}),
},
"Toast": {
"Style": Object.freeze({
"Success": "SUCCESS",
"Failure": "FAILURE",
"Animated": "ANIMATED",
}),
},
"WindowManagement": {
"DesktopType": Object.freeze({
"User": "User",
"FullScreen": "FullScreen",
}),
},
};
+200
View File
@@ -0,0 +1,200 @@
// Assembles the module an extension gets from `require("@raycast/api")`.
import { Action, ActionPanel, Detail, Form, Grid, List, MenuBarExtra, Navigation, setActionEffects, useNavigation } from "./components.js";
import * as enums from "./enums.generated.js";
import * as system from "./system.js";
const { nestedEnums, ...flatEnums } = enums;
Form.DatePicker.Type = flatEnums.DatePickerType;
Action.PickDate.Type = flatEnums.DatePickerType;
Grid.Fit = flatEnums.GridFit;
Grid.Inset = flatEnums.GridInset;
Grid.ItemSize = flatEnums.GridItemSize;
Grid.AspectRatio = flatEnums.GridAspectRatio;
Action.Style = flatEnums.ActionStyle;
// The convenience actions run their effect through the system APIs rather than duplicating them.
setActionEffects({
copy: async ({ content, concealed }) => {
await system.Clipboard.copy(content, { concealed });
await system.closeMainWindow();
await system.showHUD("Copied to Clipboard");
},
paste: async ({ content }) => {
await system.Clipboard.paste(content);
},
open: async ({ target, application }) => {
await system.open(target, application);
await system.closeMainWindow();
},
openWith: async ({ path }) => {
await system.openWith(path);
},
showInFinder: async ({ path }) => {
await system.showInFinder(path);
await system.closeMainWindow();
},
trash: async ({ paths }) => {
await system.trash(paths);
},
createSnippet: () => system.unsupported("Action.CreateSnippet"),
createQuicklink: () => system.unsupported("Action.CreateQuicklink"),
quickLook: () => system.unsupported("Action.ToggleQuickLook"),
unsupported: (what) => system.unsupported(what),
});
const Image = { Mask: nestedEnums.Image.Mask };
const Alert = { ActionStyle: nestedEnums.Alert.ActionStyle };
const Keyboard = {
Shortcut: {
Common: {
Copy: { modifiers: ["cmd", "shift"], key: "c" },
CopyDeeplink: { modifiers: ["cmd", "shift"], key: "c" },
CopyName: { modifiers: ["cmd", "shift"], key: "." },
CopyPath: { modifiers: ["cmd", "shift"], key: "," },
Save: { modifiers: ["cmd"], key: "s" },
Duplicate: { modifiers: ["cmd"], key: "d" },
Edit: { modifiers: ["cmd"], key: "e" },
MoveDown: { modifiers: ["cmd", "shift"], key: "arrowDown" },
MoveUp: { modifiers: ["cmd", "shift"], key: "arrowUp" },
New: { modifiers: ["cmd"], key: "n" },
Open: { modifiers: ["cmd"], key: "o" },
OpenWith: { modifiers: ["cmd", "shift"], key: "o" },
Pin: { modifiers: ["cmd", "shift"], key: "p" },
Refresh: { modifiers: ["cmd"], key: "r" },
Remove: { modifiers: ["ctrl"], key: "x" },
RemoveAll: { modifiers: ["ctrl", "shift"], key: "x" },
ToggleQuickLook: { modifiers: ["cmd"], key: "y" },
},
},
};
/// Surfaces Tinycast doesn't implement. They exist so a bundle that merely imports them still loads;
/// calling one gives the extension (and the user) a clear reason instead of a TypeError.
function rejectingNamespace(name, members) {
const target = {};
for (const member of members) target[member] = () => system.unsupported(`${name}.${member}`);
return target;
}
const AI = {
...rejectingNamespace("AI", ["ask"]),
Model: Object.freeze({}),
Creativity: Object.freeze({}),
};
const OAuth = {
RedirectMethod: nestedEnums.OAuth.RedirectMethod,
PKCEClient: class PKCEClient {
constructor() {
throw new Error("OAuth.PKCEClient is not supported in Tinycast extensions yet. See docs/extensions.md.");
}
},
};
const BrowserExtension = rejectingNamespace("BrowserExtension", ["getContent", "getTabs"]);
const WindowManagement = {
DesktopType: nestedEnums.WindowManagement.DesktopType,
...rejectingNamespace("WindowManagement", ["getWindowsOnActiveDesktop", "getActiveWindow", "setWindowBounds", "getDesktops"]),
};
export const raycastApi = {
// Components
List,
Grid,
Detail,
Form,
ActionPanel,
Action,
MenuBarExtra,
Icon: flatEnums.Icon,
Color: flatEnums.Color,
Image,
Keyboard,
Alert,
Toast: system.Toast,
Cache: system.Cache,
LaunchType: flatEnums.LaunchType,
PopToRootType: flatEnums.PopToRootType,
// Navigation
useNavigation,
Navigation,
// Feedback
showToast: system.showToast,
showHUD: system.showHUD,
confirmAlert: system.confirmAlert,
// System
Clipboard: system.Clipboard,
LocalStorage: system.LocalStorage,
environment: system.environment,
getPreferenceValues: system.getPreferenceValues,
openExtensionPreferences: system.openExtensionPreferences,
openCommandPreferences: system.openCommandPreferences,
closeMainWindow: system.closeMainWindow,
popToRoot: system.popToRoot,
clearSearchBar: system.clearSearchBar,
open: system.open,
trash: system.trash,
showInFinder: system.showInFinder,
getApplications: system.getApplications,
getDefaultApplication: system.getDefaultApplication,
getFrontmostApplication: system.getFrontmostApplication,
getSelectedText: system.getSelectedText,
getSelectedFinderItems: system.getSelectedFinderItems,
getFrontmostBrowserTab: system.getFrontmostBrowserTab,
captureException: system.captureException,
launchCommand: system.launchCommand,
updateCommandMetadata: system.updateCommandMetadata,
// Unimplemented namespaces
AI,
OAuth,
BrowserExtension,
WindowManagement,
// Deprecated aliases
copyTextToClipboard: system.copyTextToClipboard,
pasteText: system.pasteText,
clearClipboard: system.clearClipboard,
getLocalStorageItem: system.getLocalStorageItem,
setLocalStorageItem: system.setLocalStorageItem,
removeLocalStorageItem: system.removeLocalStorageItem,
allLocalStorageItems: system.allLocalStorageItems,
clearLocalStorage: system.clearLocalStorage,
randomId: system.randomId,
ActionPanelItem: Action,
ActionPanelSection: ActionPanel.Section,
ActionPanelSubmenu: ActionPanel.Submenu,
CopyToClipboardAction: Action.CopyToClipboard,
OpenInBrowserAction: Action.OpenInBrowser,
OpenAction: Action.Open,
PasteAction: Action.Paste,
PushAction: Action.Push,
ShowInFinderAction: Action.ShowInFinder,
SubmitFormAction: Action.SubmitForm,
TrashAction: Action.Trash,
ImageMask: Image.Mask,
ToastStyle: system.Toast.Style,
AlertActionStyle: Alert.ActionStyle,
FormTextField: Form.TextField,
FormTextArea: Form.TextArea,
FormCheckbox: Form.Checkbox,
FormDatePicker: Form.DatePicker,
FormDropdown: Form.Dropdown,
FormDropdownItem: Form.Dropdown.Item,
FormDropdownSection: Form.Dropdown.Section,
FormSeparator: Form.Separator,
FormTagPicker: Form.TagPicker,
FormTagPickerItem: Form.TagPicker.Item,
};
// Bundles compiled by esbuild reach for the interop marker before spreading the namespace.
raycastApi.__esModule = true;
raycastApi.default = raycastApi;
+375
View File
@@ -0,0 +1,375 @@
// The non-visual half of @raycast/api: clipboard, storage, cache, preferences, app lookup and the
// window/feedback calls. Everything here is an async host call answered by Swift on the main actor.
import { hostCall } from "../host.js";
import { nestedEnums } from "./enums.generated.js";
const ToastStyle = nestedEnums.Toast.Style;
let boot = { environment: {}, preferences: {}, launchProps: {} };
export function configureSystem(info) {
boot = { ...boot, ...info };
}
export function unsupported(what) {
return Promise.reject(
new Error(`${what} is not supported in Tinycast extensions yet. See docs/extensions.md.`),
);
}
// ─── Clipboard ──────────────────────────────────────────────────────
export const Clipboard = {
copy: (content, options) => hostCall("clipboard", "copy", [normalizeClipboardContent(content), options ?? {}]),
paste: (content) => hostCall("clipboard", "paste", [normalizeClipboardContent(content)]),
clear: () => hostCall("clipboard", "clear", []),
read: (options) => hostCall("clipboard", "read", [options ?? {}]),
readText: (options) => hostCall("clipboard", "readText", [options ?? {}]),
};
function normalizeClipboardContent(content) {
if (content === null || content === undefined) return { text: "" };
if (typeof content === "string") return { text: content };
if (typeof content === "number") return { text: String(content) };
return content;
}
// ─── LocalStorage ───────────────────────────────────────────────────
export const LocalStorage = {
async getItem(key) {
const value = await hostCall("storage", "get", [String(key)]);
return value === null ? undefined : value;
},
setItem: (key, value) => hostCall("storage", "set", [String(key), value]),
removeItem: (key) => hostCall("storage", "remove", [String(key)]),
clear: () => hostCall("storage", "clear", []),
allItems: () => hostCall("storage", "all", []),
};
// ─── Cache ──────────────────────────────────────────────────────────
// Raycast's Cache is synchronous. Swift hands the whole namespace over at construction and every
// mutation is fire-and-forget write-behind, so reads stay synchronous as the API promises.
const cacheSubscribers = new Map();
let nextCacheSubscription = 1;
export class Cache {
constructor(options = {}) {
this.namespace = options.namespace ?? "default";
this.capacity = options.capacity ?? 10 * 1024 * 1024;
this._entries = new Map(Object.entries(cacheSnapshot(this.namespace)));
this._subscribers = new Set();
// `useCachedState` hands `cache.subscribe` straight to `useSyncExternalStore`, unbound — so every
// method has to survive being detached from the instance.
for (const method of ["has", "get", "set", "remove", "clear", "subscribe"]) {
this[method] = Cache.prototype[method].bind(this);
}
}
get isEmpty() {
return this._entries.size === 0;
}
has(key) {
return this._entries.has(String(key));
}
get(key) {
return this._entries.get(String(key));
}
set(key, data) {
this._entries.set(String(key), String(data));
this._persist(String(key), String(data));
this._notify(String(key), String(data));
}
remove(key) {
const existed = this._entries.delete(String(key));
if (existed) {
this._persist(String(key), null);
this._notify(String(key), undefined);
}
return existed;
}
clear(options = {}) {
this._entries.clear();
hostCall("cache", "clear", [this.namespace]).catch(() => {});
if (options.notifySubscribers !== false) this._notify(undefined, undefined);
}
subscribe(subscriber) {
const id = nextCacheSubscription++;
this._subscribers.add(subscriber);
cacheSubscribers.set(id, { namespace: this.namespace, subscriber });
return () => {
this._subscribers.delete(subscriber);
cacheSubscribers.delete(id);
};
}
_persist(key, value) {
hostCall("cache", "set", [this.namespace, key, value]).catch(() => {});
}
_notify(key, data) {
for (const subscriber of this._subscribers) {
try {
subscriber(key, data);
} catch {
// A throwing subscriber must not break the write that triggered it.
}
}
}
}
/// Swift installs the initial contents of every cache namespace at boot; a namespace first touched
/// later starts empty and fills as the extension writes to it.
function cacheSnapshot(namespace) {
return boot.caches?.[namespace] ?? {};
}
// ─── Preferences & environment ──────────────────────────────────────
export function getPreferenceValues() {
return { ...boot.preferences };
}
export const environment = new Proxy(
{},
{
get(_target, key) {
if (key === "canAccess") return () => false;
return boot.environment?.[key];
},
has: (_target, key) => key in (boot.environment ?? {}),
ownKeys: () => Object.keys(boot.environment ?? {}),
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }),
},
);
export function openExtensionPreferences() {
return hostCall("window", "openPreferences", ["extension"]);
}
export function openCommandPreferences() {
return hostCall("window", "openPreferences", ["command"]);
}
// ─── Window / navigation control ────────────────────────────────────
export function closeMainWindow(options = {}) {
return hostCall("window", "close", [options]);
}
export function popToRoot(options = {}) {
return hostCall("window", "popToRoot", [options]);
}
export function clearSearchBar(options = {}) {
return hostCall("window", "clearSearchBar", [options]);
}
// ─── Applications & files ───────────────────────────────────────────
export function open(target, application) {
const app = typeof application === "string" ? application : application?.bundleId ?? application?.path;
return hostCall("system", "open", [String(target), app ?? null]);
}
/// Raycast shows an app picker for Action.OpenWith; Swift resolves the candidates and presents them.
export function openWith(path) {
return hostCall("system", "openWith", [String(path)]);
}
export function trash(paths) {
return hostCall("system", "trash", [(Array.isArray(paths) ? paths : [paths]).map(String)]);
}
export function showInFinder(path) {
return hostCall("system", "showInFinder", [String(path)]);
}
export function getApplications(path) {
return hostCall("system", "applications", [path ? String(path) : null]);
}
export function getDefaultApplication(path) {
return hostCall("system", "defaultApplication", [String(path)]);
}
export function getFrontmostApplication() {
return hostCall("system", "frontmostApplication", []);
}
export function getSelectedText() {
return hostCall("system", "selectedText", []);
}
export function getSelectedFinderItems() {
return hostCall("system", "selectedFinderItems", []);
}
export function captureException(error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
}
export function launchCommand(options) {
return hostCall("system", "launchCommand", [options]);
}
export function updateCommandMetadata(metadata) {
return hostCall("system", "updateCommandMetadata", [metadata]);
}
export function getFrontmostBrowserTab() {
return unsupported("getFrontmostBrowserTab");
}
// ─── Feedback ───────────────────────────────────────────────────────
export class Toast {
constructor(options = {}) {
this._id = null;
this._options = {
style: options.style ?? ToastStyle.Success,
title: options.title ?? "",
message: options.message,
primaryAction: options.primaryAction,
secondaryAction: options.secondaryAction,
};
}
get style() {
return this._options.style;
}
set style(value) {
this._options.style = value;
this._sync();
}
get title() {
return this._options.title;
}
set title(value) {
this._options.title = value;
this._sync();
}
get message() {
return this._options.message;
}
set message(value) {
this._options.message = value;
this._sync();
}
get primaryAction() {
return this._options.primaryAction;
}
set primaryAction(value) {
this._options.primaryAction = value;
this._sync();
}
get secondaryAction() {
return this._options.secondaryAction;
}
set secondaryAction(value) {
this._options.secondaryAction = value;
this._sync();
}
async show() {
this._id = await hostCall("feedback", "showToast", [this._serialize()]);
return this;
}
async hide() {
if (this._id === null) return;
await hostCall("feedback", "hideToast", [this._id]);
this._id = null;
}
_sync() {
if (this._id === null) return;
hostCall("feedback", "updateToast", [this._id, this._serialize()]).catch(() => {});
}
/// Toast actions carry callbacks, which can't cross the bridge. Register them locally and send only
/// the titles plus a token Swift echoes back through `runToastAction`.
_serialize() {
const encode = (action, slotName) => {
if (!action) return null;
const token = `${this._token()}:${slotName}`;
toastActions.set(token, action.onAction);
return { title: action.title, shortcut: action.shortcut, token };
};
return {
style: this._options.style,
title: this._options.title,
message: this._options.message,
primaryAction: encode(this._options.primaryAction, "primary"),
secondaryAction: encode(this._options.secondaryAction, "secondary"),
};
}
_token() {
if (!this._tokenBase) this._tokenBase = `toast-${nextToastToken++}`;
return this._tokenBase;
}
}
Toast.Style = ToastStyle;
let nextToastToken = 1;
const toastActions = new Map();
export function runToastAction(token) {
const handler = toastActions.get(token);
if (!handler) return;
// Raycast hands the live Toast to the callback; the shim passes the token holder's own toast.
handler({ hide: () => {} });
}
export async function showToast(optionsOrStyle, title, message) {
const options =
typeof optionsOrStyle === "object" && optionsOrStyle !== null
? optionsOrStyle
: { style: optionsOrStyle, title, message };
const toast = new Toast(options);
await toast.show();
return toast;
}
export function showHUD(title, options = {}) {
return hostCall("feedback", "showHUD", [String(title), options]);
}
export function confirmAlert(options = {}) {
return hostCall("feedback", "confirmAlert", [
{
title: options.title,
message: options.message,
icon: options.icon,
primaryAction: options.primaryAction ? { title: options.primaryAction.title, style: options.primaryAction.style } : null,
dismissAction: options.dismissAction ? { title: options.dismissAction.title, style: options.dismissAction.style } : null,
rememberUserChoice: !!options.rememberUserChoice,
},
]).then((confirmed) => {
if (confirmed) options.primaryAction?.onAction?.();
else options.dismissAction?.onAction?.();
return confirmed;
});
}
// ─── Deprecated aliases still used by older extensions ──────────────
export const copyTextToClipboard = Clipboard.copy;
export const pasteText = Clipboard.paste;
export const clearClipboard = Clipboard.clear;
export const getLocalStorageItem = LocalStorage.getItem;
export const setLocalStorageItem = LocalStorage.setItem;
export const removeLocalStorageItem = LocalStorage.removeItem;
export const allLocalStorageItems = LocalStorage.allItems;
export const clearLocalStorage = LocalStorage.clear;
export const randomId = () => `${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}`;
+184
View File
@@ -0,0 +1,184 @@
// A Buffer subset over Uint8Array — enough for the encode/decode work extension bundles do.
// Anything stream-shaped is deliberately absent; see docs/extensions.md for the supported surface.
import { base64ToBytes, bytesToBase64, utf8Decode, utf8Encode } from "./polyfills.js";
function hexToBytes(text) {
const clean = String(text).replace(/[^0-9a-fA-F]/g, "");
const out = new Uint8Array(clean.length >> 1);
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16);
return out;
}
function bytesToHex(bytes) {
let out = "";
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
return out;
}
function latin1ToBytes(text) {
const out = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 0xff;
return out;
}
function bytesToLatin1(bytes) {
let out = "";
for (const byte of bytes) out += String.fromCharCode(byte);
return out;
}
function normalizeEncoding(encoding) {
const name = String(encoding || "utf8").toLowerCase();
if (name === "utf-8" || name === "utf8") return "utf8";
if (name === "base64" || name === "base64url") return name;
if (name === "hex") return "hex";
if (name === "latin1" || name === "binary" || name === "ascii") return "latin1";
if (name === "utf16le" || name === "ucs2" || name === "ucs-2" || name === "utf-16le") return "utf16le";
return "utf8";
}
function decode(bytes, encoding) {
switch (normalizeEncoding(encoding)) {
case "base64":
return bytesToBase64(bytes);
case "base64url":
return bytesToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
case "hex":
return bytesToHex(bytes);
case "latin1":
return bytesToLatin1(bytes);
case "utf16le": {
let out = "";
for (let i = 0; i + 1 < bytes.length; i += 2) out += String.fromCharCode(bytes[i] | (bytes[i + 1] << 8));
return out;
}
default:
return utf8Decode(bytes);
}
}
function encode(text, encoding) {
switch (normalizeEncoding(encoding)) {
case "base64":
case "base64url":
return base64ToBytes(String(text).replace(/-/g, "+").replace(/_/g, "/"));
case "hex":
return hexToBytes(text);
case "latin1":
return latin1ToBytes(String(text));
case "utf16le": {
const source = String(text);
const out = new Uint8Array(source.length * 2);
for (let i = 0; i < source.length; i++) {
const code = source.charCodeAt(i);
out[i * 2] = code & 0xff;
out[i * 2 + 1] = code >> 8;
}
return out;
}
default:
return utf8Encode(String(text));
}
}
export class Buffer extends Uint8Array {
static from(value, encodingOrOffset, length) {
if (typeof value === "string") return wrap(encode(value, encodingOrOffset));
if (value instanceof ArrayBuffer) {
return wrap(
new Uint8Array(
value,
encodingOrOffset || 0,
length === undefined ? value.byteLength - (encodingOrOffset || 0) : length,
),
);
}
if (value instanceof Uint8Array) return wrap(new Uint8Array(value));
if (Array.isArray(value)) return wrap(new Uint8Array(value));
if (value && typeof value === "object" && value.type === "Buffer" && Array.isArray(value.data)) {
return wrap(new Uint8Array(value.data));
}
throw new TypeError("Buffer.from: unsupported input");
}
static alloc(size, fill) {
const bytes = new Uint8Array(Math.max(0, size | 0));
if (fill !== undefined && fill !== 0) {
const value = typeof fill === "number" ? fill : encode(String(fill), "utf8")[0] ?? 0;
bytes.fill(value & 0xff);
}
return wrap(bytes);
}
static allocUnsafe(size) {
return Buffer.alloc(size);
}
static concat(list, totalLength) {
const parts = list.map((part) => (part instanceof Uint8Array ? part : Buffer.from(part)));
const total = totalLength === undefined ? parts.reduce((sum, part) => sum + part.length, 0) : totalLength;
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
if (offset >= total) break;
out.set(part.subarray(0, Math.min(part.length, total - offset)), offset);
offset += part.length;
}
return wrap(out);
}
static isBuffer(value) {
return value instanceof Uint8Array;
}
static byteLength(value, encoding) {
if (typeof value === "string") return encode(value, encoding).length;
return value?.length ?? 0;
}
toString(encoding, start, end) {
const view = this.subarray(start ?? 0, end ?? this.length);
return decode(view, encoding);
}
toJSON() {
return { type: "Buffer", data: Array.from(this) };
}
equals(other) {
if (!(other instanceof Uint8Array) || other.length !== this.length) return false;
for (let i = 0; i < this.length; i++) if (this[i] !== other[i]) return false;
return true;
}
write(text, offset = 0, length, encoding) {
if (typeof length === "string") {
encoding = length;
length = undefined;
}
const bytes = encode(text, encoding);
const count = Math.min(length ?? bytes.length, this.length - offset);
this.set(bytes.subarray(0, count), offset);
return count;
}
slice(start, end) {
return wrap(this.subarray(start, end));
}
}
/// `new Uint8Array(...)` results need the Buffer prototype grafted on: subclassing Uint8Array and
/// then copying would double every allocation for large payloads.
function wrap(bytes) {
Object.setPrototypeOf(bytes, Buffer.prototype);
return bytes;
}
export const bufferModule = {
Buffer,
atob: globalThis.atob,
btoa: globalThis.btoa,
constants: { MAX_LENGTH: 0x7fffffff, MAX_STRING_LENGTH: 0x1fffffe8 },
kMaxLength: 0x7fffffff,
};
+81
View File
@@ -0,0 +1,81 @@
// The single seam between the JS runtime and Swift. Swift installs `__tinycastHost` on the global
// before evaluating the bundle; everything else in here goes through these helpers.
const raw = globalThis.__tinycastHost;
if (!raw) throw new Error("__tinycastHost missing — the runtime was evaluated outside Tinycast.");
export const hostRaw = raw;
let nextCallId = 1;
const pending = new Map();
/// Every Raycast API that touches the system is an async host call: Swift answers later via
/// `__tinycast.settle`, so the JS thread never blocks waiting on the main actor.
export function hostCall(api, method, args) {
return new Promise((resolve, reject) => {
const callId = nextCallId++;
pending.set(callId, { resolve, reject });
try {
raw.invoke(String(callId), api, method, JSON.stringify(args === undefined ? [] : args));
} catch (error) {
pending.delete(callId);
reject(error);
}
});
}
/// The blocking counterpart, for the node shims only (fs, child_process, crypto, zlib). Safe because
/// Swift services these entirely on the JS thread — nothing here ever hops to the main actor, so a
/// synchronous answer can't deadlock against the UI.
export function hostCallSync(api, method, args) {
const json = hostRaw.invokeSync(api, method, JSON.stringify(args === undefined ? [] : args));
const result = json ? JSON.parse(json) : { ok: true };
if (result.ok) return result.value;
const error = new Error(String(result.error || `${api}.${method} failed`));
if (result.code) error.code = result.code;
if (result.errno !== undefined) error.errno = result.errno;
if (result.path) error.path = result.path;
throw error;
}
export function settle(callId, ok, payload) {
const entry = pending.get(Number(callId));
if (!entry) return;
pending.delete(Number(callId));
if (ok) {
entry.resolve(payload === undefined || payload === "" ? undefined : JSON.parse(payload));
} else {
entry.reject(new Error(String(payload || "Host call failed")));
}
}
export function log(level, parts) {
let text;
try {
text = parts.map(formatLogArg).join(" ");
} catch {
text = "[unserializable log argument]";
}
raw.log(level, text);
}
function formatLogArg(value) {
if (typeof value === "string") return value;
if (value instanceof Error) return describeError(value);
if (value === undefined) return "undefined";
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
/// JavaScriptCore's `Error.stack` is frames only — unlike V8 it does not repeat the message — so the
/// headline has to be prepended or a logged error arrives as a bare stack.
export function describeError(error) {
if (!(error instanceof Error)) return String(error);
const headline = `${error.name || "Error"}: ${error.message}`;
const stack = String(error.stack || "");
if (!stack) return headline;
return stack.startsWith(headline) ? stack : `${headline}\n${stack}`;
}
+180
View File
@@ -0,0 +1,180 @@
// Entry point. Installs the polyfills and the module registry, then exposes `__tinycast` — the only
// thing Swift calls into.
import "./polyfills.js";
import "./url.js";
import { createElement } from "react";
import * as React from "react";
import * as JSXRuntime from "react/jsx-runtime";
import { describeError, log, settle } from "./host.js";
import { fireTimer, setUncaughtHandler } from "./polyfills.js";
import { configureNodeShims } from "./node-shims.js";
import { defineModule, evaluateCommonJS } from "./modules.js";
import { NavigationRoot, setFieldCommandHandler } from "./api/components.js";
import { Surface } from "./reconciler.js";
import { raycastApi } from "./api/index.js";
import { configureSystem, runToastAction } from "./api/system.js";
defineModule("react", React);
defineModule("react/jsx-runtime", JSXRuntime);
defineModule("react/jsx-dev-runtime", JSXRuntime);
defineModule("@raycast/api", raycastApi);
// react-dom only appears in bundles defensively; make the import resolve and the calls explain.
defineModule("react-dom", {
render: () => {
throw new Error("react-dom is not available — Tinycast renders extensions natively.");
},
createPortal: (children) => children,
flushSync: (fn) => fn?.(),
version: React.version,
});
const sessions = new Map();
/// One running command. A view command mounts a React tree through `Surface`; a no-view command just
/// awaits its default export.
class Session {
constructor(id, host) {
this.id = id;
this.host = host;
this.surface = null;
this.navigationDepth = 1;
this.navigation = {};
}
mountView(element) {
this.surface = new Surface(
(tree) => this.host.render(this.id, JSON.stringify(tree)),
(error) => this.fail(error),
);
this.surface.render(
createElement(NavigationRoot, {
initial: element,
controls: this.navigation,
onStackChange: (depth) => {
this.navigationDepth = depth;
this.host.navigationDepthChanged(this.id, depth);
},
}),
);
}
fail(error) {
this.host.failed(this.id, describeError(error));
}
unmount() {
this.surface?.unmount();
this.surface = null;
}
}
const hostCalls = {
render: (sessionId, json) => globalThis.__tinycastHost.render(sessionId, json),
failed: (sessionId, message) => globalThis.__tinycastHost.failed(sessionId, message),
navigationDepthChanged: (sessionId, depth) =>
globalThis.__tinycastHost.navigationDepthChanged(sessionId, String(depth)),
finished: (sessionId) => globalThis.__tinycastHost.finished(sessionId),
};
setUncaughtHandler((error) => {
const message = describeError(error);
log("error", [message]);
// Attribute an unhandled rejection to the only running session when there is exactly one, so the
// palette can show it instead of failing silently.
if (sessions.size === 1) {
const [session] = sessions.values();
session.fail(error);
}
});
setFieldCommandHandler((command, fieldId) => {
globalThis.__tinycastHost.fieldCommand(String(command), String(fieldId ?? ""));
});
globalThis.__tinycast = {
/// Called once, before any command runs.
boot(configJson) {
const config = JSON.parse(configJson);
configureNodeShims(config.node ?? {});
configureSystem(config);
return "ok";
},
/// Load and start one command. `mode` is "view" or "no-view"; a view command's default export is a
/// component, a no-view command's is an async function.
start(sessionId, code, filename, dirname, mode, contextJson) {
const context = JSON.parse(contextJson || "{}");
configureSystem(context);
const session = new Session(sessionId, hostCalls);
sessions.set(sessionId, session);
// Commands declaring `arguments` read `props.arguments.<name>` unguarded, so the bag always exists.
const launchProps = {
launchType: "userInitiated",
arguments: {},
...(context.launchProps ?? {}),
};
try {
const exports = evaluateCommonJS(code, filename, dirname);
const entry = exports?.default ?? exports;
if (mode === "view") {
if (typeof entry !== "function") {
throw new Error("A view command must default-export a React component.");
}
session.mountView(createElement(entry, launchProps));
} else {
if (typeof entry !== "function") {
throw new Error("A no-view command must default-export a function.");
}
Promise.resolve(entry(launchProps)).then(
() => hostCalls.finished(sessionId),
(error) => session.fail(error),
);
}
} catch (error) {
session.fail(error);
}
return "ok";
},
/// Route a UI event back to the callback it came from.
dispatch(sessionId, handlerId, argsJson) {
const session = sessions.get(sessionId);
if (!session?.surface) return "0";
try {
const args = JSON.parse(argsJson || "[]").map(reviveArg);
return session.surface.dispatch(handlerId, args) ? "1" : "0";
} catch (error) {
session.fail(error);
return "0";
}
},
/// Escape / the back chevron in a pushed screen.
popNavigation(sessionId) {
const session = sessions.get(sessionId);
if (!session?.surface || session.navigationDepth <= 1) return "0";
session.navigation.pop?.();
return "1";
},
settle,
fireTimer,
runToastAction,
stop(sessionId) {
sessions.get(sessionId)?.unmount();
sessions.delete(sessionId);
return "ok";
},
};
/// `{"$date": …}` is how a Form.DatePicker value crosses back from Swift.
function reviveArg(value) {
if (value && typeof value === "object") {
if (typeof value.$date === "string") return new Date(value.$date);
if (Array.isArray(value)) return value.map(reviveArg);
}
return value;
}
+34
View File
@@ -0,0 +1,34 @@
// The `require` an extension bundle sees. Bundles are single-file CJS with `react`,
// `react/jsx-runtime`, `@raycast/api` and the Node builtins left external — exactly the set below.
import { nodeModules } from "./node-shims.js";
const registry = new Map();
export function defineModule(name, exports) {
registry.set(name, exports);
}
for (const name of Object.keys(nodeModules)) defineModule(name, nodeModules[name]);
export function requireModule(name) {
const key = String(name);
if (registry.has(key)) return registry.get(key);
// Deep imports into a provided package (`react-dom/client`) resolve to the package itself rather
// than exploding, which is enough for the handful of bundles that reference them defensively.
const root = key.startsWith("@") ? key.split("/").slice(0, 2).join("/") : key.split("/")[0];
if (registry.has(root)) return registry.get(root);
throw new Error(
`Cannot find module '${key}'. Tinycast provides React, @raycast/api and a subset of Node builtins — see docs/extensions.md.`,
);
}
/// Evaluate one CJS bundle. `filename`/`dirname` matter: extensions resolve bundled assets relative
/// to `__dirname`, and `environment.assetsPath` points at the same directory.
export function evaluateCommonJS(code, filename, dirname) {
const module = { exports: {}, id: filename, filename, loaded: false, children: [], paths: [] };
const factory = globalThis.__tinycastCompile(code, filename);
factory(module.exports, requireModule, module, filename, dirname);
module.loaded = true;
return module.exports;
}
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
// Globals JavaScriptCore doesn't ship that extension bundles (and React's scheduler) assume.
import { hostCall, hostRaw, log } from "./host.js";
const g = globalThis;
if (!g.console) g.console = {};
for (const level of ["log", "info", "warn", "error", "debug", "trace"]) {
g.console[level] = (...args) => log(level === "debug" || level === "trace" ? "log" : level, args);
}
// Extensions occasionally call these; make them harmless instead of a TypeError.
for (const noop of ["group", "groupEnd", "table", "time", "timeEnd", "dir", "assert", "count"]) {
if (!g.console[noop]) g.console[noop] = () => {};
}
if (!g.performance) g.performance = { now: () => Date.now() };
else if (!g.performance.now) g.performance.now = () => Date.now();
// ─── Timers ─────────────────────────────────────────────────────────
// Swift owns the clock: it schedules on its runloop and calls back into `fireTimer`.
let nextTimerId = 1;
const timers = new Map();
function schedule(callback, delay, repeats, args) {
if (typeof callback !== "function") return 0;
const id = nextTimerId++;
timers.set(id, { callback, args, repeats });
hostRaw.startTimer(String(id), Math.max(0, Number(delay) || 0), repeats);
return id;
}
function unschedule(id) {
const key = Number(id);
if (!timers.has(key)) return;
timers.delete(key);
hostRaw.clearTimer(String(key));
}
export function fireTimer(id) {
const key = Number(id);
const timer = timers.get(key);
if (!timer) return;
if (!timer.repeats) timers.delete(key);
try {
timer.callback(...timer.args);
} catch (error) {
reportUncaught(error);
}
}
g.setTimeout = (cb, delay, ...args) => schedule(cb, delay, false, args);
g.setInterval = (cb, delay, ...args) => schedule(cb, delay, true, args);
g.clearTimeout = unschedule;
g.clearInterval = unschedule;
// Node's immediate/tick APIs, used by bundled deps.
g.setImmediate = (cb, ...args) => schedule(cb, 0, false, args);
g.clearImmediate = unschedule;
if (!g.queueMicrotask) {
const resolved = Promise.resolve();
g.queueMicrotask = (cb) => {
resolved.then(cb).catch(reportUncaught);
};
}
// ─── Error reporting ────────────────────────────────────────────────
let uncaughtSink = (error) => log("error", ["Uncaught:", error]);
export function setUncaughtHandler(handler) {
uncaughtSink = handler;
}
export function reportUncaught(error) {
try {
uncaughtSink(error);
} catch {
log("error", ["Uncaught (and the handler threw):", error]);
}
}
// ─── fetch ──────────────────────────────────────────────────────────
// Backed by URLSession on the Swift side. Bodies cross the bridge base64-encoded so binary
// responses survive; text/JSON go through the same path.
class TinycastHeaders {
constructor(init) {
this._map = new Map();
if (init instanceof TinycastHeaders) {
for (const [key, value] of init._map) this._map.set(key, value);
} else if (Array.isArray(init)) {
for (const [key, value] of init) this.append(key, value);
} else if (init && typeof init === "object") {
for (const key of Object.keys(init)) this.append(key, init[key]);
}
}
_key(name) {
return String(name).toLowerCase();
}
append(name, value) {
const key = this._key(name);
const existing = this._map.get(key);
this._map.set(key, existing === undefined ? String(value) : `${existing}, ${value}`);
}
set(name, value) {
this._map.set(this._key(name), String(value));
}
get(name) {
const value = this._map.get(this._key(name));
return value === undefined ? null : value;
}
has(name) {
return this._map.has(this._key(name));
}
delete(name) {
this._map.delete(this._key(name));
}
forEach(fn, thisArg) {
for (const [key, value] of this._map) fn.call(thisArg, value, key, this);
}
keys() {
return this._map.keys();
}
values() {
return this._map.values();
}
entries() {
return this._map.entries();
}
[Symbol.iterator]() {
return this._map.entries();
}
toJSON() {
return Object.fromEntries(this._map);
}
}
class TinycastResponse {
constructor({ status, statusText, headers, url, bodyBase64 }) {
this.status = status;
this.statusText = statusText || "";
this.headers = new TinycastHeaders(headers);
this.url = url || "";
this.ok = status >= 200 && status < 300;
this.redirected = false;
this.type = "basic";
this._bodyBase64 = bodyBase64 || "";
this.bodyUsed = false;
}
clone() {
return new TinycastResponse({
status: this.status,
statusText: this.statusText,
headers: this.headers.toJSON(),
url: this.url,
bodyBase64: this._bodyBase64,
});
}
async arrayBuffer() {
this.bodyUsed = true;
return base64ToBytes(this._bodyBase64).buffer;
}
async bytes() {
this.bodyUsed = true;
return base64ToBytes(this._bodyBase64);
}
async text() {
this.bodyUsed = true;
return utf8Decode(base64ToBytes(this._bodyBase64));
}
async json() {
return JSON.parse(await this.text());
}
async blob() {
throw new Error("Response.blob() is not supported in Tinycast extensions.");
}
}
class TinycastRequest {
constructor(input, init = {}) {
if (input instanceof TinycastRequest) {
this.url = input.url;
this.method = init.method || input.method;
this.headers = new TinycastHeaders(init.headers || input.headers);
this.body = init.body !== undefined ? init.body : input.body;
} else {
this.url = String(input);
this.method = (init.method || "GET").toUpperCase();
this.headers = new TinycastHeaders(init.headers);
this.body = init.body;
}
this.signal = init.signal;
}
}
async function tinycastFetch(input, init = {}) {
const request = input instanceof TinycastRequest ? input : new TinycastRequest(input, init);
const signal = init.signal || request.signal;
if (signal?.aborted) throw abortError();
const raw = await hostCall("fetch", "request", [
{
url: request.url,
method: request.method,
headers: request.headers.toJSON(),
bodyBase64: encodeBody(request.body),
},
]);
if (signal?.aborted) throw abortError();
return new TinycastResponse(raw);
}
function abortError() {
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
// `AbortSignal.timeout` aborts with TimeoutError, not AbortError — callers branch on the name.
function timeoutError() {
const error = new Error("The operation timed out.");
error.name = "TimeoutError";
return error;
}
function encodeBody(body) {
if (body === undefined || body === null) return null;
if (typeof body === "string") return bytesToBase64(utf8Encode(body));
if (body instanceof Uint8Array) return bytesToBase64(body);
if (body instanceof ArrayBuffer) return bytesToBase64(new Uint8Array(body));
if (g.Buffer && g.Buffer.isBuffer?.(body)) return bytesToBase64(new Uint8Array(body));
if (body instanceof URLSearchParams) return bytesToBase64(utf8Encode(body.toString()));
return bytesToBase64(utf8Encode(String(body)));
}
if (!g.fetch) {
g.fetch = tinycastFetch;
g.Headers = TinycastHeaders;
g.Response = TinycastResponse;
g.Request = TinycastRequest;
}
// ─── AbortController ────────────────────────────────────────────────
if (!g.AbortController) {
class AbortSignalShim {
constructor() {
this.aborted = false;
this.reason = undefined;
this._listeners = new Set();
this.onabort = null;
}
addEventListener(type, listener) {
if (type === "abort") this._listeners.add(listener);
}
removeEventListener(type, listener) {
if (type === "abort") this._listeners.delete(listener);
}
throwIfAborted() {
if (this.aborted) throw this.reason ?? abortError();
}
// The statics, not just the instance shape: a signal missing them still reads as supported at
// the type level, so an extension calls `AbortSignal.timeout` and gets "is not a function".
static abort(reason) {
const signal = new AbortSignalShim();
signal._fire(reason);
return signal;
}
static timeout(ms) {
const signal = new AbortSignalShim();
setTimeout(() => signal._fire(timeoutError()), ms);
return signal;
}
static any(signals) {
const merged = new AbortSignalShim();
for (const source of signals) {
if (source?.aborted) {
merged._fire(source.reason);
break;
}
source?.addEventListener("abort", () => merged._fire(source.reason));
}
return merged;
}
_fire(reason) {
if (this.aborted) return;
this.aborted = true;
this.reason = reason ?? abortError();
const event = { type: "abort", target: this };
if (typeof this.onabort === "function") this.onabort(event);
for (const listener of this._listeners) {
try {
listener(event);
} catch (error) {
reportUncaught(error);
}
}
}
}
g.AbortSignal = AbortSignalShim;
g.AbortController = class {
constructor() {
this.signal = new AbortSignalShim();
}
abort(reason) {
this.signal._fire(reason);
}
};
}
// ─── Text encoding / base64 ─────────────────────────────────────────
export function utf8Encode(text) {
const out = [];
for (let i = 0; i < text.length; i++) {
let code = text.codePointAt(i);
if (code > 0xffff) i++;
if (code < 0x80) out.push(code);
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
else if (code < 0x10000)
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
else
out.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
}
return new Uint8Array(out);
}
export function utf8Decode(bytes) {
let out = "";
for (let i = 0; i < bytes.length; ) {
const byte = bytes[i++];
if (byte < 0x80) out += String.fromCharCode(byte);
else if (byte < 0xe0) out += String.fromCharCode(((byte & 0x1f) << 6) | (bytes[i++] & 0x3f));
else if (byte < 0xf0)
out += String.fromCharCode(
((byte & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f),
);
else {
const code =
((byte & 0x07) << 18) |
((bytes[i++] & 0x3f) << 12) |
((bytes[i++] & 0x3f) << 6) |
(bytes[i++] & 0x3f);
out += String.fromCodePoint(code);
}
}
return out;
}
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
export function bytesToBase64(bytes) {
let out = "";
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = bytes[i + 1];
const c = bytes[i + 2];
out += B64[a >> 2];
out += B64[((a & 3) << 4) | ((b ?? 0) >> 4)];
out += b === undefined ? "=" : B64[((b & 15) << 2) | ((c ?? 0) >> 6)];
out += c === undefined ? "=" : B64[c & 63];
}
return out;
}
export function base64ToBytes(text) {
const clean = String(text).replace(/[^A-Za-z0-9+/]/g, "");
const out = new Uint8Array((clean.length * 3) >> 2);
let outIndex = 0;
for (let i = 0; i < clean.length; i += 4) {
const a = B64.indexOf(clean[i]);
const b = B64.indexOf(clean[i + 1]);
const c = B64.indexOf(clean[i + 2]);
const d = B64.indexOf(clean[i + 3]);
out[outIndex++] = (a << 2) | (b >> 4);
if (c >= 0) out[outIndex++] = ((b & 15) << 4) | (c >> 2);
if (d >= 0) out[outIndex++] = ((c & 3) << 6) | d;
}
return out.subarray(0, outIndex);
}
if (!g.atob) g.atob = (text) => String.fromCharCode(...base64ToBytes(text));
if (!g.btoa) {
g.btoa = (text) => {
const bytes = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff;
return bytesToBase64(bytes);
};
}
if (!g.structuredClone) {
g.structuredClone = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
}
// JavaScriptCore has no TextEncoder/TextDecoder (they're WebCore APIs), and bundled deps reach for
// them freely. Only the UTF-8 path is real; an exotic requested encoding decodes as UTF-8.
if (!g.TextEncoder) {
g.TextEncoder = class TextEncoder {
get encoding() {
return "utf-8";
}
encode(text = "") {
return utf8Encode(String(text));
}
encodeInto(text, target) {
const bytes = utf8Encode(String(text));
const written = Math.min(bytes.length, target.length);
target.set(bytes.subarray(0, written));
return { read: text.length, written };
}
};
}
if (!g.TextDecoder) {
g.TextDecoder = class TextDecoder {
constructor(encoding = "utf-8", options = {}) {
this.encoding = String(encoding).toLowerCase();
this.fatal = !!options.fatal;
this.ignoreBOM = !!options.ignoreBOM;
}
decode(input) {
if (input === undefined) return "";
let bytes;
if (input instanceof Uint8Array) bytes = input;
else if (input instanceof ArrayBuffer) bytes = new Uint8Array(input);
else if (ArrayBuffer.isView(input))
bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
else throw new TypeError("TextDecoder.decode: unsupported input");
if (!this.ignoreBOM && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
bytes = bytes.subarray(3);
}
return utf8Decode(bytes);
}
};
}
+183
View File
@@ -0,0 +1,183 @@
// Node's `punycode` module. Extension bundles pull it in transitively through whatwg-url / tr46 (and
// so through almost every HTTP client), which makes it the single most load-bearing Node builtin
// after `path`. RFC 3492 / RFC 5891, ported from the reference implementation.
const BASE = 36;
const T_MIN = 1;
const T_MAX = 26;
const SKEW = 38;
const DAMP = 700;
const INITIAL_BIAS = 72;
const INITIAL_N = 128;
const DELIMITER = "-";
const MAX_INT = 0x7fffffff;
function error(type) {
throw new RangeError(
{ overflow: "Overflow: input needs wider integers to process", "not-basic": "Illegal input >= 0x80 (not a basic code point)", "invalid-input": "Invalid input" }[type],
);
}
function map(array, fn) {
return array.map(fn);
}
function mapDomain(domain, fn) {
const parts = domain.split("@");
let result = "";
if (parts.length > 1) {
result = parts[0] + "@";
domain = parts[1];
}
domain = domain.replace(/[.。.。]/g, ".");
return result + map(domain.split("."), fn).join(".");
}
export function ucs2decode(string) {
const output = [];
let counter = 0;
while (counter < string.length) {
const value = string.charCodeAt(counter++);
if (value >= 0xd800 && value <= 0xdbff && counter < string.length) {
const extra = string.charCodeAt(counter++);
if ((extra & 0xfc00) === 0xdc00) {
output.push(((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000);
} else {
output.push(value);
counter--;
}
} else {
output.push(value);
}
}
return output;
}
export function ucs2encode(codePoints) {
return String.fromCodePoint(...codePoints);
}
function basicToDigit(codePoint) {
if (codePoint >= 0x30 && codePoint < 0x3a) return 26 + (codePoint - 0x30);
if (codePoint >= 0x41 && codePoint < 0x5b) return codePoint - 0x41;
if (codePoint >= 0x61 && codePoint < 0x7b) return codePoint - 0x61;
return BASE;
}
function digitToBasic(digit, flag) {
return digit + 22 + (digit < 26 ? 75 : 0) - (flag !== 0 ? 1 : 0) * 32;
}
function adapt(delta, numPoints, firstTime) {
let k = 0;
delta = firstTime ? Math.floor(delta / DAMP) : delta >> 1;
delta += Math.floor(delta / numPoints);
for (; delta > ((BASE - T_MIN) * T_MAX) >> 1; k += BASE) {
delta = Math.floor(delta / (BASE - T_MIN));
}
return Math.floor(k + ((BASE - T_MIN + 1) * delta) / (delta + SKEW));
}
export function decode(input) {
const output = [];
const inputLength = input.length;
let i = 0;
let n = INITIAL_N;
let bias = INITIAL_BIAS;
let basic = input.lastIndexOf(DELIMITER);
if (basic < 0) basic = 0;
for (let j = 0; j < basic; ++j) {
if (input.charCodeAt(j) >= 0x80) error("not-basic");
output.push(input.charCodeAt(j));
}
for (let index = basic > 0 ? basic + 1 : 0; index < inputLength; ) {
const oldi = i;
for (let w = 1, k = BASE; ; k += BASE) {
if (index >= inputLength) error("invalid-input");
const digit = basicToDigit(input.charCodeAt(index++));
if (digit >= BASE) error("invalid-input");
if (digit > Math.floor((MAX_INT - i) / w)) error("overflow");
i += digit * w;
const t = k <= bias ? T_MIN : k >= bias + T_MAX ? T_MAX : k - bias;
if (digit < t) break;
if (w > Math.floor(MAX_INT / (BASE - t))) error("overflow");
w *= BASE - t;
}
const out = output.length + 1;
bias = adapt(i - oldi, out, oldi === 0);
if (Math.floor(i / out) > MAX_INT - n) error("overflow");
n += Math.floor(i / out);
i %= out;
output.splice(i++, 0, n);
}
return String.fromCodePoint(...output);
}
export function encode(input) {
const output = [];
const decoded = ucs2decode(String(input));
const inputLength = decoded.length;
let n = INITIAL_N;
let delta = 0;
let bias = INITIAL_BIAS;
for (const codePoint of decoded) if (codePoint < 0x80) output.push(String.fromCharCode(codePoint));
const basicLength = output.length;
let handledCPCount = basicLength;
if (basicLength) output.push(DELIMITER);
while (handledCPCount < inputLength) {
let m = MAX_INT;
for (const codePoint of decoded) if (codePoint >= n && codePoint < m) m = codePoint;
const handledCPCountPlusOne = handledCPCount + 1;
if (m - n > Math.floor((MAX_INT - delta) / handledCPCountPlusOne)) error("overflow");
delta += (m - n) * handledCPCountPlusOne;
n = m;
for (const codePoint of decoded) {
if (codePoint < n && ++delta > MAX_INT) error("overflow");
if (codePoint !== n) continue;
let q = delta;
for (let k = BASE; ; k += BASE) {
const t = k <= bias ? T_MIN : k >= bias + T_MAX ? T_MAX : k - bias;
if (q < t) break;
const qMinusT = q - t;
const baseMinusT = BASE - t;
output.push(String.fromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0)));
q = Math.floor(qMinusT / baseMinusT);
}
output.push(String.fromCharCode(digitToBasic(q, 0)));
bias = adapt(delta, handledCPCountPlusOne, handledCPCount === basicLength);
delta = 0;
++handledCPCount;
}
++delta;
++n;
}
return output.join("");
}
export function toUnicode(domain) {
return mapDomain(domain, (part) =>
/^xn--/i.test(part) ? decode(part.slice(4).toLowerCase()) : part,
);
}
export function toASCII(domain) {
return mapDomain(domain, (part) => (/[^\0-\x7E]/.test(part) ? "xn--" + encode(part) : part));
}
export const punycode = {
version: "2.3.1",
ucs2: { decode: ucs2decode, encode: ucs2encode },
decode,
encode,
toASCII,
toUnicode,
};
+238
View File
@@ -0,0 +1,238 @@
// A React host renderer whose "DOM" is a plain JSON tree that Swift renders natively.
//
// Two conventions make the Raycast component surface expressible:
// * `__slot` instances fold their children into the parent's props under a name, so element-valued
// props (`actions={<ActionPanel/>}`, `detail={<List.Item.Detail/>}`) reach Swift as structure.
// * function props become `{"$fn": "<nodeId>:<propName>"}` handles; Swift dispatches them back by id.
import Reconciler from "react-reconciler";
import { DefaultEventPriority } from "react-reconciler/constants";
import { reportUncaught } from "./polyfills.js";
export const SLOT_TYPE = "__slot";
let nextInstanceId = 1;
function createNode(type, props) {
return { id: nextInstanceId++, type, props: props ?? {}, children: [] };
}
const hostConfig = {
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
isPrimaryRenderer: true,
noTimeout: -1,
warnsIfNotActing: false,
getRootHostContext: () => null,
getChildHostContext: (parentContext) => parentContext,
getPublicInstance: (instance) => instance,
createInstance: (type, props) => createNode(type, props),
createTextInstance: (text) => ({ id: nextInstanceId++, type: "#text", text, children: [] }),
appendInitialChild: (parent, child) => parent.children.push(child),
appendChild: (parent, child) => {
detach(parent, child);
parent.children.push(child);
},
appendChildToContainer: (container, child) => {
detach(container, child);
container.children.push(child);
},
insertBefore: (parent, child, before) => insert(parent, child, before),
insertInContainerBefore: (container, child, before) => insert(container, child, before),
removeChild: (parent, child) => detach(parent, child),
removeChildFromContainer: (container, child) => detach(container, child),
clearContainer: (container) => {
container.children.length = 0;
},
finalizeInitialChildren: () => false,
shouldSetTextContent: () => false,
commitUpdate: (instance, type, prevProps, nextProps) => {
instance.props = nextProps;
},
commitTextUpdate: (instance, prev, next) => {
instance.text = next;
},
commitMount: () => {},
resetTextContent: () => {},
prepareForCommit: () => null,
resetAfterCommit: (container) => container.onCommit(),
preparePortalMount: () => {},
detachDeletedInstance: () => {},
scheduleTimeout: (fn, delay) => setTimeout(fn, delay),
cancelTimeout: (handle) => clearTimeout(handle),
// React 19 update-priority hooks: a single-surface renderer has no event lanes to distinguish.
getCurrentUpdatePriority: () => DefaultEventPriority,
setCurrentUpdatePriority: () => {},
resolveUpdatePriority: () => DefaultEventPriority,
getCurrentEventPriority: () => DefaultEventPriority,
shouldAttemptEagerTransition: () => false,
requestPostPaintCallback: () => {},
trackSchedulerEvent: () => {},
resolveEventType: () => null,
resolveEventTimeStamp: () => -1.1,
maySuspendCommit: () => false,
preloadInstance: () => true,
startSuspendingCommit: () => {},
suspendInstance: () => {},
waitForCommitToBeReady: () => null,
NotPendingTransition: null,
HostTransitionContext: {
$$typeof: Symbol.for("react.context"),
Provider: null,
Consumer: null,
_currentValue: null,
_currentValue2: null,
_threadCount: 0,
},
resetFormInstance: () => {},
bindToConsole: (method, args) => () => method.apply(console, args),
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
prepareScopeUpdate: () => {},
getInstanceFromScope: () => null,
getInstanceFromNode: () => null,
};
function detach(parent, child) {
const index = parent.children.indexOf(child);
if (index >= 0) parent.children.splice(index, 1);
}
function insert(parent, child, before) {
detach(parent, child);
const index = parent.children.indexOf(before);
if (index < 0) parent.children.push(child);
else parent.children.splice(index, 0, child);
}
const reconciler = Reconciler(hostConfig);
/// One mounted command. `onTree` fires after every commit with the serialized tree; handler lookups
/// go through `handlers`, which is rebuilt on each serialization so a dispatch always hits the
/// callback from the newest render.
export class Surface {
constructor(onTree, onError) {
this.handlers = new Map();
this.onTree = onTree;
this.onError = onError;
this.container = { id: 0, type: "#root", props: {}, children: [], onCommit: () => this.flush() };
this.root = reconciler.createContainer(
this.container,
0, // LegacyRoot — extensions never opt into concurrent-only behaviour
null,
false,
null,
"tinycast",
(error) => this.onError(error),
(error) => this.onError(error),
(error) => this.onError(error),
null,
);
}
render(element) {
reconciler.updateContainer(element, this.root, null, null);
}
unmount() {
try {
reconciler.updateContainer(null, this.root, null, null);
} catch (error) {
reportUncaught(error);
}
this.handlers.clear();
}
flush() {
this.handlers.clear();
const children = this.container.children.map((child) => this.serialize(child)).filter(Boolean);
this.onTree({ children });
}
dispatch(handlerId, args) {
const handler = this.handlers.get(handlerId);
if (!handler) return false;
handler(...args);
return true;
}
serialize(node) {
if (node.type === "#text") return { type: "#text", text: String(node.text) };
const props = {};
for (const key of Object.keys(node.props)) {
if (key === "children") continue;
const value = this.encode(node.props[key], node.id, key);
if (value !== undefined) props[key] = value;
}
const children = [];
for (const child of node.children) {
// A slot child is structure for the parent, not a row of its own.
if (child.type === SLOT_TYPE) {
const name = child.props?.name;
if (!name) continue;
const contents = child.children.map((entry) => this.serialize(entry)).filter(Boolean);
if (contents.length) props[name] = child.props.single ? contents[0] : contents;
continue;
}
const serialized = this.serialize(child);
if (serialized) children.push(serialized);
}
return { id: node.id, type: node.type, props, children };
}
/// JSON-safe encoding of a prop value. Functions become dispatchable handles; Dates keep their
/// type so Swift can round-trip a Form.DatePicker value.
encode(value, nodeId, key) {
if (value === undefined || value === null) return value === null ? null : undefined;
switch (typeof value) {
case "function": {
const handlerId = `${nodeId}:${key}`;
this.handlers.set(handlerId, value);
return { $fn: handlerId };
}
case "string":
case "number":
case "boolean":
return value;
case "bigint":
return Number(value);
case "symbol":
return undefined;
default:
break;
}
if (value instanceof Date) return { $date: value.toISOString() };
if (Array.isArray(value)) {
return value.map((item, index) => this.encode(item, nodeId, `${key}.${index}`) ?? null);
}
// A React element that reached us as a plain prop (rather than through a slot) can't be
// rendered; drop it instead of serializing React internals.
if (value.$$typeof) return undefined;
const out = {};
for (const name of Object.keys(value)) {
const encoded = this.encode(value[name], nodeId, `${key}.${name}`);
if (encoded !== undefined) out[name] = encoded;
}
return out;
}
}
export function flushSync(fn) {
return reconciler.flushSyncWork ? reconciler.flushSyncWork(fn) : fn?.();
}
export function actBatch(fn) {
return fn();
}
+264
View File
@@ -0,0 +1,264 @@
// URL / URLSearchParams for JavaScriptCore, which ships neither. Covers the hierarchical http(s)-
// style URLs extensions build and parse; it is not a full WHATWG implementation (no IDNA, no
// percent-encoding normalization of the host).
const SPECIAL_PORTS = { "http:": "80", "https:": "443", "ws:": "80", "wss:": "443", "ftp:": "21" };
export class URLSearchParams {
constructor(init) {
this._pairs = [];
this._owner = null;
if (init === undefined || init === null) return;
if (init instanceof URLSearchParams) {
this._pairs = init._pairs.map((pair) => [pair[0], pair[1]]);
} else if (typeof init === "string") {
this._parse(init);
} else if (Array.isArray(init)) {
for (const pair of init) this._pairs.push([String(pair[0]), String(pair[1])]);
} else if (typeof init[Symbol.iterator] === "function") {
for (const pair of init) this._pairs.push([String(pair[0]), String(pair[1])]);
} else if (typeof init === "object") {
for (const key of Object.keys(init)) this._pairs.push([key, String(init[key])]);
}
}
_parse(text) {
const body = String(text).replace(/^[?]/, "");
if (!body) return;
for (const chunk of body.split("&")) {
if (!chunk) continue;
const eq = chunk.indexOf("=");
const key = eq < 0 ? chunk : chunk.slice(0, eq);
const value = eq < 0 ? "" : chunk.slice(eq + 1);
this._pairs.push([decodeComponent(key), decodeComponent(value)]);
}
}
_changed() {
if (this._owner) this._owner._syncSearchFromParams();
}
append(key, value) {
this._pairs.push([String(key), String(value)]);
this._changed();
}
delete(key, value) {
const name = String(key);
this._pairs = this._pairs.filter(
(pair) => pair[0] !== name || (value !== undefined && pair[1] !== String(value)),
);
this._changed();
}
get(key) {
const hit = this._pairs.find((pair) => pair[0] === String(key));
return hit ? hit[1] : null;
}
getAll(key) {
return this._pairs.filter((pair) => pair[0] === String(key)).map((pair) => pair[1]);
}
has(key, value) {
const name = String(key);
return this._pairs.some(
(pair) => pair[0] === name && (value === undefined || pair[1] === String(value)),
);
}
set(key, value) {
const name = String(key);
const index = this._pairs.findIndex((pair) => pair[0] === name);
if (index < 0) {
this._pairs.push([name, String(value)]);
} else {
this._pairs[index] = [name, String(value)];
this._pairs = this._pairs.filter((pair, i) => i <= index || pair[0] !== name);
}
this._changed();
}
sort() {
this._pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
this._changed();
}
get size() {
return this._pairs.length;
}
forEach(fn, thisArg) {
for (const [key, value] of this._pairs.slice()) fn.call(thisArg, value, key, this);
}
*keys() {
for (const pair of this._pairs.slice()) yield pair[0];
}
*values() {
for (const pair of this._pairs.slice()) yield pair[1];
}
*entries() {
for (const pair of this._pairs.slice()) yield [pair[0], pair[1]];
}
[Symbol.iterator]() {
return this.entries();
}
toString() {
return this._pairs
.map(([key, value]) => `${encodeComponent(key)}=${encodeComponent(value)}`)
.join("&");
}
}
function encodeComponent(text) {
return encodeURIComponent(text).replace(/%20/g, "+").replace(/[!'()~]/g, (c) =>
"%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}
function decodeComponent(text) {
try {
return decodeURIComponent(String(text).replace(/\+/g, " "));
} catch {
return String(text);
}
}
const URL_RE = /^([A-Za-z][A-Za-z0-9+.-]*:)(\/\/)?([^/?#]*)?([^?#]*)(\?[^#]*)?(#.*)?$/;
export class URL {
constructor(input, base) {
let text = String(input).trim();
if (base !== undefined && !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(text)) {
text = resolveRelative(String(base), text);
}
const match = URL_RE.exec(text);
if (!match) throw new TypeError(`Invalid URL: ${input}`);
this.protocol = match[1].toLowerCase();
const authority = match[2] ? match[3] || "" : "";
this.username = "";
this.password = "";
let hostPort = authority;
const at = authority.lastIndexOf("@");
if (at >= 0) {
const credentials = authority.slice(0, at);
hostPort = authority.slice(at + 1);
const colon = credentials.indexOf(":");
this.username = colon < 0 ? credentials : credentials.slice(0, colon);
this.password = colon < 0 ? "" : credentials.slice(colon + 1);
}
// IPv6 literals keep their brackets and must not be split on their inner colons.
if (hostPort.startsWith("[")) {
const close = hostPort.indexOf("]");
this.hostname = hostPort.slice(0, close + 1);
this.port = hostPort.slice(close + 1).replace(/^:/, "");
} else {
const colon = hostPort.lastIndexOf(":");
this.hostname = (colon < 0 ? hostPort : hostPort.slice(0, colon)).toLowerCase();
this.port = colon < 0 ? "" : hostPort.slice(colon + 1);
}
if (this.port && SPECIAL_PORTS[this.protocol] === this.port) this.port = "";
let path = match[4] || "";
if (match[2] && !path.startsWith("/")) path = "/" + path;
this.pathname = match[2] ? normalizePath(path) || "/" : path;
this.hash = match[6] || "";
this._search = match[5] || "";
this.searchParams = new URLSearchParams(this._search);
this.searchParams._owner = this;
}
get search() {
return this._search;
}
set search(value) {
const text = String(value);
this._search = !text || text === "?" ? "" : text.startsWith("?") ? text : "?" + text;
this.searchParams._owner = null;
this.searchParams = new URLSearchParams(this._search);
this.searchParams._owner = this;
}
_syncSearchFromParams() {
const text = this.searchParams.toString();
this._search = text ? "?" + text : "";
}
get host() {
return this.port ? `${this.hostname}:${this.port}` : this.hostname;
}
set host(value) {
const text = String(value);
const colon = text.lastIndexOf(":");
if (colon > 0 && !text.endsWith("]")) {
this.hostname = text.slice(0, colon).toLowerCase();
this.port = text.slice(colon + 1);
} else {
this.hostname = text.toLowerCase();
this.port = "";
}
}
get origin() {
return this.hostname ? `${this.protocol}//${this.host}` : "null";
}
get href() {
let out = this.protocol;
if (this.hostname || this.protocol === "file:") {
out += "//";
if (this.username) {
out += this.username;
if (this.password) out += ":" + this.password;
out += "@";
}
out += this.host;
}
return out + this.pathname + this._search + this.hash;
}
set href(value) {
const replacement = new URL(value);
for (const key of ["protocol", "username", "password", "hostname", "port", "pathname", "hash"]) {
this[key] = replacement[key];
}
this.search = replacement.search;
}
toString() {
return this.href;
}
toJSON() {
return this.href;
}
static canParse(input, base) {
try {
new URL(input, base);
return true;
} catch {
return false;
}
}
}
function resolveRelative(base, relative) {
const parsed = new URL(base);
if (relative.startsWith("//")) return parsed.protocol + relative;
if (relative.startsWith("/")) return `${parsed.protocol}//${parsed.host}${relative}`;
if (relative.startsWith("?")) return `${parsed.protocol}//${parsed.host}${parsed.pathname}${relative}`;
if (relative.startsWith("#")) {
return `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}${relative}`;
}
const dir = parsed.pathname.replace(/[^/]*$/, "");
return `${parsed.protocol}//${parsed.host}${normalizePath(dir + relative)}`;
}
function normalizePath(path) {
const leadingSlash = path.startsWith("/");
const trailingSlash = path.endsWith("/") && path.length > 1;
const out = [];
for (const segment of path.split("/")) {
if (segment === "" || segment === ".") continue;
if (segment === "..") out.pop();
else out.push(segment);
}
let joined = out.join("/");
if (leadingSlash) joined = "/" + joined;
if (trailingSlash && !joined.endsWith("/")) joined += "/";
return joined;
}
if (!globalThis.URL) globalThis.URL = URL;
if (!globalThis.URLSearchParams) globalThis.URLSearchParams = URLSearchParams;
+376
View File
@@ -0,0 +1,376 @@
// Drives the built runtime against a real extension bundle inside a bare `vm` context — the closest
// thing to JavaScriptCore that Node offers (no console, no timers, no URL, no Node globals).
//
// node test.mjs # the built-in fixtures
// node test.mjs <extension-dir> <command-name> # any prebuilt Raycast extension
//
// Prints the render tree the Swift side would receive.
import { createContext, runInContext } from "node:vm";
import { readFileSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { createHash, randomUUID, randomBytes, createHmac } from "node:crypto";
import { homedir, tmpdir } from "node:os";
import * as fs from "node:fs";
import * as zlib from "node:zlib";
const runtime = readFileSync(resolve("../../Tinycast/Resources/RaycastRuntime.generated.js"), "utf8");
export function createHarness({ onRender, onFail, verbose = false } = {}) {
const context = createContext({});
const timers = new Map();
const state = { trees: [], failures: [], logs: [], finished: false, hostCalls: [] };
const host = {
log(level, message) {
state.logs.push(`[${level}] ${message}`);
if (verbose) console.log(` [${level}] ${message}`);
},
render(sessionId, json) {
const tree = JSON.parse(json);
state.trees.push(tree);
onRender?.(tree);
},
failed(sessionId, message) {
state.failures.push(message);
onFail?.(message);
},
navigationDepthChanged(sessionId, depth) {
state.navigationDepth = Number(depth);
},
finished() {
state.finished = true;
},
fieldCommand() {},
startTimer(id, ms, repeats) {
const fire = () => runInContext(`__tinycast.fireTimer(${JSON.stringify(id)})`, context);
timers.set(id, repeats ? setInterval(fire, Math.max(ms, 1)) : setTimeout(fire, ms));
},
clearTimer(id) {
const handle = timers.get(id);
if (handle) {
clearTimeout(handle);
clearInterval(handle);
timers.delete(id);
}
},
invoke(callId, api, method, argsJson) {
state.hostCalls.push(`${api}.${method}`);
Promise.resolve()
.then(() => stubHostCall(api, method, JSON.parse(argsJson)))
.then(
(value) => settle(callId, true, value),
(error) => settle(callId, false, String(error?.message ?? error)),
);
},
invokeSync(api, method, argsJson) {
try {
return JSON.stringify({ ok: true, value: syncHostCall(api, method, JSON.parse(argsJson)) });
} catch (error) {
return JSON.stringify({ ok: false, error: String(error?.message ?? error), code: error?.code });
}
},
};
function settle(callId, ok, value) {
runInContext(
`__tinycast.settle(${JSON.stringify(String(callId))}, ${ok}, ${JSON.stringify(value === undefined ? "" : JSON.stringify(value))})`,
context,
);
}
context.__tinycastHost = host;
// Mirrors what Swift installs: compile the extension's CJS body in global scope.
context.__tinycastCompile = (code, filename) =>
runInContext(
`(function (exports, require, module, __filename, __dirname) {\n${code}\n})`,
context,
{ filename },
);
runInContext(runtime, context, { filename: "RaycastRuntime.generated.js" });
return {
context,
state,
call(expression) {
return runInContext(expression, context);
},
boot(config) {
return runInContext(`__tinycast.boot(${JSON.stringify(JSON.stringify(config))})`, context);
},
start(sessionId, code, filename, dirname, mode, ctx) {
return runInContext(
`__tinycast.start(${JSON.stringify(sessionId)}, ${JSON.stringify(code)}, ${JSON.stringify(filename)}, ${JSON.stringify(dirname)}, ${JSON.stringify(mode)}, ${JSON.stringify(JSON.stringify(ctx))})`,
context,
);
},
dispatch(sessionId, handlerId, args = []) {
return runInContext(
`__tinycast.dispatch(${JSON.stringify(sessionId)}, ${JSON.stringify(handlerId)}, ${JSON.stringify(JSON.stringify(args))})`,
context,
);
},
stop(sessionId) {
runInContext(`__tinycast.stop(${JSON.stringify(sessionId)})`, context);
for (const id of [...timers.keys()]) host.clearTimer(id);
},
};
}
function syncHostCall(api, method, args) {
switch (`${api}.${method}`) {
case "fs.readFile":
return fs.readFileSync(args[0]).toString("base64");
case "fs.writeFile":
fs[args[2] ? "appendFileSync" : "writeFileSync"](args[0], Buffer.from(args[1], "base64"));
return null;
case "fs.exists":
return fs.existsSync(args[0]);
case "fs.stat": {
const stat = args[1] ? fs.lstatSync(args[0]) : fs.statSync(args[0]);
return {
size: stat.size,
mode: stat.mode,
mtimeMs: stat.mtimeMs,
atimeMs: stat.atimeMs,
ctimeMs: stat.ctimeMs,
birthtimeMs: stat.birthtimeMs,
_isFile: stat.isFile(),
_isDirectory: stat.isDirectory(),
_isSymbolicLink: stat.isSymbolicLink(),
};
}
case "fs.readdir":
return fs.readdirSync(args[0], { withFileTypes: true }).map((entry) => ({
name: entry.name,
parentPath: args[0],
_isFile: entry.isFile(),
_isDirectory: entry.isDirectory(),
_isSymbolicLink: entry.isSymbolicLink(),
}));
case "fs.mkdir":
return fs.mkdirSync(args[0], { recursive: args[1] }) ?? null;
case "fs.realpath":
return fs.realpathSync(args[0]);
case "fs.mkdtemp":
return fs.mkdtempSync(args[0]);
case "fs.remove":
fs.rmSync(args[0], { recursive: args[1], force: args[2] });
return null;
case "fs.rename":
fs.renameSync(args[0], args[1]);
return null;
case "fs.copyFile":
fs.copyFileSync(args[0], args[1]);
return null;
case "crypto.uuid":
return randomUUID();
case "crypto.random":
return randomBytes(args[0]).toString("base64");
case "crypto.hash":
return createHash(args[0]).update(Buffer.from(args[1], "base64")).digest("base64");
case "crypto.hmac":
return createHmac(args[0], Buffer.from(args[2], "base64"))
.update(Buffer.from(args[1], "base64"))
.digest("base64");
case "proc.run": {
const spec = args[0];
try {
const stdout = spec.shell
? execFileSync("/bin/sh", ["-c", spec.command], { cwd: spec.cwd })
: execFileSync(spec.command, spec.args, { cwd: spec.cwd });
return { stdout: stdout.toString("base64"), stderr: "", status: 0 };
} catch (error) {
return {
stdout: Buffer.from(error.stdout ?? "").toString("base64"),
stderr: Buffer.from(error.stderr ?? "").toString("base64"),
status: error.status ?? 1,
};
}
}
case "zlib.gzip":
return zlib.gzipSync(Buffer.from(args[0], "base64")).toString("base64");
case "zlib.gunzip":
return zlib.gunzipSync(Buffer.from(args[0], "base64")).toString("base64");
case "zlib.deflate":
return zlib.deflateSync(Buffer.from(args[0], "base64")).toString("base64");
case "zlib.inflate":
return zlib.inflateSync(Buffer.from(args[0], "base64")).toString("base64");
case "zlib.deflateRaw":
return zlib.deflateRawSync(Buffer.from(args[0], "base64")).toString("base64");
case "zlib.inflateRaw":
return zlib.inflateRawSync(Buffer.from(args[0], "base64")).toString("base64");
default:
throw new Error(`harness: no sync stub for ${api}.${method}`);
}
}
async function stubHostCall(api, method, args) {
switch (`${api}.${method}`) {
case "storage.get":
return null;
case "storage.all":
return {};
case "clipboard.readText":
return "";
case "feedback.showToast":
return "toast-1";
case "system.frontmostApplication":
return { name: "Finder", path: "/System/Library/CoreServices/Finder.app", bundleId: "com.apple.finder" };
case "system.applications":
return [];
case "fetch.request": {
const spec = args[0];
const response = await fetch(spec.url, {
method: spec.method,
headers: spec.headers,
body: spec.bodyBase64 ? Buffer.from(spec.bodyBase64, "base64") : undefined,
});
const body = Buffer.from(await response.arrayBuffer());
return {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers),
url: response.url,
bodyBase64: body.toString("base64"),
};
}
case "proc.run":
return syncHostCall(api, method, args);
default:
if (["window", "feedback", "cache", "storage", "clipboard", "system"].includes(api)) return null;
throw new Error(`harness: no async stub for ${api}.${method}`);
}
}
export function bootConfig(overrides = {}) {
return {
node: {
arch: "arm64",
env: { HOME: homedir(), PATH: process.env.PATH },
cwd: homedir(),
homedir: homedir(),
tmpdir: tmpdir(),
username: "tester",
cpus: 8,
},
environment: {
extensionName: "fixture",
commandName: "fixture",
commandMode: "view",
assetsPath: "/tmp",
supportPath: "/tmp",
isDevelopment: false,
raycastVersion: "1.104.0",
textSize: "medium",
appearance: "dark",
launchType: "userInitiated",
},
preferences: {},
caches: {},
...overrides,
};
}
/// Compact one-line-per-node dump so a tree diff is readable in a terminal.
export function describeTree(tree, indent = "") {
const lines = [];
const walk = (node, depth) => {
if (node.type === "#text") {
lines.push(`${" ".repeat(depth)}"${node.text}"`);
return;
}
const props = Object.entries(node.props ?? {})
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${summarize(value)}`)
.join(" ");
lines.push(`${" ".repeat(depth)}<${node.type}${props ? " " + props : ""}>`);
for (const child of node.children ?? []) walk(child, depth + 1);
};
for (const child of tree.children ?? []) walk(child, 0);
return lines.map((line) => indent + line).join("\n");
}
function summarize(value) {
if (value && typeof value === "object" && value.$fn) return `fn(${value.$fn})`;
if (Array.isArray(value)) return `[${value.length}]`;
if (value && typeof value === "object") {
if (value.$date) return value.$date;
if (value.type) return `<${value.type}>`;
return "{…}";
}
return JSON.stringify(value);
}
// ─── CLI ────────────────────────────────────────────────────────────
if (import.meta.url === `file://${process.argv[1]}`) {
const [dir, command] = process.argv.slice(2);
if (dir) {
await runExtension(dir, command);
} else {
await runFixtures();
}
}
/// Mirrors the Swift-side resolution: a manifest default can be platform-keyed
/// (`{"macOS": "…", "Windows": "…"}`), and a checkbox with no default is false, not "".
export function preferenceDefault(pref) {
const raw = pref.default;
if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw.macOS ?? "";
if (raw !== undefined) return raw;
return pref.type === "checkbox" ? false : "";
}
async function runExtension(dir, commandName) {
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
const commands = manifest.commands ?? [];
const target = commandName ? commands.find((c) => c.name === commandName) : commands[0];
if (!target) throw new Error(`no command ${commandName ?? ""} in ${manifest.name}`);
const file = join(dir, `${target.name}.js`);
if (!existsSync(file)) throw new Error(`missing built bundle ${file}`);
console.log(`▶ ${manifest.title} — ${target.title} (${target.mode})`);
const harness = createHarness({ verbose: true });
harness.boot(
bootConfig({
environment: {
...bootConfig().environment,
extensionName: manifest.name,
commandName: target.name,
commandMode: target.mode,
assetsPath: join(dir, "assets"),
},
preferences: {
...Object.fromEntries(
[...(manifest.preferences ?? []), ...(target.preferences ?? [])].map((pref) => [
pref.name,
preferenceDefault(pref),
]),
),
// `EXT_TEST_PREFS={"version":"v8"}` stands in for what the user set in Settings — plenty of
// extensions branch on a preference that has no manifest default. Same knob as ext-test.
...JSON.parse(process.env.EXT_TEST_PREFS ?? "{}"),
},
}),
);
harness.start("s1", readFileSync(file, "utf8"), file, dir, target.mode === "view" ? "view" : "no-view", {});
await new Promise((resolve) => setTimeout(resolve, 1500));
if (harness.state.failures.length) {
console.log("\n✗ failures:");
for (const failure of harness.state.failures) console.log(failure);
}
const last = harness.state.trees.at(-1);
console.log(`\n${harness.state.trees.length} render(s), host calls: ${[...new Set(harness.state.hostCalls)].join(", ") || "none"}`);
if (last) console.log("\n" + describeTree(last));
harness.stop("s1");
process.exit(harness.state.failures.length ? 1 : 0);
}
async function runFixtures() {
const { runFixtures: run } = await import("./fixtures.mjs");
await run();
}
+35 -4
View File
@@ -73,7 +73,8 @@ run file-search-session-test Tinycast/Platform/Signposts.swift \
run ranking-test $L/SearchRelevance.swift $L/LauncherRankingStore.swift
run scopes-test $L/SearchScopes.swift
run calc-test Tinycast/Features/Calculator/Model/*.swift
run clipboard-test Tinycast/Features/Clipboard/Model/ClipboardStore.swift
run clipboard-test Tinycast/Features/Clipboard/Model/ClipboardStore.swift \
Tinycast/Features/Clipboard/Model/ClipboardFilter.swift
run emoji-test Tinycast/Features/Emoji/Model/EmojiCatalog.swift \
Tinycast/Features/Emoji/Model/EmojiGridGeometry.swift \
Tinycast/Features/Emoji/Model/EmojiData.generated.swift
@@ -85,14 +86,24 @@ run scroll-reveal-test Tinycast/DesignSystem/Scrolling/SelectionReveal.swift
run hover-arming-test Tinycast/Palette/HoverArming.swift \
Tinycast/Palette/PaletteState.swift \
Tinycast/Palette/PaletteMode.swift \
Tinycast/Features/Clipboard/Model/ClipboardStore.swift \
Tinycast/Features/Clipboard/Model/ClipboardFilter.swift \
Tinycast/Features/Quicklinks/Model/Quicklink.swift
run hotkey-test Tinycast/Features/HotKeys/Model/DoubleTapModifier.swift \
Tinycast/Features/HotKeys/Model/DoubleTapDetector.swift \
Tinycast/Features/HotKeys/Model/HyperKey.swift \
Tinycast/Features/HotKeys/Service/KeyShortcut.swift
Tinycast/Features/HotKeys/Service/KeyShortcut.swift \
Tinycast/Features/HotKeys/Model/HotKeyAction.swift \
Tinycast/Features/Launcher/Model/CommandID.swift \
Tinycast/Features/Quicklinks/Model/Quicklink.swift \
Tinycast/Features/SystemActions/Model/SystemAction.swift \
Tinycast/Features/WindowManagement/WindowCommand.swift
run callout-test Tinycast/DesignSystem/Theme.swift \
Tinycast/Features/HotKeys/UI/CalloutPlacement.swift
run icon-cache-test Tinycast/Platform/Images/IconCache.swift
run entry-icon-test Tinycast/Platform/Images/IconCache.swift
run ext-icon-test Tinycast/Platform/Images/IconCache.swift \
Tinycast/Features/Extensions/Service/ExtensionIconCache.swift
run system-action-test Tinycast/Features/SystemActions/Model/SystemAction.swift
run volume-test Tinycast/Features/SystemActions/Model/VolumeLevel.swift
run window-command-test Tinycast/Features/WindowManagement/WindowCommand.swift \
@@ -124,10 +135,30 @@ run notes-editor-test Tinycast/Platform/Signposts.swift \
Tinycast/Features/Notes/UI/NoteEditorView.swift
run raycast-test Tinycast/Features/Backup/Model/RaycastFormat.swift \
Tinycast/Features/Backup/Model/RaycastV1Decoder.swift \
Tinycast/Features/Backup/Service/Gunzip.swift \
Tinycast/Features/Clipboard/Model/ClipboardStore.swift
Tinycast/Platform/Compression/Zlib.swift \
Tinycast/Features/Clipboard/Model/ClipboardStore.swift \
Tinycast/Features/Clipboard/Model/ClipboardFilter.swift
run settings-backup-test Tinycast/Features/Settings/AppSettingsKey.swift \
Tinycast/Features/Backup/Model/SettingsBackupCoverage.swift
E=Tinycast/Features/Extensions
run symbols-test $E/Service/SymbolCatalog.swift
run ext-cleanup-test $E/Service/ExtensionCleanup.swift \
$E/Service/ExtensionCatalog.swift \
$E/Model/ExtensionManifest.swift
run ext-store-test $E/Model/ExtensionRegistry.swift \
$E/Model/ExtensionPackageManager.swift \
$E/Model/ExtensionStoreResponse.swift
run ext-test -parse-as-library \
$E/Model/ExtensionBootConfig.swift \
$E/Model/ExtensionManifest.swift \
$E/Model/RenderNode.swift \
$E/Service/ExtensionCatalog.swift \
$E/Service/ExtensionFetcher.swift \
$E/Service/ExtensionNodeShims.swift \
$E/Service/ExtensionRuntime.swift \
$E/UI/ExtensionScreen.swift \
$L/SearchRelevance.swift \
Tinycast/Platform/Compression/Zlib.swift
run settings-history-test Tinycast/Features/Settings/SettingsTab.swift \
Tinycast/Features/Settings/SettingsHistory.swift
+141 -88
View File
@@ -81,8 +81,6 @@ struct CalcTests {
expectDisplay("2sqrt(9)", "6")
expectDisplay("2(3+1)+1", "9") // implicit "*" binds like explicit "*", not looser
expectDisplay("10π ^e", "224.5915772") // and looser than "^"
// Units are never mistaken for a constant/function, so this stays untouched
expectDisplay("10km to mi", "6.213711922 mi")
// Juxtaposition against a bracket carries the unit through, matching explicit "*"
expectDisplay("2(3)kg", "6 kg")
expectDisplay("2*(3)kg", "6 kg")
@@ -397,7 +395,6 @@ struct CalcTests {
expectDisplay("1 rad to deg", "57.29577951 deg")
expectDisplay("1 turn to deg", "360 deg")
expectDisplay("200 grad to deg", "180 deg")
expectDisplay("sin(30deg)", "0.5") // trig postfix still works inside parens
// Implied quantity of 1 for number-less conversions
expectDisplay("day to s", "86,400 s")
@@ -485,7 +482,7 @@ struct CalcTests {
"1 eur to usd", "Exchange rates unavailable — check your connection.")
expectNil("10 usd to nonsense")
expectNil("usd") // a lone code is still an app search
expectNil("btc") // crypto isn't in the table — Frankfurter is central-bank fiat only
expectNil("btc") // …and a lone ticker no more than a lone code
// The table is generated from the feed's own currency list, so codes nobody hand-typed still
// resolve — reaching "no rate" (not "no card") is what proves recognition.
expectError("5 usd to zmw", "No exchange rate for ZMW.")
@@ -493,8 +490,11 @@ struct CalcTests {
check(
"CurrencyData sizes", expected: "true",
got:
"\(CurrencyData.all.count >= 120 && CurrencyData.signs.count >= 20 && CurrencyData.aliases.count >= 100)"
"\(CurrencyData.all.count >= 150 && CurrencyData.signs.count >= 20 && CurrencyData.aliases.count >= 100)"
)
// Retired codes are filtered out, so a currency nobody spends can't shadow a live one
expectNil("1 hrk to usd")
expectNil("1 kuna to usd")
// Badges come from CLDR's label, which is shorter than the registry name where it matters
expectBadges("1 chf to usd", source: "Swiss Franc", target: "US Dollar")
expectBadges("1 aed to usd", source: "UAE Dirham", target: "US Dollar")
@@ -503,13 +503,10 @@ struct CalcTests {
expectError("1 forint to usd", "No exchange rate for HUF.")
expectError("1 taka to usd", "No exchange rate for BDT.")
expectError("1 rand to usd", "No exchange rate for ZAR.")
expectDisplay("1 euro to dollars", "1.09 USD")
// Accented nouns resolve with or without the accent
expectError("1 krónur to usd", "No exchange rate for ISK.")
expectError("1 kronur to usd", "No exchange rate for ISK.")
// Nouns several currencies share are the hand-written part, and they must still win
expectDisplay("100 dollars to yen", "15,700.00 JPY")
expectDisplay("10 pounds to euros", "11.65 EUR")
expectDisplay("1 franc to usd", "1.23 USD")
expectError("1 peso to usd", "No exchange rate for MXN.")
// `krona` is contested (SEK vs ISK) and deliberately assigned to neither
@@ -525,7 +522,6 @@ struct CalcTests {
expectDisplay("-5 usd to eur", "-4.60 EUR")
// CUP (Cuban peso) is a generated code that collides with a unit; volume still wins
expectDisplay("1 cup to ml", "236.5882365 mL")
expectDisplay("1 cup to tbsp", "16 tbsp")
// Currency expressions — still pure and deterministic against the injected rate table
expectDisplay("10$", "10.00 USD")
@@ -552,8 +548,6 @@ struct CalcTests {
expectDisplay("$5(2)", "10.00 USD")
expectDisplay("5(2)$", "10.00 USD")
expectDisplay("$5(2) to eur", "9.20 EUR")
expectNilWithoutConsent("$5(2)")
expectNilWithoutConsent("5(2)$")
expectError("$10 + 5kg", "Cannot add Currency and Weight.")
expectErrorWithoutRates(
"$10 + $5", "Exchange rates unavailable — check your connection.")
@@ -562,33 +556,70 @@ struct CalcTests {
expectErrorWithoutRates(
"10$", "Exchange rates unavailable — check your connection.")
// Consent gate: without it the currency path doesn't exist. Not an error card explaining a
// feature the user never enabled — no card at all, so the query falls through to app search.
expectNilWithoutConsent("1 euro to dollars")
expectNilWithoutConsent("100 dollars to yen")
expectNilWithoutConsent("50 GBP in euros")
expectNilWithoutConsent("eur to usd")
expectNilWithoutConsent("€20 to GBP")
expectNilWithoutConsent("2*50 usd to cad")
expectNilWithoutConsent("1 zloty to eur")
expectNilWithoutConsent("10$")
expectNilWithoutConsent("$10 + $5")
expectNilWithoutConsent("$10 + €5")
expectNilWithoutConsent("$100 * 3%")
expectNilWithoutConsent("$10 +")
expectNilWithoutConsent("($10 + $5) to eur")
expectNilWithoutConsent("$10 + 5kg")
// Even the friendly category error stays silent — it would leak that currency exists.
expectNilWithoutConsent("10 usd to kg")
expectNilWithoutConsent("10 kg to usd")
// Everything that isn't currency is untouched by the gate.
expectDisplayWithoutConsent("10 pounds to kilograms", "4.5359237 kg")
expectDisplayWithoutConsent("10 pounds", "4.5359237 kg")
expectDisplayWithoutConsent("1 cup to ml", "236.5882365 mL")
expectDisplayWithoutConsent("10km to mi", "6.213711922 mi")
expectDisplayWithoutConsent("2+2", "4")
expectDisplayWithoutConsent("255 to hex", "0xFF")
expectDisplayWithoutConsent("20% off 500", "400")
// Crypto — priced by the same table, so a coin converts against fiat with no special case
expectDisplay("1 btc to usd", "60,000.00 USD")
expectDisplay("1 bitcoin to usd", "60,000.00 USD")
expectDisplay("0.5 sol to eur", "46.00 EUR")
expectDisplay("2 eth to gbp", "3,160.00 GBP")
expectBadges("1 eth to usd", source: "Ethereum", target: "US Dollar")
// Sub-cent widening covers a coin the same way it covers IDR
expectDisplay("1 usd to btc", "0.00001667 BTC")
expectCopy("1 usd to btc", "0.00001667 BTC")
// A symbol the feed omits behaves exactly like an unquoted fiat code
expectError("1 shib to usd", "No exchange rate for SHIB.")
// Tickers are recognized rather than swallowed by the unit table
expectError("1 dash to usd", "No exchange rate for DASH.")
expectError("1 neo to usd", "No exchange rate for NEO.")
// A ticker outranks a generated noun, and only that noun: `soles` still reaches PEN
expectBadges("1 sol to usd", source: "Solana", target: "US Dollar")
expectError("1 soles to usd", "No exchange rate for PEN.")
check(
"crypto is absent from the generated fiat table", expected: "true",
got: "\(CurrencyData.all.allSatisfy { !CalcCurrency.cryptoCodes.contains($0.code) })")
// A bare amount answers in the Mac's region currency, which is injected, never read
expectDisplay("1 usd", "83.50 INR", region: "INR")
expectExpression("1 usd", "1 USD", region: "INR")
expectBadges("1 usd", source: "US Dollar", target: "Indian Rupee", region: "INR")
expectDisplay("10$", "835.00 INR", region: "INR")
expectDisplay("1 btc", "5,010,000.00 INR", region: "INR")
expectCopy("1 usd", "83.50 INR", region: "INR")
// Nothing to say: the region names the currency written, one nobody quotes, or none at all
expectDisplay("1 usd", "1.00 USD", region: "USD")
expectDisplay("1 usd", "1.00 USD", region: "NPR")
expectDisplay("1 usd", "1.00 USD", region: "ZZZ")
expectDisplay("1 usd", "1.00 USD")
// An operator, a target or a half-typed expression all keep the currency written
expectDisplay("$10 + €5", "14.20 EUR", region: "INR")
expectDisplay("$10 +", "10.00 USD", region: "INR")
expectBadges("$10 +", source: "Expression", target: "US Dollar", region: "INR")
expectDisplay("1 usd to eur", "0.92 EUR", region: "INR")
expectDisplay("10 pounds", "4.5359237 kg", region: "INR")
expectNil("usd", region: "INR")
expectNil("btc", region: "INR")
// The feed decoding, exercised the way the store hands it over
expectSnapshot(
"fiat only", fiat: fiatJSON, crypto: nil,
expected: "USD=1 EUR=0.9 BTC=nil complete=false")
expectSnapshot(
"both feeds", fiat: fiatJSON, crypto: cryptoJSON,
expected: "USD=1 EUR=0.9 BTC=5e-05 complete=true")
// A coin payload quoted against another base is ignored rather than folded in wrongly
expectSnapshot(
"mismatched base", fiat: fiatJSON,
crypto: Data(#"{"success":true,"target":"EUR","rates":{"BTC":20000}}"#.utf8),
expected: "USD=1 EUR=0.9 BTC=nil complete=false")
expectSnapshotThrows("no quotes", fiat: Data(#"{"success":true,"source":"USD","quotes":{}}"#.utf8))
// A cached snapshot that prices no coin predates them, whatever its `fetchedAt` claims
let coinless = CurrencyRates(base: "USD", rates: ["EUR": 0.9], fetchedAt: clock.now)
check(
"a coin-less snapshot is rejected on load", expected: "false",
got: "\(CurrencyFeed.pricesCoins(coinless))")
check("the fixture prices coins", expected: "true", got: "\(CurrencyFeed.pricesCoins(fx))")
expectSnapshotThrows(
"feed reported failure",
fiat: Data(#"{"success":false,"source":"USD","quotes":{"USDEUR":0.9}}"#.utf8))
print("\n\(passes) passed, \(failures) failed")
exit(failures == 0 ? 0 : 1)
@@ -612,16 +643,23 @@ struct CalcTests {
// MARK: - Fixed exchange rates so currency answers are deterministic
/// NPR, ZMW and AFN are deliberately absent: the table recognizes them (they're in the generated
/// list), so a query for one must reach "no exchange rate" rather than falling through to no card.
/// NPR, ZMW, AFN and SHIB are deliberately absent: the table recognizes them, so a query for one
/// must reach "no exchange rate" rather than no card. Coins are inverted, as the store stores them.
static let fx = CurrencyRates(
base: "USD",
rates: [
"USD": 1, "EUR": 0.92, "GBP": 0.79, "JPY": 157, "INR": 83.5, "CAD": 1.36,
"KRW": 1330, "IDR": 18053, "CHF": 0.81, "AED": 3.6725
"KRW": 1330, "IDR": 18053, "CHF": 0.81, "AED": 3.6725,
"BTC": 1.0 / 60_000, "ETH": 1.0 / 2_000, "SOL": 1.0 / 100, "DOGE": 10
],
fetchedAt: Date(timeIntervalSince1970: 1_785_000_000))
/// A pair whose base isn't the source and a nonsense rate, both of which must be dropped.
static let fiatJSON = Data(
#"{"success":true,"source":"USD","quotes":{"USDEUR":0.9,"EURGBP":0.8,"USDBAD":-1}}"#.utf8)
/// Quoted the other way round — 1 BTC costs 20,000 USD, so the table stores 0.00005.
static let cryptoJSON = Data(#"{"success":true,"target":"USD","rates":{"BTC":20000}}"#.utf8)
// MARK: - Helpers
static func expectDisplayAt(_ query: String, _ expected: String) {
@@ -653,35 +691,50 @@ struct CalcTests {
}
}
static func expectBadges(_ query: String, source: String, target: String) {
guard let result = CalcEngine.evaluate(query, currency: .on(fx)) else {
fail(query, expected: "\(source) → \(target)", got: "nil")
return
}
check(query + " [source badge]", expected: source, got: result.sourceBadge ?? "nil")
check(query + " [target badge]", expected: target, got: result.targetBadge ?? "nil")
/// The Mac's region currency, injected rather than read, so the suite ignores the host's region.
static func label(_ query: String, _ region: String?) -> String {
region.map { "\(query) [region \($0)]" } ?? query
}
static func expectDisplay(_ query: String, _ expected: String) {
guard case .value(let display, _)? = CalcEngine.evaluate(query, currency: .on(fx))?.payload
else {
fail(query, expected: expected, got: "nil / error")
static func expectBadges(
_ query: String, source: String, target: String, region: String? = nil
) {
guard let result = CalcEngine.evaluate(query, rates: fx, region: region) else {
fail(label(query, region), expected: "\(source) → \(target)", got: "nil")
return
}
check(query, expected: expected, got: display)
check(
label(query, region) + " [source badge]", expected: source,
got: result.sourceBadge ?? "nil")
check(
label(query, region) + " [target badge]", expected: target,
got: result.targetBadge ?? "nil")
}
static func expectCopy(_ query: String, _ expected: String) {
guard case .value(_, let copy)? = CalcEngine.evaluate(query, currency: .on(fx))?.payload
static func expectDisplay(_ query: String, _ expected: String, region: String? = nil) {
guard
case .value(let display, _)? = CalcEngine.evaluate(
query, rates: fx, region: region)?.payload
else {
fail(query, expected: expected, got: "nil / error")
fail(label(query, region), expected: expected, got: "nil / error")
return
}
check(query, expected: expected, got: copy)
check(label(query, region), expected: expected, got: display)
}
static func expectCopy(_ query: String, _ expected: String, region: String? = nil) {
guard
case .value(_, let copy)? = CalcEngine.evaluate(
query, rates: fx, region: region)?.payload
else {
fail(label(query, region), expected: expected, got: "nil / error")
return
}
check(label(query, region), expected: expected, got: copy)
}
static func expectError(_ query: String, _ expected: String) {
guard case .error(let message)? = CalcEngine.evaluate(query, currency: .on(fx))?.payload
guard case .error(let message)? = CalcEngine.evaluate(query, rates: fx)?.payload
else {
fail(query, expected: "error: \(expected)", got: "nil / value")
return
@@ -689,9 +742,9 @@ struct CalcTests {
check(query, expected: expected, got: message)
}
/// Consented, but no snapshot has landed yet — first run, or still offline.
/// No snapshot has landed yet — first run, or still offline.
static func expectErrorWithoutRates(_ query: String, _ expected: String) {
guard case .error(let message)? = CalcEngine.evaluate(query, currency: .on(nil))?.payload
guard case .error(let message)? = CalcEngine.evaluate(query, rates: nil)?.payload
else {
fail(query, expected: "error: \(expected)", got: "nil / value")
return
@@ -699,39 +752,39 @@ struct CalcTests {
check(query, expected: expected, got: message)
}
/// No consent: the currency path must not engage. Checks the explicit `.off` source and the
/// default argument, since a caller that forgets to pass one must still get the feature off.
static func expectNilWithoutConsent(_ query: String) {
if let result = CalcEngine.evaluate(query, currency: .off) {
fail(query, expected: "nil (consent withheld)", got: "\(result.payload)")
} else if let result = CalcEngine.evaluate(query) {
fail(query, expected: "nil (default source)", got: "\(result.payload)")
static func expectExpression(_ query: String, _ expected: String, region: String? = nil) {
guard let result = CalcEngine.evaluate(query, rates: fx, region: region) else {
fail(label(query, region), expected: expected, got: "nil")
return
}
check(label(query, region), expected: expected, got: result.expression)
}
/// `CurrencyFeed` is handed the two payloads exactly as the store receives them.
static func expectSnapshot(_ name: String, fiat: Data, crypto: Data?, expected: String) {
guard let result = try? CurrencyFeed.snapshot(fiat: fiat, crypto: crypto, now: clock.now)
else {
fail(name, expected: expected, got: "threw")
return
}
let show = { (code: String) in result.rates.rates[code].map(CalcFormatter.copyText) ?? "nil" }
check(
name, expected: expected,
got: "USD=\(show("USD")) EUR=\(show("EUR")) BTC=\(show("BTC")) "
+ "complete=\(result.complete)")
}
static func expectSnapshotThrows(_ name: String, fiat: Data) {
if let result = try? CurrencyFeed.snapshot(fiat: fiat, crypto: nil, now: clock.now) {
fail(name, expected: "throws", got: "\(result.rates.rates.count) rates")
} else {
passes += 1
}
}
/// A non-currency answer that must survive with the feature switched off.
static func expectDisplayWithoutConsent(_ query: String, _ expected: String) {
guard case .value(let display, _)? = CalcEngine.evaluate(query, currency: .off)?.payload
else {
fail(query, expected: expected, got: "nil / error")
return
}
check(query, expected: expected, got: display)
}
static func expectExpression(_ query: String, _ expected: String) {
guard let result = CalcEngine.evaluate(query, currency: .on(fx)) else {
fail(query, expected: expected, got: "nil")
return
}
check(query, expected: expected, got: result.expression)
}
static func expectNil(_ query: String) {
if let result = CalcEngine.evaluate(query, currency: .on(fx)) {
fail(query, expected: "nil", got: "\(result.payload)")
static func expectNil(_ query: String, region: String? = nil) {
if let result = CalcEngine.evaluate(query, rates: fx, region: region) {
fail(label(query, region), expected: "nil", got: "\(result.payload)")
} else {
passes += 1
}
+87 -4
View File
@@ -13,6 +13,9 @@ struct ClipboardTests {
pasteLeavesPinsAlone()
pinsSurvivePruningAndTheWindow()
pinsLeadFilteredSearches()
textFormClassification()
typeFilterSplitsTheHistory()
typeFilterJoinsTheSearchMemo()
persistence()
migrationFromShippedDatabase()
@@ -125,7 +128,7 @@ struct ClipboardTests {
_ = store.importEntries(seed)
store.togglePinned(item(store, "needle in the haystack"))
let results = store.search("haystack")
let results = store.search("haystack", filter: .all)
expect(results.count > 200, "FTS results plus the pinned block")
expect(
results.first?.text == "needle in the haystack",
@@ -133,13 +136,89 @@ struct ClipboardTests {
expect(
results.filter(\.isPinned).count == 1, "the pinned row is not duplicated")
let short = store.search("ne") // below the trigram threshold: the fallback path
// Below the trigram threshold: the fallback path.
let short = store.search("ne", filter: .all)
expect(
short.first?.text == "needle in the haystack",
"the pinned match leads the fallback search too")
}
}
/// The link/address classifier, including the filenames that must not read as links.
static func textFormClassification() {
let links = [
"https://apple.com", "http://apple.com/path?q=1", "apple.com", "apple.com/store",
"www.Apple.com", "vscode://file/tmp/x", "docs.google.com", "bit.ly/abc"
]
for text in links {
expect(form(text) == .link, "\(text) is a link")
}
let addresses = ["hi@apple.com", "mailto:hi@apple.com", "first.last@mail.example.co.uk"]
for text in addresses {
expect(form(text) == .email, "\(text) is an address")
}
let plain = [
// Extensions that collide with a real TLD are the whole reason for the TLD set.
"report.pdf", "index.html", "App.swift", "data.json", "Safari.app", "image.png",
"hello world", "visit apple.com today", "3.14", "", " ", "no-dot-at-all",
"two@at@signs.com", "@apple.com", "hi@localhost", "line one\nline two"
]
for text in plain {
expect(form(text) == .plain, "\(String(text.prefix(20))) is plain text")
}
// Past the scan cap, so a multi-MB copy is never walked looking for a scheme.
expect(
form("https://apple.com/" + String(repeating: "a", count: 4096)) == .plain,
"an over-long token is plain by definition")
expect(
ClipboardItem(imagePath: "/tmp/x.png", sourceBundleID: nil).textForm == nil,
"an image has no text form")
}
/// Each filter returns only its own kind, and a pin still leads the block.
static func typeFilterSplitsTheHistory() {
withStore { store, _ in
store.addText("just some prose", sourceBundleID: nil)
store.addText("https://apple.com", sourceBundleID: nil)
store.addText("hi@apple.com", sourceBundleID: nil)
store.addText("second.link.dev", sourceBundleID: nil)
expect(texts(store).count == 4, "every entry under All Types")
expect(texts(store, filter: .text) == ["just some prose"], "text excludes links")
expect(
texts(store, filter: .link) == ["second.link.dev", "https://apple.com"],
"links stay newest-first")
expect(texts(store, filter: .email) == ["hi@apple.com"], "addresses on their own")
expect(texts(store, filter: .image).isEmpty, "no images were captured")
store.togglePinned(item(store, "https://apple.com"))
expect(
texts(store, filter: .link) == ["https://apple.com", "second.link.dev"],
"a pinned link leads its filtered block")
}
}
/// The memo keys on the filter too: same query, new filter, different rows.
static func typeFilterJoinsTheSearchMemo() {
withStore { store, _ in
store.addText("shared token prose", sourceBundleID: nil)
store.addText("shared-token.com", sourceBundleID: nil)
expect(store.search("shared", filter: .all).count == 2, "both match the query")
expect(
store.search("shared", filter: .link).map(\.text) == ["shared-token.com"],
"the same query under a link filter is not served from the wider memo")
expect(
store.search("shared", filter: .text).map(\.text) == ["shared token prose"],
"and switching filters again re-runs rather than reusing")
expect(store.search("shared", filter: .all).count == 2, "back to both")
}
}
/// Pin stamps and their order survive a reopen.
static func persistence() {
withStore { store, dir in
@@ -253,14 +332,18 @@ struct ClipboardTests {
return Set(String(decoding: data, as: UTF8.self).split(separator: "\n").map(String.init))
}
static func form(_ text: String) -> ClipboardItem.TextForm? {
ClipboardItem(text: text, sourceBundleID: nil).textForm
}
static func entry(_ text: String, at date: Date) -> ClipboardItem {
ClipboardItem(
id: UUID(), kind: .text, text: text, imagePath: nil, createdAt: date,
sourceBundleID: nil)
}
static func texts(_ store: ClipboardStore) -> [String] {
store.search("").compactMap(\.text)
static func texts(_ store: ClipboardStore, filter: ClipboardFilter = .all) -> [String] {
store.search("", filter: filter).compactMap(\.text)
}
static func item(_ store: ClipboardStore, _ text: String) -> ClipboardItem {
+192
View File
@@ -0,0 +1,192 @@
import AppKit
import Foundation
@main
@MainActor
struct EntryIconTests {
static var failures = 0
static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() {
failures += 1
print("FAIL: \(message)")
} else {
print("PASS \(message)")
}
}
static let red = SymbolTint(key: "red", color: .systemRed)
static let blue = SymbolTint(key: "blue", color: .systemBlue)
// MARK: - Identity
/// `AppEntry.iconKey` interpolates an `EntryIcon` into a string, and a row's async load is keyed
/// on it. Two icons that render differently but print the same would serve each other's bitmap.
static func everyCasePrintsDistinctly() {
let icons: [EntryIcon] = [
.file,
.symbol("star"),
.symbol("bolt"),
.tintedSymbol(name: "star", tint: red),
.tintedSymbol(name: "star", tint: blue),
.tintedSymbol(name: "bolt", tint: red),
.artwork(path: "/tmp/a.png", extent: 0.76),
.artwork(path: "/tmp/a.png", extent: 0.83),
.artwork(path: "/tmp/b.png", extent: 0.76)
]
let printed = Set(icons.map { "\($0)" })
expect(printed.count == icons.count, "each icon prints uniquely: \(printed.count)/\(icons.count)")
}
/// Hashable is what `.task(id:)` compares, so a re-skin has to read as a different value.
static func hashingSeparatesTheCases() {
expect(EntryIcon.symbol("star") == EntryIcon.symbol("star"), "the same symbol is equal")
expect(EntryIcon.symbol("star") != EntryIcon.symbol("bolt"), "two symbols differ")
expect(
EntryIcon.tintedSymbol(name: "star", tint: red)
!= EntryIcon.tintedSymbol(name: "star", tint: blue),
"two tints of one symbol differ")
expect(
EntryIcon.symbol("star") != EntryIcon.tintedSymbol(name: "star", tint: red),
"a tinted tile differs from the plain one")
expect(
EntryIcon.artwork(path: "/tmp/a.png", extent: 0.76)
!= EntryIcon.artwork(path: "/tmp/a.png", extent: 0.83),
"one file at two extents differs")
expect(
Set([EntryIcon.file, .symbol("star"), .file]).count == 2,
"hashing collapses only equal values")
}
// MARK: - Drawing
/// Each case has to reach its own path. A wrong branch would silently draw the fallback.
static func everyCaseDraws() {
let url = URL(fileURLWithPath: "/System/Applications/Calculator.app")
let cases: [(String, EntryIcon)] = [
("file", .file),
("symbol", .symbol("star")),
("tintedSymbol", .tintedSymbol(name: "star", tint: red))
]
for (label, icon) in cases {
let image = IconCache.icon(for: icon, fileURL: url)
expect(image.size.width > 0, "\(label) draws something")
}
}
/// A tint has to reach the tile. Same glyph, two tints, three different bitmaps.
static func tintsAndSymbolsDoNotShareABitmap() {
let url = URL(fileURLWithPath: "/System/Applications/Calculator.app")
let plain = bitmap(IconCache.icon(for: .symbol("star"), fileURL: url))
let redTile = bitmap(IconCache.icon(for: .tintedSymbol(name: "star", tint: red), fileURL: url))
let blueTile = bitmap(IconCache.icon(for: .tintedSymbol(name: "star", tint: blue), fileURL: url))
expect(plain != nil && redTile != nil && blueTile != nil, "all three rasterize")
expect(plain != redTile, "a tinted tile differs from the plain one")
expect(redTile != blueTile, "two tints do not share a cache entry")
}
/// The whole point of carrying `extent`: one file, two sizes, and neither serves the other.
static func oneFileAtTwoExtentsDiffers() {
guard let path = writePNG() else { return expect(false, "the fixture writes") }
defer { try? FileManager.default.removeItem(at: path.deletingLastPathComponent()) }
let small = IconCache.icon(for: .artwork(path: path.path, extent: 0.60), fileURL: path)
let large = IconCache.icon(for: .artwork(path: path.path, extent: 0.90), fileURL: path)
guard let smallInk = inkExtent(small), let largeInk = inkExtent(large) else {
return expect(false, "both artwork sizes rasterize")
}
expect(smallInk < largeInk, "a smaller extent draws smaller ink: \(smallInk) vs \(largeInk)")
expect(bitmap(small) != bitmap(large), "two extents do not share a cache entry")
}
/// A second ask must hit the cache, not redraw — that is what keeps a scrolled list cheap.
static func askingTwiceIsStable() {
let url = URL(fileURLWithPath: "/System/Applications/Calculator.app")
let first = bitmap(IconCache.icon(for: .tintedSymbol(name: "gear", tint: red), fileURL: url))
let second = bitmap(IconCache.icon(for: .tintedSymbol(name: "gear", tint: red), fileURL: url))
expect(first != nil && first == second, "the same icon is stable across two asks")
}
/// Cache-only lookups must answer for a warm icon and stay silent for a cold one.
static func cacheOnlyLookupMatchesTheDrawnIcon() {
let url = URL(fileURLWithPath: "/System/Applications/Calculator.app")
let cold = EntryIcon.symbol("nonexistent.glyph.\(UUID().uuidString)")
expect(IconCache.cached(cold, fileURL: url) == nil, "a cold icon is not reported warm")
let warm = EntryIcon.symbol("bookmark")
_ = IconCache.icon(for: warm, fileURL: url)
expect(IconCache.cached(warm, fileURL: url) != nil, "a drawn icon is reported warm")
}
// MARK: - Helpers
static func bitmap(_ image: NSImage) -> Data? { image.tiffRepresentation }
/// A red square filling its canvas, so fitting it to an extent is visible in the result.
static func writePNG() -> URL? {
let side = 256
guard
let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8,
samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
bytesPerRow: 0, bitsPerPixel: 0), let ctx = NSGraphicsContext(bitmapImageRep: rep)
else { return nil }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
NSColor.red.setFill()
NSRect(x: 0, y: 0, width: side, height: side).fill()
NSGraphicsContext.restoreGraphicsState()
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("entry-icon-test-\(UUID().uuidString)")
guard let data = rep.representation(using: .png, properties: [:]),
(try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true))
!= nil
else { return nil }
let url = dir.appendingPathComponent("fixture.png")
return (try? data.write(to: url)) != nil ? url : nil
}
/// The larger side of the alpha bounding box, as a share of the canvas.
static func inkExtent(_ image: NSImage) -> CGFloat? {
let side = 96
guard
let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8,
samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
bytesPerRow: 0, bitsPerPixel: 0), let ctx = NSGraphicsContext(bitmapImageRep: rep)
else { return nil }
rep.size = NSSize(width: side, height: side)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
image.draw(in: NSRect(x: 0, y: 0, width: side, height: side))
NSGraphicsContext.restoreGraphicsState()
var minX = side, maxX = -1, minY = side, maxY = -1
for y in 0..<side {
for x in 0..<side where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.06 {
minX = min(minX, x)
maxX = max(maxX, x)
minY = min(minY, y)
maxY = max(maxY, y)
}
}
guard maxX >= 0 else { return nil }
return CGFloat(max(maxX - minX + 1, maxY - minY + 1)) / CGFloat(side)
}
static func main() {
everyCasePrintsDistinctly()
hashingSeparatesTheCases()
everyCaseDraws()
tintsAndSymbolsDoNotShareABitmap()
oneFileAtTwoExtentsDiffers()
askingTwiceIsStable()
cacheOnlyLookupMatchesTheDrawnIcon()
print(failures == 0 ? "Entry icon tests passed" : "\(failures) tests failed")
exit(failures == 0 ? 0 : 1)
}
}
+174
View File
@@ -0,0 +1,174 @@
import Foundation
@main
@MainActor
struct ExtensionCleanupTests {
static var failures = 0
static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() {
failures += 1
print("FAIL: \(message)")
} else {
print("PASS \(message)")
}
}
// MARK: - Fixtures
/// Roots under one throwaway directory. Nothing here can reach the real install: `Roots` is
/// injected, so `Bundle.main` is never consulted.
static func makeRoots() -> (ExtensionCleanup.Roots, URL) {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("ext-cleanup-test-\(UUID().uuidString)", isDirectory: true)
let roots = ExtensionCleanup.Roots(
temp: base.appendingPathComponent("temp", isDirectory: true),
support: base.appendingPathComponent("support", isDirectory: true),
data: base.appendingPathComponent("data", isDirectory: true))
for url in [roots.temp, roots.support, roots.data] {
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
}
return (roots, base)
}
static func makeDirectory(_ parent: URL, _ name: String, bytes: Int = 1024) {
let url = parent.appendingPathComponent(name, isDirectory: true)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
try? Data(repeating: 0, count: bytes).write(to: url.appendingPathComponent("payload.bin"))
}
static func makeFile(_ parent: URL, _ name: String, bytes: Int = 512) {
try? Data(repeating: 0, count: bytes).write(to: parent.appendingPathComponent(name))
}
static func exists(_ url: URL, _ name: String) -> Bool {
FileManager.default.fileExists(atPath: url.appendingPathComponent(name).path)
}
// MARK: - Cases
/// A workspace is ours by name. Anything else in the temp root belongs to somebody else.
static func sweepTakesOnlyOurWorkspaces() {
let (roots, base) = makeRoots()
defer { try? FileManager.default.removeItem(at: base) }
makeDirectory(roots.temp, "tinycast-install-ABC123")
makeDirectory(roots.temp, "tinycast-install-DEF456")
makeDirectory(roots.temp, "keep-me")
makeFile(roots.temp, "unrelated.txt")
ExtensionCleanup.sweepWorkspaces(in: roots.temp)
expect(!exists(roots.temp, "tinycast-install-ABC123"), "a stale workspace is swept")
expect(!exists(roots.temp, "tinycast-install-DEF456"), "every stale workspace is swept")
expect(exists(roots.temp, "keep-me"), "another app's directory survives")
expect(exists(roots.temp, "unrelated.txt"), "an unrelated file survives")
}
/// The installed set is the authority: what it names is kept, everything else is a leftover.
static func cleanTakesOnlyOrphans() {
let (roots, base) = makeRoots()
defer { try? FileManager.default.removeItem(at: base) }
makeDirectory(roots.support, "kill-process")
makeDirectory(roots.support, "speedtest")
makeFile(roots.data, "kill-process.json")
makeFile(roots.data, "speedtest.json")
makeDirectory(roots.temp, "tinycast-install-STRAY")
let report = ExtensionCleanup.clean(installed: ["kill-process"], in: roots)
expect(exists(roots.support, "kill-process"), "an installed extension keeps its scratch dir")
expect(exists(roots.data, "kill-process.json"), "an installed extension keeps its data")
expect(!exists(roots.support, "speedtest"), "an orphaned scratch dir goes")
expect(!exists(roots.data, "speedtest.json"), "an orphaned data file goes")
expect(!exists(roots.temp, "tinycast-install-STRAY"), "a stray workspace goes")
expect(report.items == 3, "the report counts every removal: \(report.items)")
expect(report.bytes > 0, "the report measures what it freed: \(report.bytes)")
}
/// An npm-scoped name flattens the same way everywhere, or its files orphan themselves.
static func scopedNamesMatchTheirDirectories() {
let (roots, base) = makeRoots()
defer { try? FileManager.default.removeItem(at: base) }
makeDirectory(roots.support, ExtensionCatalog.safeName("@scope/thing"))
makeFile(roots.data, "\(ExtensionCatalog.safeName("@scope/thing")).json")
let report = ExtensionCleanup.clean(installed: ["@scope/thing"], in: roots)
expect(report.isEmpty, "a scoped name matches its own directory: \(report.items) removed")
expect(exists(roots.support, "scope-thing"), "the scoped scratch dir survives")
}
/// What the button promises is what pressing it does.
static func reclaimableMatchesClean() {
let (roots, base) = makeRoots()
defer { try? FileManager.default.removeItem(at: base) }
makeDirectory(roots.support, "gone-one")
makeDirectory(roots.support, "gone-two")
makeFile(roots.data, "gone-one.json")
let predicted = ExtensionCleanup.reclaimable(installed: [], in: roots)
let actual = ExtensionCleanup.clean(installed: [], in: roots)
expect(predicted.items == 3, "reclaimable counts every stray: \(predicted.items)")
expect(predicted == actual, "reclaimable predicts clean exactly")
}
/// Nothing to do is not a failure, and neither is a root that was never created.
static func emptyAndMissingRootsAreSafe() {
let (roots, base) = makeRoots()
defer { try? FileManager.default.removeItem(at: base) }
expect(ExtensionCleanup.clean(installed: [], in: roots).isEmpty, "empty roots report nothing")
let missing = ExtensionCleanup.Roots(
temp: base.appendingPathComponent("nope-temp"),
support: base.appendingPathComponent("nope-support"),
data: base.appendingPathComponent("nope-data"))
expect(ExtensionCleanup.clean(installed: [], in: missing).isEmpty, "missing roots report nothing")
ExtensionCleanup.sweepWorkspaces(in: missing.temp)
}
/// The installer and the sweep have to agree on the name, which is why one type owns it.
static func workspaceIsSweptByItsOwnPrefix() {
let (roots, base) = makeRoots()
defer { try? FileManager.default.removeItem(at: base) }
let workspace = ExtensionCleanup.workspace(in: roots.temp)
try? FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true)
ExtensionCleanup.sweepWorkspaces(in: roots.temp)
expect(
!FileManager.default.fileExists(atPath: workspace.path),
"a workspace the installer would make is one the sweep finds")
}
/// Raycast Beta v2 keeps its extensions under `raycast-x`. Checking only `raycast` is why an
/// install of the Beta reported that no Raycast was present at all.
static func bothRaycastChannelsAreSearched() {
let roots = ExtensionCatalog.raycastExtensionRoots().map(\.path)
expect(roots.count == 2, "both channels are searched: \(roots.count)")
expect(
roots.contains { $0.hasSuffix("/.config/raycast/extensions") },
"the stable channel is searched")
expect(
roots.contains { $0.hasSuffix("/.config/raycast-x/extensions") },
"the beta channel is searched")
}
static func main() {
bothRaycastChannelsAreSearched()
sweepTakesOnlyOurWorkspaces()
cleanTakesOnlyOrphans()
scopedNamesMatchTheirDirectories()
reclaimableMatchesClean()
emptyAndMissingRootsAreSafe()
workspaceIsSweptByItsOwnPrefix()
print(failures == 0 ? "Extension cleanup tests passed" : "\(failures) tests failed")
exit(failures == 0 ? 0 : 1)
}
}
+102
View File
@@ -0,0 +1,102 @@
import AppKit
import Foundation
@main
@MainActor
struct ExtensionIconTests {
static var failures = 0
static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() {
failures += 1
print("FAIL: \(message)")
}
}
/// Extension artwork is normalized, so how much transparent margin a source ships cannot change
/// the size it draws at — and it lands below an app icon on purpose. See docs/features/extensions.md.
static func artworkIsNormalized() {
guard let bleed = writePNG("bleed", inset: 0), let padded = writePNG("padded", inset: 96)
else { return expect(false, "the fixtures write") }
defer {
try? FileManager.default.removeItem(at: bleed.deletingLastPathComponent())
try? FileManager.default.removeItem(at: padded.deletingLastPathComponent())
}
guard let full = inkExtent(ExtensionIconCache.icon(atPath: bleed.path)),
let margin = inkExtent(ExtensionIconCache.icon(atPath: padded.path))
else { return expect(false, "both fixtures rasterize") }
expect(abs(full - margin) <= 0.03, "padding can't change the drawn size: \(full) vs \(margin)")
expect(
full < IconCache.appIconExtent - 0.03,
"artwork draws below an app icon's \(IconCache.appIconExtent): \(full)")
}
/// A missing file still answers, so a row never renders an empty slot.
static func missingFileFallsBack() {
let icon = ExtensionIconCache.icon(atPath: "/nonexistent/\(UUID().uuidString).png")
expect(icon.size.width > 0, "a missing icon falls back to the puzzle-piece tile")
}
/// A red square on a transparent canvas, `inset` pixels in from each edge.
static func writePNG(_ name: String, inset: Int) -> URL? {
let side = 512
guard
let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8,
samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
bytesPerRow: 0, bitsPerPixel: 0), let ctx = NSGraphicsContext(bitmapImageRep: rep)
else { return nil }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
NSColor.red.setFill()
NSRect(x: inset, y: inset, width: side - inset * 2, height: side - inset * 2).fill()
NSGraphicsContext.restoreGraphicsState()
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("ext-icon-test-\(UUID().uuidString)")
guard let data = rep.representation(using: .png, properties: [:]),
(try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true))
!= nil
else { return nil }
let url = dir.appendingPathComponent("\(name).png")
return (try? data.write(to: url)) != nil ? url : nil
}
/// The larger side of the alpha bounding box, as a share of the canvas.
static func inkExtent(_ image: NSImage) -> CGFloat? {
let side = 96
guard
let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8,
samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
bytesPerRow: 0, bitsPerPixel: 0), let ctx = NSGraphicsContext(bitmapImageRep: rep)
else { return nil }
rep.size = NSSize(width: side, height: side)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
image.draw(in: NSRect(x: 0, y: 0, width: side, height: side))
NSGraphicsContext.restoreGraphicsState()
var minX = side, maxX = -1, minY = side, maxY = -1
for y in 0..<side {
for x in 0..<side where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.06 {
minX = min(minX, x)
maxX = max(maxX, x)
minY = min(minY, y)
maxY = max(maxY, y)
}
}
guard maxX >= 0 else { return nil }
return CGFloat(max(maxX - minX + 1, maxY - minY + 1)) / CGFloat(side)
}
static func main() {
artworkIsNormalized()
missingFileFallsBack()
print(failures == 0 ? "Extension icon tests passed" : "\(failures) tests failed")
exit(failures == 0 ? 0 : 1)
}
}
+280
View File
@@ -0,0 +1,280 @@
import Foundation
/// Guards the registry model and the two response parsers — the parts of installing from a registry
/// that can be checked without a network. Payloads are trimmed captures of the real responses.
@main
@MainActor
struct ExtensionStoreTests {
static var failures = 0
static var passes = 0
static func main() {
registryParsing()
registryDefaults()
storeResponse()
gitHubContents()
manifestSummary()
packageManagers()
abbreviation()
print(failures == 0 ? "\nALL PASSED" : "\n\(failures) FAILED")
print("\(passes) passed, \(failures) failed")
exit(failures == 0 ? 0 : 1)
}
// MARK: - Registry
static func registryParsing() {
print("\n# registry parsing")
let plain = ExtensionRegistry.parse("https://github.com/raycast/extensions")
check("a repository URL parses", plain?.owner == "raycast" && plain?.repository == "extensions")
check("and defaults to extensions/ on main", plain?.path == "extensions" && plain?.ref == "main")
let short = ExtensionRegistry.parse("someone/my-extensions")
check("owner/repo alone parses", short?.owner == "someone")
let git = ExtensionRegistry.parse("git@example/nope")
check("a non-GitHub remote still yields owner/repo", git != nil)
let deep = ExtensionRegistry.parse(
"https://github.com/raycast/extensions/tree/abc123/extensions")
check("a tree link keeps its ref", deep?.ref == "abc123")
check("and its path", deep?.path == "extensions")
let nested = ExtensionRegistry.parse("github.com/me/repo/tree/dev/packages/raycast")
check("a nested path survives", nested?.path == "packages/raycast")
check("with its branch", nested?.ref == "dev")
let dotGit = ExtensionRegistry.parse("https://github.com/me/repo.git")
check("a .git suffix is dropped", dotGit?.repository == "repo")
check("junk is rejected", ExtensionRegistry.parse("not a url") == nil)
check("a bare owner is rejected", ExtensionRegistry.parse("raycast") == nil)
let named = ExtensionRegistry.parse("me/repo", name: "Mine")
check("an explicit name wins", named?.name == "Mine")
check("and is derived when absent", ExtensionRegistry.parse("me/repo")?.name == "me/repo")
let messy = ExtensionRegistry(kind: .github, name: "x", path: "/extensions/")
check("a path is normalized", messy.path == "extensions")
}
static func registryDefaults() {
print("\n# registry defaults")
check("both defaults ship", ExtensionRegistry.defaults.count == 2)
check("the store comes first", ExtensionRegistry.defaults.first?.kind == .raycastStore)
check("both are built in", ExtensionRegistry.defaults.allSatisfy(\.isBuiltIn))
// Only the store is searched out of the box: it is the one that needs no toolchain.
check("the store is on by default", ExtensionRegistry.store.isEnabled)
check("the source registry is not", !ExtensionRegistry.officialGitHub.isEnabled)
check("an added registry starts on", ExtensionRegistry.parse("me/repo")?.isEnabled == true)
check("an added one is not", ExtensionRegistry.parse("me/repo")?.isBuiltIn == false)
// Persisted by id, so a stable id is what keeps a stored copy recognisable as built-in.
check(
"built-in ids are fixed",
ExtensionRegistry.store.id.uuidString == "00000000-0000-0000-0000-000000000001")
let encoded = try? JSONEncoder().encode(ExtensionRegistry.defaults)
let decoded = encoded.flatMap { try? JSONDecoder().decode([ExtensionRegistry].self, from: $0) }
check("registries round-trip", decoded == ExtensionRegistry.defaults)
}
// MARK: - Raycast's store
static let storePayload = """
{"data":[
{"id":"abc","name":"coffee","title":"Coffee","description":"Prevent sleep",
"author":{"name":"Max Schmidt","handle":"mooxl"},
"icons":{"light":"https://files.raycast.com/icon","dark":null},
"commands":[{"name":"caffeinate"},{"name":"decaffeinate"}],
"download_count":124218,"status":"active",
"download_url":"https://example.com/coffee.zip",
"commit_sha":"c325a1a","relative_path":"extensions/coffee/"},
{"id":"def","name":"gone","title":"Gone","status":"active"},
{"id":"ghi","name":"dead","title":"Dead","status":"kill_listed",
"download_url":"https://example.com/dead.zip"}
]}
"""
static func storeResponse() {
print("\n# store response")
guard
let listings = try? ExtensionStoreResponse.parseStore(
Data(storePayload.utf8), registry: .store)
else {
check("the store payload parses", false)
return
}
check("only installable entries survive", listings.count == 1)
guard let coffee = listings.first else { return }
check("the title is read", coffee.title == "Coffee")
check("the author's name wins over the handle", coffee.author == "Max Schmidt")
check("commands are counted", coffee.commandCount == 2)
check("downloads are read", coffee.downloadCount == 124_218)
check("the icon resolves", coffee.iconURL?.absoluteString == "https://files.raycast.com/icon")
check("it carries its registry", coffee.registryID == ExtensionRegistry.store.id)
if case .prebuiltZip(let url) = coffee.source {
check("the source is the zip", url.absoluteString == "https://example.com/coffee.zip")
} else {
check("the source is the zip", false)
}
check("a store extension needs no build", !coffee.needsBuild)
check(
"a truncated body throws",
(try? ExtensionStoreResponse.parseStore(Data("{".utf8), registry: .store)) == nil)
let url = ExtensionStoreResponse.searchURL(query: "co ffee", page: 2)?.absoluteString ?? ""
check("the query is escaped", url.contains("q=co%20ffee"))
check("the page is passed", url.contains("page=2"))
// Tinycast is macOS-only; a windows-only extension is noise.
check("macOS is requested", url.contains("platform=macos"))
}
// MARK: - A GitHub registry
static func gitHubContents() {
print("\n# github contents")
let payload = """
[{"name":"src","path":"extensions/coffee/src","type":"dir","download_url":null},
{"name":"package.json","path":"extensions/coffee/package.json","type":"file",
"size":6486,"download_url":"https://raw.example/package.json"}]
"""
guard let entries = try? ExtensionStoreResponse.parseContents(Data(payload.utf8)) else {
check("a listing parses", false)
return
}
check("both entries parse", entries.count == 2)
check("a directory is flagged", entries[0].isDirectory)
check("a file is not", !entries[1].isDirectory)
check("the download URL is read", entries[1].downloadURL == "https://raw.example/package.json")
// GitHub answers a rate limit or a bad ref with an object where an array was expected.
let rejection = #"{"message":"API rate limit exceeded"}"#
do {
_ = try ExtensionStoreResponse.parseContents(Data(rejection.utf8))
check("a rejection throws", false)
} catch {
check(
"a rejection surfaces GitHub's own words",
error.localizedDescription.contains("rate limit"))
}
let url =
ExtensionStoreResponse.contentsURL(
owner: "raycast", repository: "extensions", path: "extensions/coffee", ref: "abc"
)?.absoluteString ?? ""
check(
"the contents URL is built",
url.hasPrefix("https://api.github.com/repos/raycast/extensions/contents/extensions/coffee"))
check("with the ref", url.contains("ref=abc"))
}
static func manifestSummary() {
print("\n# manifest summary")
let registry = ExtensionRegistry.officialGitHub
let manifest = """
{"name":"coffee","title":"Coffee","description":"Prevent sleep","author":"mooxl",
"icon":"extension-icon.png","commands":[{"name":"caffeinate"}]}
"""
guard
let listing = ExtensionStoreResponse.parseManifestSummary(
Data(manifest.utf8), folder: "coffee", registry: registry)
else {
check("a manifest parses", false)
return
}
check("the title is read", listing.title == "Coffee")
check("the author is read", listing.author == "mooxl")
check("commands are counted", listing.commandCount == 1)
check("no download count is claimed", listing.downloadCount == nil)
check("source has to be built", listing.needsBuild)
check(
"the icon points into the repository",
listing.iconURL?.absoluteString
== "https://raw.githubusercontent.com/raycast/extensions/main/extensions/coffee/assets/extension-icon.png"
)
if case .githubFolder(let owner, _, let path, let ref) = listing.source {
check("the folder is addressed", owner == "raycast" && path == "extensions/coffee")
check("at the registry's ref", ref == "main")
} else {
check("the folder is addressed", false)
}
check(
"a manifest with no name is skipped",
ExtensionStoreResponse.parseManifestSummary(
Data(#"{"description":"x"}"#.utf8), folder: "x", registry: registry) == nil)
check(
"junk is skipped",
ExtensionStoreResponse.parseManifestSummary(
Data("not json".utf8), folder: "x", registry: registry) == nil)
}
// MARK: - Package managers
static func packageManagers() {
print("\n# package managers")
check("automatic is first", ExtensionPackageManager.allCases.first == .automatic)
check(
"every real manager has an executable",
ExtensionPackageManager.allCases.filter { $0 != .automatic }
.allSatisfy { !$0.executableName.isEmpty })
check(
"every real manager can install",
ExtensionPackageManager.allCases.filter { $0 != .automatic }
.allSatisfy { !$0.installArguments.isEmpty })
check(
"every real manager runs the build script",
ExtensionPackageManager.allCases.filter { $0 != .automatic }
.allSatisfy { $0.buildArguments == ["run", "build"] })
// An extension's postinstall is code we never asked to run; the build script is the contract.
check(
"installs skip lifecycle scripts",
ExtensionPackageManager.allCases.filter { $0 != .automatic }
.allSatisfy { $0.installArguments.contains("--ignore-scripts") })
check(
"the preference order covers every real manager",
Set(ExtensionPackageManager.preferenceOrder)
== Set(ExtensionPackageManager.allCases.filter { $0 != .automatic }))
check("npm is the last resort", ExtensionPackageManager.preferenceOrder.last == .npm)
check(
"homebrew is on the search path",
ExtensionPackageManager.searchPaths.contains("/opt/homebrew/bin"))
check(
"so is Intel homebrew",
ExtensionPackageManager.searchPaths.contains("/usr/local/bin"))
check(
"and a version manager's shims",
ExtensionPackageManager.searchPaths.contains { $0.hasSuffix(".volta/bin") })
// Resolution reads the real filesystem, so only its shape is asserted here.
let resolved = ExtensionPackageManager.npm.resolve()
check(
"resolution returns the manager it found",
resolved == nil || resolved?.manager == .npm)
}
static func abbreviation() {
print("\n# download counts")
check("under a thousand is exact", ExtensionListing.abbreviate(942) == "942")
check("thousands are k", ExtensionListing.abbreviate(124_218) == "124k")
check("millions keep a decimal", ExtensionListing.abbreviate(1_240_000) == "1.2M")
check("big millions don't", ExtensionListing.abbreviate(24_000_000) == "24M")
check("zero is zero", ExtensionListing.abbreviate(0) == "0")
}
// MARK: - Helpers
static func check(_ description: String, _ condition: Bool) {
if condition {
passes += 1
print("PASS \(description)")
} else {
failures += 1
print("FAIL \(description)")
}
}
}
+779
View File
@@ -0,0 +1,779 @@
// Standalone test for the Raycast-extension runtime — compiles the *real* engine sources (no copy to
// sync) and runs them against JavaScriptCore, exactly as the app does:
//
// swiftc -parse-as-library \
// Tinycast/Core/Extensions/{ExtensionRuntime,ExtensionNodeShims,ExtensionBootConfig,ExtensionManifest,ExtensionScreen,RenderNode}.swift \
// via Scripts/run-tests.sh ext-test
// -o /tmp/ext-test && /tmp/ext-test
//
// With no arguments it runs the built-in checks (manifest parsing, tree decoding, screen flattening,
// and a synthetic command end-to-end through JSC). Pass a directory to run a real extension:
//
// /tmp/ext-test ~/.config/raycast/extensions/<uuid> [command-name]
import Foundation
@main
struct ExtensionTests {
static func main() async {
let arguments = Array(CommandLine.arguments.dropFirst())
if let directory = arguments.first {
await runInstalledExtension(
directory: URL(fileURLWithPath: directory), command: arguments.dropFirst().first)
} else {
await runChecks()
}
}
// MARK: - Harness plumbing
/// Records every host call and answers it. `proc` and `fetch` go through the app's real
/// implementations, so an extension that shells out or hits the network behaves as it would in the
/// app; everything main-actor-shaped (clipboard, window, storage) gets a canned answer.
@MainActor
final class StubHost: ExtensionHostAPI {
var calls: [String] = []
var toasts: [String] = []
var huds: [String] = []
private let fetcher = ExtensionFetcher()
func perform(api: String, method: String, arguments: [RenderValue]) async throws -> String {
calls.append("\(api).\(method)")
if api == "proc", method == "run" {
if ProcessInfo.processInfo.environment["EXT_TEST_VERBOSE"] != nil {
let spec = arguments.first?.objectValue ?? [:]
let args = (spec["args"]?.arrayValue ?? []).compactMap(\.stringValue)
print(
" proc.run: \(spec["command"]?.stringValue ?? "?") \(args.joined(separator: " "))"
+ " [shell=\(spec["shell"]?.boolValue ?? false) detached=\(spec["detached"]?.boolValue ?? false)]"
)
}
return ExtensionRuntime.jsonString(
from: try await ExtensionAsyncProcess.run(arguments.first))
}
if api == "fetch" {
return ExtensionRuntime.jsonString(from: try await fetcher.request(arguments.first))
}
switch "\(api).\(method)" {
case "feedback.showToast":
toasts.append(arguments.first?.objectValue?["title"]?.stringValue ?? "")
return "1"
case "feedback.showHUD":
huds.append(arguments.first?.stringValue ?? "")
return ""
case "storage.get", "clipboard.readText":
return #""""#
case "storage.all":
return "{}"
case "system.frontmostApplication":
return
#"{"name":"Finder","path":"/System/Library/CoreServices/Finder.app","bundleId":"com.apple.finder"}"#
case "system.applications":
return "[]"
default:
return ""
}
}
}
@MainActor
final class Recorder: ExtensionRuntimeDelegate {
var trees: [RenderTree] = []
var failures: [String] = []
var logs: [String] = []
var finished = false
func runtime(_ runtime: ExtensionRuntime, session: String, didRender tree: RenderTree) {
trees.append(tree)
}
func runtime(_ runtime: ExtensionRuntime, session: String, didFail message: String) {
failures.append(message)
}
func runtime(_ runtime: ExtensionRuntime, session: String, navigationDepth: Int) {}
func runtime(_ runtime: ExtensionRuntime, session: String, didFinish: Void) { finished = true }
func runtime(_ runtime: ExtensionRuntime, log level: String, message: String) {
logs.append("[\(level)] \(message)")
}
}
/// The generated runtime, found relative to this source file's repository.
static func runtimeURL() -> URL {
let candidates = [
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent("Tinycast/Resources/RaycastRuntime.generated.js"),
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent()
.appendingPathComponent("Tinycast/Resources/RaycastRuntime.generated.js")
]
return candidates.first { FileManager.default.fileExists(atPath: $0.path) } ?? candidates[0]
}
@MainActor
static func makeRuntime() -> (ExtensionRuntime, StubHost, Recorder) {
let host = StubHost()
let recorder = Recorder()
let runtime = ExtensionRuntime(hostAPI: host, runtimeURL: runtimeURL())
runtime.setDelegate(recorder)
return (runtime, host, recorder)
}
static func launchContext(
extensionName: String = "fixture", command: String = "fixture",
mode: ExtensionCommandMode = .view, assets: String = "/tmp",
preferences: [String: ExtensionPreferenceValue] = [:],
arguments: [String: String] = [:]
) -> ExtensionLaunchContext {
ExtensionLaunchContext(
extensionName: extensionName, extensionTitle: extensionName, commandName: command,
commandMode: mode, assetsPath: assets, supportPath: "/tmp",
preferences: preferences, caches: [:], arguments: arguments, fallbackText: nil)
}
/// `EXT_TEST_ARGS="hours=0,minutes=5"` — stands in for the palette's inline argument fields.
static func environmentArguments() -> [String: String] {
guard let raw = ProcessInfo.processInfo.environment["EXT_TEST_ARGS"] else { return [:] }
var arguments: [String: String] = [:]
for pair in raw.split(separator: ",") {
let parts = pair.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else { continue }
arguments[String(parts[0])] = String(parts[1])
}
return arguments
}
/// `EXT_TEST_PREFS` as JSON — strings and bools only, which is what a manifest preference holds.
static func environmentPreferences() -> [String: ExtensionPreferenceValue] {
guard let raw = ProcessInfo.processInfo.environment["EXT_TEST_PREFS"],
let json = try? JSONSerialization.jsonObject(with: Data(raw.utf8)) as? [String: Any]
else { return [:] }
return json.compactMapValues { value in
if let flag = value as? Bool { return .bool(flag) }
if let text = value as? String { return .string(text) }
return nil
}
}
/// Let the JS event loop and the main-actor host hops settle.
static func settle(_ milliseconds: UInt64 = 250) async {
try? await Task.sleep(nanoseconds: milliseconds * 1_000_000)
}
// MARK: - Results
nonisolated(unsafe) static var failures = 0
nonisolated(unsafe) static var passes = 0
static func check(_ label: String, _ condition: Bool, _ detail: String = "") {
if condition {
passes += 1
} else {
failures += 1
print("FAIL \(label)\(detail.isEmpty ? "" : "\n \(detail)")")
}
}
// MARK: - Checks
@MainActor
static func runChecks() async {
manifestChecks()
renderNodeChecks()
screenChecks()
await runtimeChecks()
print("\n\(passes) passed, \(failures) failed")
exit(failures == 0 ? 0 : 1)
}
static func manifestChecks() {
let json: [String: Any] = [
"name": "demo", "title": "Demo", "description": "d", "author": "a",
"icon": "icon.png", "platforms": ["macOS", "Windows"],
"preferences": [
["name": "token", "type": "password", "required": true],
// A platform-keyed default must resolve to the macOS value.
[
"name": "socket", "type": "textfield",
"default": ["macOS": "/var/run/x.sock", "Windows": "\\\\pipe"]
],
["name": "flag", "type": "checkbox", "title": "Flag"],
[
"name": "mode", "type": "dropdown", "default": "b",
"data": [["title": "A", "value": "a"], ["title": "B", "value": "b"]]
]
],
"commands": [
["name": "search", "title": "Search", "mode": "view", "keywords": ["find"]],
["name": "toggle", "title": "Toggle", "mode": "no-view"],
["name": "bar", "title": "Bar", "mode": "menu-bar"],
[
"name": "args", "title": "Args", "mode": "view",
"arguments": [["name": "q", "required": true, "placeholder": "Query"]]
]
]
]
guard let manifest = ExtensionManifest(json: json) else {
check("manifest parses", false)
return
}
check("manifest parses", true)
check("title", manifest.title == "Demo")
check("supports macOS", manifest.supportsMacOS)
check("commands", manifest.commands.count == 4, "\(manifest.commands.count)")
check("view mode", manifest.commands[0].mode == .view)
check("no-view mode", manifest.commands[1].mode == .noView)
check("menu-bar is unsupported", manifest.commands[2].mode.isSupported == false)
check("menu-bar explains itself", manifest.commands[2].mode.unsupportedReason != nil)
check("keywords", manifest.commands[0].keywords == ["find"])
check("arguments", manifest.commands[3].arguments.first?.name == "q")
check("argument required", manifest.commands[3].arguments.first?.required == true)
// A blank optional argument must arrive as "", never absent: extensions do `Number(args.x)`,
// which is 0 for "" but NaN for undefined.
check(
"unfilled arguments are completed to empty strings",
manifest.commands[3].completeArguments([:]) == ["q": ""],
String(describing: manifest.commands[3].completeArguments([:])))
check(
"provided arguments survive completion",
manifest.commands[3].completeArguments(["q": "hi"]) == ["q": "hi"])
let prefs = Dictionary(uniqueKeysWithValues: manifest.preferences.map { ($0.name, $0) })
check("password kind", prefs["token"]?.kind == .password)
check("required flagged", prefs["token"]?.required == true)
check(
"platform-keyed default resolves to macOS",
prefs["socket"]?.defaultValue == .string("/var/run/x.sock"),
String(describing: prefs["socket"]?.defaultValue))
check(
"checkbox with no default is false",
prefs["flag"]?.effectiveDefault == .bool(false),
String(describing: prefs["flag"]?.effectiveDefault))
check("dropdown options", prefs["mode"]?.options.count == 2)
check("dropdown default", prefs["mode"]?.effectiveDefault == .string("b"))
// A manifest with no commands isn't an extension Tinycast can run.
check("rejects a manifest with no commands", ExtensionManifest(json: ["name": "x"]) == nil)
check(
"rejects a Windows-only manifest",
ExtensionManifest(
json: [
"name": "w", "platforms": ["Windows"],
"commands": [["name": "c", "title": "C"]]
])?.supportsMacOS == false)
// Launcher round-trip: an entry id must decode back to the same command.
let reference = ExtensionCommandRef(extensionName: "@scope/demo", commandName: "search")
let decoded = ExtensionCommandRef(entryID: reference.entryID)
check(
"command ref round-trips", decoded == reference,
"\(reference.entryID) → \(String(describing: decoded))")
check(
"non-extension entry id is rejected",
ExtensionCommandRef(entryID: "/Applications/Mail.app") == nil)
}
static func renderNodeChecks() {
let json = """
{"children":[{"id":1,"type":"__screen","props":{"active":true},"children":[
{"id":2,"type":"List","props":{"isLoading":false,"filtering":true,
"actions":{"id":9,"type":"ActionPanel","props":{},"children":[]}},
"children":[
{"id":3,"type":"List.Item","props":{
"title":"Row","subtitle":"sub","keywords":["kw"],
"icon":{"source":"circle-16","tintColor":"raycast-green"},
"accessories":[{"text":"3"},{"tag":{"value":"live","color":"#ff0000"}}],
"due":{"$date":"2026-01-02T03:04:05.678Z"},
"actions":{"id":4,"type":"ActionPanel","props":{},"children":[
{"id":5,"type":"Action","props":{"title":"Go","onAction":{"$fn":"5:onAction"},
"shortcut":{"modifiers":["cmd","shift"],"key":"g"}},"children":[]},
{"id":6,"type":"ActionPanel.Section","props":{"title":"More"},"children":[
{"id":7,"type":"Action","props":{"title":"Nested","style":"destructive"},"children":[]}]}]}},
"children":[]}]}]}]}
"""
guard let tree = RenderTree(json: json) else {
check("render tree decodes", false)
return
}
check("render tree decodes", true)
check("one screen", tree.screens.count == 1)
check("active screen resolves", tree.active?.bool("active") == true)
check("active root is the List", tree.activeRoot?.type == "List")
guard let list = tree.activeRoot, let item = list.children.first else {
check("list has an item", false)
return
}
check("list has an item", true)
check("string prop", item.string("title") == "Row")
check("bool prop", list.bool("filtering") == true)
check("array prop", item.array("keywords").compactMap(\.stringValue) == ["kw"])
check("nested object prop", item.object("icon")?["source"]?.stringValue == "circle-16")
check("accessories decode", item.array("accessories").count == 2)
// 2026-01-02T03:04:05.678Z
let expectedDue = DateComponents(
calendar: Calendar(identifier: .gregorian), timeZone: TimeZone(identifier: "UTC"),
year: 2026, month: 1, day: 2, hour: 3, minute: 4, second: 5
).date!
check(
"date prop revives",
item.date("due").map { abs($0.timeIntervalSince(expectedDue) - 0.678) < 0.01 } == true,
String(describing: item.date("due")))
check("slot prop becomes a node", item.node("actions")?.type == "ActionPanel")
check("screen-level actions slot", list.node("actions")?.id == 9)
// A number that happens to be whole must not read back as "3.0".
check(
"whole numbers stringify without a decimal",
RenderValue.number(3).stringValue == "3", RenderValue.number(3).stringValue ?? "nil")
check("bools aren't numbers", RenderValue.bool(true).boolValue == true)
check(
"a plain object prop isn't mistaken for a node",
RenderValue(json: ["type": "day", "min": 1] as [String: Any]).nodeValue == nil)
let actions = ExtensionScreen.actions(in: item.node("actions"))
check("actions flatten across sections", actions.count == 2, "\(actions.count)")
check("first action title", actions.first?.title == "Go")
check("handler id survives", actions.first?.handler == "5:onAction")
check(
"shortcut renders as keycaps", actions.first?.shortcutCaps == ["⌘", "⇧", "G"],
String(describing: actions.first?.shortcutCaps))
check("section title carried", actions.last?.section == "More")
check("destructive style", actions.last?.isDestructive == true)
}
static func screenChecks() {
func tree(_ children: String) -> RenderTree {
RenderTree(
json: """
{"children":[{"id":1,"type":"__screen","props":{"active":true},"children":[\(children)]}]}
""")!
}
let listJSON = """
{"id":2,"type":"List","props":{"filtering":true,"searchBarPlaceholder":"Find…"},"children":[
{"id":3,"type":"List.Section","props":{"title":"Alpha","subtitle":"two"},"children":[
{"id":4,"type":"List.Item","props":{"title":"Apple"},"children":[]},
{"id":5,"type":"List.Item","props":{"title":"Banana"},"children":[]}]},
{"id":6,"type":"List.Item","props":{"title":"Cherry","keywords":["red"]},"children":[]}]}
"""
let list = ExtensionScreen(tree: tree(listJSON), query: "")
check("kind is list", list.kind == .list)
check("placeholder", list.searchPlaceholder == "Find…")
check("filters locally", list.filtersLocally)
check(
"items flattened in order",
list.items.map { $0.string("title") } == ["Apple", "Banana", "Cherry"])
check("rows interleave the section header", list.rows.count == 4, "\(list.rows.count)")
if case .header(let title, let subtitle, _) = list.rows.first {
check("header title", title == "Alpha")
check("header subtitle", subtitle == "two")
} else {
check("first row is a header", false)
}
// Filtering keeps row order and drops now-empty sections along with their header.
let filtered = ExtensionScreen(tree: tree(listJSON), query: "an")
check(
"filter matches title and keyword",
filtered.items.map { $0.string("title") } == ["Banana"],
String(describing: filtered.items.map { $0.string("title") }))
check("empty section drops its header", filtered.rows.count == 2, "\(filtered.rows.count)")
let byKeyword = ExtensionScreen(tree: tree(listJSON), query: "red")
check("keyword match", byKeyword.items.map { $0.string("title") } == ["Cherry"])
// A command that owns the search text must not be filtered behind its back.
let controlled = ExtensionScreen(
tree: tree(
"""
{"id":2,"type":"List","props":{"onSearchTextChange":{"$fn":"2:onSearchTextChange"}},"children":[
{"id":3,"type":"List.Item","props":{"title":"Apple"},"children":[]}]}
"""), query: "zzz")
check("onSearchTextChange disables local filtering", controlled.filtersLocally == false)
check("controlled rows survive a non-matching query", controlled.items.count == 1)
check("search handler exposed", controlled.searchTextHandler == "2:onSearchTextChange")
let grid = ExtensionScreen(
tree: tree(
"""
{"id":2,"type":"Grid","props":{"columns":4},"children":[
{"id":3,"type":"Grid.Item","props":{"title":"One"},"children":[]}]}
"""), query: "")
check("kind is grid with columns", grid.kind == .grid(columns: 4), String(describing: grid.kind))
let form = ExtensionScreen(
tree: tree(
"""
{"id":2,"type":"Form","props":{},"children":[
{"id":3,"type":"Form.TextField","props":{"id":"name","value":"Ada"},"children":[]},
{"id":4,"type":"Form.Separator","props":{},"children":[]}]}
"""), query: "")
check("kind is form", form.kind == .form)
check("fields collected", form.fields.count == 2)
check("form has no selectable rows", form.items.isEmpty)
let detail = ExtensionScreen(
// Doubled delimiters: the markdown heading contains `"#`, which closes a single-# raw string.
tree: tree(##"{"id":2,"type":"Detail","props":{"markdown":"# Hi"},"children":[]}"##),
query: "")
check("kind is detail", detail.kind == .detail)
let unsupported = ExtensionScreen(
tree: tree(#"{"id":2,"type":"MenuBarExtra","props":{},"children":[]}"#), query: "")
check(
"unknown root reported", unsupported.kind == .unsupported("MenuBarExtra"),
String(describing: unsupported.kind))
// An item's own panel wins; the screen's is the fallback.
let panels = ExtensionScreen(
tree: tree(
"""
{"id":2,"type":"List","props":{"actions":{"id":8,"type":"ActionPanel","props":{},"children":[]}},"children":[
{"id":3,"type":"List.Item","props":{"title":"A","actions":{"id":9,"type":"ActionPanel","props":{},"children":[]}},"children":[]},
{"id":4,"type":"List.Item","props":{"title":"B"},"children":[]}]}
"""), query: "")
check("item panel wins", panels.actionPanel(forItemAt: 0)?.id == 9)
check("screen panel is the fallback", panels.actionPanel(forItemAt: 1)?.id == 8)
check("out-of-range selection falls back", panels.actionPanel(forItemAt: 99)?.id == 8)
}
// MARK: - End-to-end through JavaScriptCore
@MainActor
static func runtimeChecks() async {
let runtimeFile = runtimeURL()
guard FileManager.default.fileExists(atPath: runtimeFile.path) else {
check("RaycastRuntime.generated.js exists", false, runtimeFile.path)
return
}
check("RaycastRuntime.generated.js exists", true)
let (runtime, host, recorder) = makeRuntime()
do {
try await runtime.boot(
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
check("runtime boots in JavaScriptCore", true)
} catch {
check("runtime boots in JavaScriptCore", false, error.localizedDescription)
return
}
// A synthetic command exercising React state, the node shims, timers and host calls.
let command = """
"use strict";
const { List, ActionPanel, Action, Icon, showToast, Toast } = require("@raycast/api");
const React = require("react");
const path = require("node:path");
const crypto = require("node:crypto");
const h = React.createElement;
module.exports.default = function Command() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const timer = setTimeout(() => setCount(1), 20);
showToast({ style: Toast.Style.Success, title: "hello" });
return () => clearTimeout(timer);
}, []);
const digest = crypto.createHash("sha256").update("abc").digest("hex").slice(0, 8);
// AbortSignal's statics, not just its instance shape: an extension reaching for
// `AbortSignal.timeout` used to get "is not a function" at runtime.
const abortable = [
typeof AbortSignal.timeout, typeof AbortSignal.abort, typeof AbortSignal.any,
String(AbortSignal.timeout(5e3).aborted), AbortSignal.abort().reason.name,
].join(",");
return h(List, { navigationTitle: "Synthetic", isLoading: false },
h(List.Item, {
title: "count=" + count,
subtitle: path.join("/a/b", "../c"),
icon: Icon.Circle,
accessories: [{ text: digest }, { text: abortable }],
actions: h(ActionPanel, null,
h(Action, { title: "Bump", onAction: () => setCount((v) => v + 10) }))
}));
};
"""
await runtime.start(
session: "s1", code: command, file: URL(fileURLWithPath: "/tmp/synthetic.js"),
mode: .view, context: launchContext())
await settle()
check("no failures", recorder.failures.isEmpty, recorder.failures.joined(separator: "\n"))
check("rendered at least once", !recorder.trees.isEmpty)
guard var screen = recorder.trees.last.map({ ExtensionScreen(tree: $0, query: "") }) else {
check("screen builds from the live tree", false)
return
}
check("screen builds from the live tree", true)
check("navigation title", screen.navigationTitle == "Synthetic")
check(
"timer fired and re-rendered",
screen.items.first?.string("title") == "count=1",
screen.items.first?.string("title") ?? "nil")
check(
"node path shim", screen.items.first?.string("subtitle") == "/a/c",
screen.items.first?.string("subtitle") ?? "nil")
check(
"crypto shim",
ExtensionAccessoriesView_labelForTest(screen.items.first?.array("accessories").first)
== "ba7816bf",
String(describing: screen.items.first?.array("accessories").first))
check("toast reached the host", host.toasts == ["hello"], host.toasts.joined(separator: ","))
check(
"AbortSignal carries its statics",
ExtensionAccessoriesView_labelForTest(screen.items.first?.array("accessories").last)
== "function,function,function,false,AbortError",
String(describing: screen.items.first?.array("accessories").last))
// Dispatch the row's action and confirm the re-render.
let actions = ExtensionScreen.actions(in: screen.actionPanel(forItemAt: 0))
check("action is dispatchable", actions.first?.handler != nil)
if let handler = actions.first?.handler {
await runtime.dispatch(
session: "s1", handler: handler,
payload: ExtensionRuntime.jsonString(from: []))
await settle()
screen = ExtensionScreen(tree: recorder.trees.last!, query: "")
check(
"action re-rendered the row",
screen.items.first?.string("title") == "count=11",
screen.items.first?.string("title") ?? "nil")
}
// Command arguments must reach `props.arguments`, and the bag must exist even when empty.
let (withArguments, _, argumentRecorder) = makeRuntime()
try? await withArguments.boot(
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
let argumentCommand = #"""
"use strict";
const { Detail } = require("@raycast/api");
const React = require("react");
module.exports.default = function (props) {
const bag = props.arguments;
return React.createElement(Detail, {
markdown: [bag.hours, bag.minutes, Object.keys(bag).length, props.launchType].join("|"),
});
};
"""#
await withArguments.start(
session: "sA", code: argumentCommand, file: URL(fileURLWithPath: "/tmp/args.js"),
mode: .view, context: launchContext(arguments: ["hours": "0", "minutes": "5"]))
await settle()
let argumentMarkdown = argumentRecorder.trees.last?.activeRoot?.string("markdown") ?? ""
check(
"launch arguments reach props.arguments", argumentMarkdown == "0|5|2|userInitiated",
argumentMarkdown)
await withArguments.stop(session: "sA")
// Running a second command in the same runtime must work exactly like the first — the JSContext
// and React's scheduler are shared across sessions.
// Stop while a timer is still pending — what closing the palette mid-refresh does. React's
// scheduler drives commits through `setTimeout`, so anything that kills timers it doesn't own
// can wedge every later session.
let (pending, _, pendingRecorder) = makeRuntime()
try? await pending.boot(
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
let tickingCommand = #"""
"use strict";
const { List } = require("@raycast/api");
const React = require("react");
module.exports.default = function Command() {
const [tick, setTick] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setTick((v) => v + 1), 40);
return () => clearInterval(id);
}, []);
return React.createElement(List, null,
React.createElement(List.Item, { key: "t", title: "tick=" + tick }));
};
"""#
await pending.start(
session: "p1", code: tickingCommand, file: URL(fileURLWithPath: "/tmp/tick.js"),
mode: .view, context: launchContext())
await settle(70)
await pending.stop(session: "p1")
pending.shutdown()
let pendingRerun = Recorder()
pending.setDelegate(pendingRerun)
try? await pending.boot(
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
await pending.start(
session: "p2", code: tickingCommand, file: URL(fileURLWithPath: "/tmp/tick.js"),
mode: .view, context: launchContext())
await settle(150)
check(
"a re-run after stopping mid-timer still renders",
!pendingRerun.trees.isEmpty,
"first run rendered \(pendingRecorder.trees.count)×; "
+ pendingRerun.failures.joined(separator: "|"))
await pending.stop(session: "p2")
await runtime.stop(session: "s1")
let rerunRecorder = Recorder()
runtime.setDelegate(rerunRecorder)
await runtime.start(
session: "s1b", code: command, file: URL(fileURLWithPath: "/tmp/synthetic.js"),
mode: .view, context: launchContext())
await settle()
check(
"a second run in the same runtime renders",
!rerunRecorder.trees.isEmpty, rerunRecorder.failures.joined(separator: "|"))
check(
"the second run's timers still fire",
rerunRecorder.trees.last
.map { ExtensionScreen(tree: $0, query: "").items.first?.string("title") == "count=1" }
== true,
rerunRecorder.trees.last
.flatMap { ExtensionScreen(tree: $0, query: "").items.first?.string("title") } ?? "no tree")
await runtime.stop(session: "s1b")
runtime.setDelegate(recorder)
// A no-view command runs headless and reports completion.
let (headless, headlessHost, headlessRecorder) = makeRuntime()
try? await headless.boot(
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
await headless.start(
session: "s2",
code: """
"use strict";
const { showHUD } = require("@raycast/api");
module.exports.default = async function () { await showHUD("done"); };
""",
file: URL(fileURLWithPath: "/tmp/headless.js"), mode: .noView,
context: launchContext(mode: .noView))
await settle()
check("no-view command finished", headlessRecorder.finished, headlessRecorder.failures.joined())
check("no-view HUD reached the host", headlessHost.huds == ["done"])
await headless.stop(session: "s2")
// A throwing command surfaces its error rather than taking the runtime down.
let (failing, _, failingRecorder) = makeRuntime()
try? await failing.boot(
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
await failing.start(
session: "s3",
code: #"module.exports.default = function () { throw new Error("kaboom"); };"#,
file: URL(fileURLWithPath: "/tmp/bad.js"), mode: .view, context: launchContext())
await settle()
check(
"a throwing command reports a failure",
failingRecorder.failures.contains { $0.contains("kaboom") },
failingRecorder.failures.joined(separator: "|"))
await failing.stop(session: "s3")
zlibChecks()
}
/// `zlib` is the one node shim with no JS-side implementation to lean on.
static func zlibChecks() {
let payload = Data(String(repeating: "tinycast extensions ", count: 64).utf8)
do {
check("gzip round-trips", try Zlib.gunzip(Zlib.gzip(payload)) == payload)
check("zlib round-trips", try Zlib.inflate(Zlib.deflate(payload)) == payload)
check("raw deflate round-trips", try Zlib.inflateRaw(Zlib.deflateRaw(payload)) == payload)
check("gzip actually compresses", try Zlib.gzip(payload).count < payload.count)
} catch {
check("zlib round-trips", false, error.localizedDescription)
}
// Known-answer checks so a framing bug can't hide behind a self-consistent round-trip.
check("crc32", Zlib.crc32(Data("123456789".utf8)) == 0xCBF4_3926)
check("adler32", Zlib.adler32(Data("123456789".utf8)) == 0x091E_01DE)
}
// MARK: - Running a real extension
@MainActor
static func runInstalledExtension(directory: URL, command commandName: String?) async {
guard let manifest = try? ExtensionManifest.load(directory: directory) else {
print("Not an extension: \(directory.path)")
exit(1)
}
let runnable = manifest.commands.filter { $0.mode.isSupported }
guard
let target = commandName.flatMap({ name in runnable.first { $0.name == name } })
?? runnable.first
else {
print("No runnable command in \(manifest.title)")
exit(1)
}
let bundle = directory.appendingPathComponent("\(target.name).js")
guard let code = try? String(contentsOf: bundle, encoding: .utf8) else {
print("Missing built bundle: \(bundle.path)")
exit(1)
}
print("▶ \(manifest.title) — \(target.title) (\(target.mode.rawValue))")
let (runtime, host, recorder) = makeRuntime()
do {
try await runtime.boot(config: .current(supportDirectory: FileManager.default.temporaryDirectory))
} catch {
print("boot failed: \(error.localizedDescription)")
exit(1)
}
var preferences: [String: ExtensionPreferenceValue] = [:]
for schema in manifest.preferences + target.preferences {
preferences[schema.name] = schema.effectiveDefault
}
// `EXT_TEST_PREFS={"version":"v8"}` stands in for what the user set in Settings — plenty of
// extensions branch on a preference that has no manifest default.
for (key, value) in environmentPreferences() { preferences[key] = value }
let context = launchContext(
extensionName: manifest.name, command: target.name, mode: target.mode,
assets: directory.appendingPathComponent("assets").path, preferences: preferences,
arguments: target.completeArguments(environmentArguments()))
let settleMS = UInt64(
ProcessInfo.processInfo.environment["EXT_TEST_SETTLE_MS"].flatMap(UInt64.init) ?? 1500)
// `EXT_TEST_RERUN=1` runs the command, tears it down the way the palette does, and runs it
// again in the same runtime — the "works once, then hangs" case.
if ProcessInfo.processInfo.environment["EXT_TEST_RERUN"] != nil {
await runtime.start(
session: "r1", code: code, file: bundle, mode: target.mode, context: context)
await settle(settleMS)
print("run 1: \(recorder.trees.count) render(s), \(recorder.failures.count) failure(s)")
await runtime.stop(session: "r1")
runtime.shutdown()
let first = recorder.trees.count
try? await runtime.boot(
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
print("→ tore down after \(first) render(s); re-running in a fresh context")
}
await runtime.start(
session: "s1", code: code, file: bundle, mode: target.mode, context: context)
await settle(settleMS)
for failure in recorder.failures { print("✗ \(failure)") }
if ProcessInfo.processInfo.environment["EXT_TEST_VERBOSE"] != nil {
for line in recorder.logs { print(" \(line)") }
}
print(
"\(recorder.trees.count) render(s); host calls: \(Set(host.calls).sorted().joined(separator: ", "))"
)
for toast in host.toasts { print(" toast: \(toast)") }
for hud in host.huds { print(" hud: \(hud)") }
if let tree = recorder.trees.last {
let screen = ExtensionScreen(tree: tree, query: "")
print("root: \(screen.kind) rows: \(screen.rows.count) fields: \(screen.fields.count)")
for item in screen.items.prefix(12) {
let accessories = item.array("accessories").count
print(
" • \(item.string("title") ?? "")"
+ (item.string("subtitle").map { " — \($0)" } ?? "")
+ (accessories > 0 ? " [\(accessories) accessories]" : "")
+ (item.node("actions") != nil ? " ⌘K" : ""))
}
if case .detail = screen.kind {
print(" markdown: \((screen.root?.string("markdown") ?? "").prefix(200))")
}
}
await runtime.stop(session: "s1")
exit(recorder.failures.isEmpty ? 0 : 1)
}
}
/// The accessory label rule lives in the view layer; mirror just the string case for the harness.
private func ExtensionAccessoriesView_labelForTest(_ value: RenderValue?) -> String? {
guard let value else { return nil }
if let text = value.stringValue { return text }
return value.objectValue?["text"]?.stringValue
}
-8
View File
@@ -270,7 +270,6 @@ struct FuzzTest {
print("\n# identifier fields")
check("bundle-id vendor component matches", rank("openai").contains("ChatGPT"))
check("bundle-id app component matches", rank("codex").contains("ChatGPT"))
check("the trimmed bundle id matches as a prefix", rank("openai.co").contains("ChatGPT"))
check("a pasted full bundle id matches", rank("com.openai.codex").contains("ChatGPT"))
check(
@@ -297,9 +296,6 @@ struct FuzzTest {
check(
"executable name does not subsequence-match",
!rank("etn").contains("Visual Studio Code"), "got \(rank("etn"))")
check(
"an identifier hit never outranks any name hit",
above(rank("chrome"), "Google Chrome", "Chess") || !rank("chrome").contains("Chess"))
let noID = SearchFields(names: ["Solo"])
check(
@@ -322,10 +318,6 @@ struct FuzzTest {
"a query longer than every candidate matches nothing",
rank(String(repeating: "z", count: 500)).isEmpty)
check("emoji query does not trap", rank("🙂🙃") == [])
check(
"combining marks do not trap",
SearchRelevance.score(query: "e\u{0301}", fields: fields) == nil
|| SearchRelevance.score(query: "e\u{0301}", fields: fields) != nil)
check(
"an RTL query does not trap",
SearchRelevance.score(query: "\u{202E}safari\u{202C}", fields: fields) != nil)
+20
View File
@@ -55,6 +55,7 @@ struct DoubleTapDetectorTests {
static func main() {
modifierGlyphs()
commandActions()
hyperChord()
hyperRetargeting()
firing()
@@ -84,6 +85,25 @@ struct DoubleTapDetectorTests {
"raw values are the persisted spelling and stay in canonical ⌃⌥⇧⌘ order")
}
// MARK: - Built-in command mappings
static func commandActions() {
let expected: [(CommandID, HotKeyAction, String)] = [
(.searchFiles, .searchFiles, "hotkey.searchFiles"),
(.clipboardHistory, .toggleClipboard, "hotkey.toggleClipboard"),
(.searchEmoji, .toggleEmoji, "hotkey.toggleEmoji"),
]
let answers = CommandID.allCases.compactMap { id in id.hotKeyAction.map { (id, $0) } }
expect(
answers.count == expected.count,
"exactly the bindable commands answer — got \(answers.map(\.0.name))")
for (id, action, key) in expected {
expect(
id.hotKeyAction == action && action.defaultsKey == key,
"\(id.name) maps to \(key)")
}
}
// MARK: - The Hyper chord
/// A combo on the G key, spelled in Carbon like the on-disk shape.
-4
View File
@@ -82,10 +82,6 @@ struct HoverArmingTests {
scrolled.disarmHoverHighlight(pointerAt: rest)
expect(!scrolled.hoverHighlightArmed, "a scroll drops the highlight")
let typed = armed()
typed.disarmHoverHighlight(pointerAt: rest)
expect(!typed.hoverHighlightArmed, "so does a keystroke")
let stillScrolling = armed()
// Every event of the gesture re-anchors, so a hand drifting on the wheel never adds up.
for step in 1...20 {
+27 -2
View File
@@ -1,3 +1,4 @@
import AppKit
import Foundation
@main
@@ -12,13 +13,35 @@ struct IconCacheTests {
}
}
/// A tinted tile is a different icon from the plain one, and from every other tint — the cache
/// key has to carry the colour, or a re-skinned extension serves whatever was drawn first.
static func tintedTiles() {
let plain = bitmap(IconCache.symbolIcon(named: "star"))
let red = bitmap(
IconCache.symbolIcon(named: "star", tint: SymbolTint(key: "red", color: .systemRed)))
let blue = bitmap(
IconCache.symbolIcon(named: "star", tint: SymbolTint(key: "blue", color: .systemBlue)))
expect(plain != nil && red != nil && blue != nil, "every tile rasterizes")
expect(plain != red, "a tinted tile differs from the plain one")
expect(red != blue, "two tints do not share a cache entry")
// Same key twice must hit the cache and give back the identical bitmap.
expect(
red
== bitmap(
IconCache.symbolIcon(
named: "star", tint: SymbolTint(key: "red", color: .systemRed))),
"the same tint is stable")
}
static func bitmap(_ image: NSImage) -> Data? { image.tiffRepresentation }
static func main() {
var generation = IconCacheGeneration()
let captured = generation.value
var stored: [Int] = []
let current = generation.publish(1, capturedAt: captured) { stored.append($0) }
expect(current == 1, "a current decode reaches its caller")
_ = generation.publish(1, capturedAt: captured) { stored.append($0) }
expect(stored == [1], "a current decode populates the cache")
generation.invalidate()
@@ -26,6 +49,8 @@ struct IconCacheTests {
expect(stale == 2, "a stale decode still reaches its active caller")
expect(stored == [1], "a stale decode cannot repopulate the cache")
tintedTiles()
print(failures == 0 ? "Icon cache tests passed" : "\(failures) tests failed")
exit(failures == 0 ? 0 : 1)
}
-1
View File
@@ -60,7 +60,6 @@ struct PalettePlacementTests {
static func theDefaultPlacement() {
let anchor = home(laptop)
expect(anchor.x, laptop.midX - width / 2, "the panel centres horizontally")
expect(anchor.x + width, laptop.midX + width / 2, "and its right edge mirrors its left")
expect(
anchor.y, laptop.maxY - laptop.height * topFraction,
"its top edge sits the margin fraction below the top of the visible area")
+4 -7
View File
@@ -139,9 +139,6 @@ enum RaycastTests {
// The IV is the file's first block, not a value derived from the passphrase.
let fixedIV = Data(repeating: 0x11, count: 16)
let pinned = makeV1File(gzippedJSON, passphrase: "12345678", iv: fixedIV)
expect(
Data(pinned.prefix(16)) == fixedIV,
"the file leads with its IV")
expect(
(try? RaycastV1Decoder.decrypt(pinned, passphrase: "12345678")) == plainJSON,
"decrypt reads the IV from the file")
@@ -396,16 +393,16 @@ enum RaycastTests {
expect(snippets?.snippets.last?.keyword == nil, "a blank keyword becomes nil")
}
// MARK: - Gunzip
// MARK: - Zlib
static func gunzipSlices() {
// `decompress` indexes a zero-based copy, so a slice starting elsewhere must not be re-indexed.
var prefixed = Data(repeating: 0xa5, count: 32)
prefixed.append(gzippedJSON)
expect(
(try? Gunzip.decompress(prefixed.dropFirst(32))) == plainJSON,
(try? Zlib.gunzip(prefixed.dropFirst(32))) == plainJSON,
"a non-zero-index gzip slice decompresses instead of trapping")
expect((try? Gunzip.decompress(gzippedJSON)) == plainJSON, "a zero-based gzip still works")
expect((try? Gunzip.decompress(Data(repeating: 0x00, count: 32))) == nil, "non-gzip throws")
expect((try? Zlib.gunzip(gzippedJSON)) == plainJSON, "a zero-based gzip still works")
expect((try? Zlib.gunzip(Data(repeating: 0x00, count: 32))) == nil, "non-gzip throws")
}
}
-1
View File
@@ -33,7 +33,6 @@ struct SettingsHistoryTests {
static func startsEmpty() {
let history = SettingsHistory(current: .general)
expect(history.current == .general, "a fresh history shows the pane it was opened on")
expect(!history.canGoBack, "and has nowhere to go back to")
expect(!history.canGoForward, "or forward to")
}
-17
View File
@@ -934,8 +934,6 @@ struct SnippetsTests {
check(
"duplicate name references resolve by stable path identity",
referenced.text == "A|K|{snippet:missing}")
check("name and keyword references are case-insensitive", referenced.text.hasPrefix("A|K|"))
check("missing references remain visible", referenced.text.hasSuffix("{snippet:missing}"))
let disabledChild = record(
"/tmp/disabled-child.md",
@@ -1431,14 +1429,6 @@ struct SnippetsTests {
check(
"a later health check installs the tap after the grant arrives",
grantsArrived == .init(status: .needsAccessibility, tapAction: .install))
let retryAfterFailure = Lifecycle.decide(
isRequested: true,
isSessionActive: true,
hasAccessibility: true,
tapState: .absent)
check(
"tap creation failure remains retryable on the next health check",
grantsArrived.tapAction == .install && retryAfterFailure.tapAction == .install)
let active = Lifecycle.decide(
isRequested: true,
@@ -1448,13 +1438,6 @@ struct SnippetsTests {
check(
"listener reports active only with the grant and a live tap",
active == .init(status: .active, tapAction: .none))
check(
"repeated start is idempotent when the tap is already active",
Lifecycle.decide(
isRequested: true,
isSessionActive: true,
hasAccessibility: true,
tapState: .active) == active)
let revoked = Lifecycle.decide(
isRequested: true,
+118
View File
@@ -0,0 +1,118 @@
// Standalone test for the SF Symbol catalog — compiles the *real* source (no copy to sync):
// swiftc Tinycast/Core/Extensions/SymbolCatalog.swift Tools/symbols-test.swift -o /tmp/symbols-test && /tmp/symbols-test
//
// It reads this machine's CoreGlyphs bundle, so it asserts shapes and invariants rather than exact
// counts — those move with every macOS release.
import AppKit
@main
@MainActor
struct SymbolTests {
static var failures = 0
static var passes = 0
static func main() {
let catalog = SymbolCatalog.load()
check("the system catalog loads", catalog.symbols.count > 1_000, "got \(catalog.symbols.count)")
check(
"it's more than the curated set",
catalog.symbols.count > SymbolCatalog.suggested.count * 10)
check("no duplicates", Set(catalog.symbols).count == catalog.symbols.count)
// Everything offered has to actually render, or the picker shows an empty tile. The app's
// own marks are asset-catalog images rather than system symbols — they are exactly the ones
// the system cannot draw, which is why they ship — so they're checked separately below.
let unrenderable = catalog.symbols.prefix(400)
.filter { !SymbolCatalog.isBundled($0) }
.filter { NSImage(systemSymbolName: $0, accessibilityDescription: nil) == nil }
check("the first 400 all render", unrenderable.isEmpty, "got \(unrenderable.prefix(5))")
// A bundled mark must not collide with a system symbol name, or the asset would shadow it.
let colliding = SymbolCatalog.bundledGlyphs.filter {
NSImage(systemSymbolName: $0, accessibilityDescription: nil) != nil
}
check(
"bundled marks name nothing the system already has", colliding.isEmpty,
"got \(colliding)")
// Apple's reserved marks must not be on offer.
for reserved in ["icloud", "applelogo", "airplayvideo"] where catalog.symbols.contains(reserved) {
fail("reserved symbol offered", detail: reserved)
}
check("reserved marks are filtered", !catalog.symbols.contains("icloud"))
// Locale variants are near-duplicates of a symbol that's already listed.
let locale = catalog.symbols.filter { $0.hasSuffix(".ar") || $0.hasSuffix(".rtl") }
check("locale variants are filtered", locale.isEmpty, "got \(locale.prefix(5))")
// Categories: the two synthetic ones plus real subjects, each non-empty.
check("suggested is first", catalog.categories.first == .suggested)
// The app's own marks come next: they are the ones the system catalogue does not have.
check("the bundled marks are second", catalog.categories.dropFirst().first == .bundled)
check("all symbols is third", catalog.categories.dropFirst(2).first == .all)
check(
"every bundled mark is offered",
SymbolCatalog.bundledGlyphs.allSatisfy(catalog.symbols.contains))
check(
"and they lead the suggested set",
Array(SymbolCatalog.suggested.prefix(SymbolCatalog.bundledGlyphs.count))
== SymbolCatalog.bundledGlyphs)
// The system has no bluetooth glyph at all, which is why the app ships one.
check(
"bluetooth is reachable",
catalog.search("bluetooth", in: .all).contains("bluetooth"))
check("subject categories exist", catalog.categories.count > 10)
let empty = catalog.categories.filter { catalog.symbols(in: $0).isEmpty }
check("no empty category", empty.isEmpty, "got \(empty.map(\.title))")
check(
"rendering-mode buckets are not categories",
!catalog.categories.contains { $0.id == "multicolor" || $0.id == "variable" })
// Search: by name, by the system's own search terms, and across words.
check(
"'coffee' finds the cup", catalog.search("coffee", in: .all).contains("cup.and.saucer"),
"got \(catalog.search("coffee", in: .all).prefix(5))")
check(
"name search works", catalog.search("cup.and.saucer", in: .all).contains("cup.and.saucer"))
check(
"words can be given in any order",
catalog.search("saucer cup", in: .all).contains("cup.and.saucer"))
check("nonsense matches nothing", catalog.search("zzzqqq", in: .all).isEmpty)
check(
"an empty query is the whole category",
catalog.search("", in: .all).count == catalog.symbols.count)
// Searching from Suggested still looks at everything — a curated set of 88 is not a search index.
check(
"search escapes the suggested set",
catalog.search("thermometer", in: .suggested).count > 1)
// The fallback stands in when CoreGlyphs isn't where we expect it.
check("the fallback is usable", !SymbolCatalog.fallback.symbols.isEmpty)
check(
"the fallback is the curated set",
SymbolCatalog.fallback.symbols.allSatisfy(SymbolCatalog.suggested.contains))
check(
"every curated symbol renders",
SymbolCatalog.fallback.symbols.count == SymbolCatalog.suggested.count,
"\(SymbolCatalog.suggested.count - SymbolCatalog.fallback.symbols.count) missing")
print(
failures == 0 ? "\nAll \(passes) checks passed." : "\n\(failures) failure(s), \(passes) passed.")
exit(failures == 0 ? 0 : 1)
}
static func check(_ desc: String, _ condition: Bool, _ detail: String = "") {
if condition {
passes += 1
} else {
fail(desc, detail: detail)
}
}
static func fail(_ desc: String, detail: String) {
failures += 1
print("FAIL \(desc) \(detail)")
}
}
-2
View File
@@ -19,8 +19,6 @@ struct SystemActionTests {
let actions = SystemActionCatalog.all
expect(actions.count == 31, "catalog contains all 31 agreed actions")
expect(actions.map(\.id) == SystemAction.ID.allCases, "catalog covers every ID once")
expect(Set(actions.map(\.id)).count == actions.count, "IDs are unique")
expect(Set(actions.map(\.entryID)).count == actions.count, "entry IDs are unique")
expect(Set(actions.map { $0.name.lowercased() }).count == actions.count, "names are unique")
expect(actions.allSatisfy { !$0.name.isEmpty }, "names are non-empty")
expect(actions.allSatisfy { !$0.sfSymbol.isEmpty }, "symbols are non-empty")
-3
View File
@@ -250,9 +250,6 @@ struct UninstallTests {
expect(
evidence("Unrelated.qlgenerator", "QuickLook", app) == nil,
"another product's plug-in is left alone")
expect(
!UninstallRules.matchableForms("Barista.app").contains("Barista"),
".app is never stripped — an app bundle is not a plug-in wrapper")
let bundle = "/Applications/Zed.app"
expect(
-6
View File
@@ -73,8 +73,6 @@ struct WindowCommandTests {
let commands = WindowCommandCatalog.all
expect(commands.count == 30, "catalog contains all 30 agreed commands")
expect(commands.map(\.id) == WindowCommand.ID.allCases, "catalog covers every ID once")
expect(Set(commands.map(\.id)).count == commands.count, "IDs are unique")
expect(Set(commands.map(\.entryID)).count == commands.count, "entry IDs are unique")
expect(
Set(commands.map { $0.name.lowercased() }).count == commands.count, "names are unique")
expect(commands.allSatisfy { !$0.name.isEmpty }, "names are non-empty")
@@ -205,10 +203,6 @@ struct WindowCommandTests {
frame(.firstTwoThirds)!.union(frame(.lastThird)!) == mainScreen.visibleFrame,
"first two thirds and last third partition the screen")
// Half the screen's area: half width, full height, horizontally centred.
expectRect(
frame(.centerHalf)!, CGRect(x: 360, y: 0, width: 720, height: 900), "center half")
// Cycling: halves only, ½ → ⅓ → ⅔, wrapping.
expectRect(frame(.leftHalf, step: 1)!, frame(.firstThird)!, "left half step 1 is a third")
expectRect(
+252 -16
View File
@@ -8,11 +8,15 @@
/* Begin PBXBuildFile section */
0047FE6A50E746CCF0FC6810 /* SystemActionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AEDBB526AB2C0406D8226D5 /* SystemActionRunner.swift */; };
0120AF7AB3AD249C484C8A03 /* ExtensionStoreResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7732FB0ED92C8FBFE22599CE /* ExtensionStoreResponse.swift */; };
017FB0EDDC9AF28ED81EA6A8 /* EntryIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08279257B0F5995E86FF2C03 /* EntryIconView.swift */; };
02452CD187D25FD9CD3EF2CF /* QuicklinkLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B865FF16D3C395A8D509B94 /* QuicklinkLauncher.swift */; };
0288221BA580DFA8F5B116B7 /* ExtensionIconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F568F2F6EF857274F1A9D8 /* ExtensionIconCache.swift */; };
02DB345553153BBDE5DFBCAD /* QuicklinkEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9A72B34E336446F312B30 /* QuicklinkEditorSheet.swift */; };
0360C7038877B9CB656DB697 /* WindowManagementSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21678BAD7BA4A4E300FDA2C /* WindowManagementSettingsView.swift */; };
03C41F6394089BAEC7E630D6 /* ClipboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBF6EF3A2C35F45B82D40968 /* ClipboardView.swift */; };
07726F26DC68689F8984F467 /* Scrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D0784E49BC5899A585F285 /* Scrypt.swift */; };
0820C7539E3328338C3C8FBD /* BarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395BACF833C810961A248F0 /* BarButton.swift */; };
089C174783A60216FCC952B3 /* SpotlightNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0ABE4AF80DD5F5F8E91F63E /* SpotlightNames.swift */; };
0B9C941402B85C69A33200C2 /* UninstallProtection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A48095F6487DEE76663F88 /* UninstallProtection.swift */; };
0C1CACEF014462D3F581CE54 /* EmojiSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B42664C60B760276FBAAD61 /* EmojiSettingsView.swift */; };
@@ -31,13 +35,18 @@
17F61DEF6389FBCFCCFB467C /* CurrencyRateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FEAC591704D983A2C31188F /* CurrencyRateStore.swift */; };
19317D6C8A55E3F85BB7AE1F /* OnboardingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BCAB1F3B6930FE0B819F6D /* OnboardingState.swift */; };
19B0314193376FD1E4AC11A5 /* WindowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = E779B6EF738591CB935F3F95 /* WindowLayout.swift */; };
1A831E78CC5D84D543763B9C /* ExtensionStoreSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72AE5D924466E1E80818A98 /* ExtensionStoreSheet.swift */; };
1B9BC6B00770D1F002E62E0D /* ExtensionBootConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5AAA98FFF7A4D34D7B490D /* ExtensionBootConfig.swift */; };
1BC16048EE05A1FD25739CB4 /* PaletteState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 188A6D3C5B6BC67716F15F83 /* PaletteState.swift */; };
1C81DCEBEFFC1191751135F4 /* PalettePlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802BD2F4CC057228F2528217 /* PalettePlacement.swift */; };
1CA10DE219BAB97BFC29E54A /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D29ADBBE565419B1E98281 /* SettingsSidebarView.swift */; };
1F0E7F2EF27CB82211EDCB4C /* ExtensionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BAA0471DD8CE4352F45FED /* ExtensionCoordinator.swift */; };
1F1AD34A26931AF6E11E13F7 /* VolumeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B962318915EF7AEAC4996D /* VolumeState.swift */; };
1F8F2135470F74627A644ACC /* ClipboardFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B45A6AB1413A8A08EF7F0A9E /* ClipboardFilterButton.swift */; };
208FFC22E116E53E7BF3D147 /* CalcQuantity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF9F16C3F5567240FF03122 /* CalcQuantity.swift */; };
22420E8A41685143315904F5 /* RaycastImportSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B711492A6DA473E6DC9646C /* RaycastImportSelection.swift */; };
22EE4D5AFAAA12E649666019 /* PermissionsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C7D17F97002FD58E8A5F53 /* PermissionsSettingsView.swift */; };
23E8792D69F3F1C539BC3BC6 /* ExtensionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8CDDBAC59480E1A7559278 /* ExtensionManager.swift */; };
244668334681070EB823FF13 /* QuicklinkArgumentsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F524FA7B33C0F861995EB1E6 /* QuicklinkArgumentsScreen.swift */; };
24EFA87B2617537B6CAAD52B /* FileSearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC2CD86BAA6567B66B2B4DB /* FileSearchSettingsView.swift */; };
24F4BF3EA74E8E65B2B92D6D /* VolumeHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09633E6F9C37F89267341D1D /* VolumeHUDController.swift */; };
@@ -47,13 +56,19 @@
29AE7D39F2969F706A8038A6 /* FileSearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847C792F7F5F31259EB56ACC /* FileSearchScreen.swift */; };
2A7F775568D6F50A7AF4C587 /* CalcParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77FF4F18AAA5D514A674878 /* CalcParser.swift */; };
2C788DCDF146ED245150EF82 /* WindowCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9ADC5C0B42F7EF38F77DE6 /* WindowCommand.swift */; };
2D51D92BE8EAB5440F4C8517 /* ExtensionPaletteModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0FB3A9CA762A7A8DB99BA6 /* ExtensionPaletteModifiers.swift */; };
2EBB42C7EB17F37628765B17 /* CurrencyData.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C7F0C39D24F068A8F84864 /* CurrencyData.generated.swift */; };
2F2360730BE4992D5745FAD3 /* ExtensionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471E1C3C250FD0A89E2C6430 /* ExtensionListView.swift */; };
2F38A89B3481A4C9A13F13A8 /* SnippetKeywordPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B00EC4FD8D4D0CD2FE596A /* SnippetKeywordPolicy.swift */; };
303FC5342B61A7862EBC1C32 /* FocusRelease.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A0079D05A5D9B21A1CFCE /* FocusRelease.swift */; };
309320CB6B5424EA558A4823 /* EmojiData.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BE90FB09366A3518C2D4E5 /* EmojiData.generated.swift */; };
309B1313B6B40D1342362492 /* CalcEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7817B7A8760B36FDA43F48FC /* CalcEngine.swift */; };
312BCD2850AFD26E8B532937 /* ExtensionInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = F023729C84682E9231F3D73B /* ExtensionInstaller.swift */; };
3155DE3A2195764DDEA97F26 /* ClipboardStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201FCD08ACA933378876385B /* ClipboardStore.swift */; };
31E0E4E0782A79504CEB588D /* ScrollIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622E1F1C01B954DCA2199BCD /* ScrollIntent.swift */; };
32C65BE018B7107FFA92DDA2 /* NoteSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FBA3F0A0B4F6C796B901B /* NoteSwitcherView.swift */; };
32238C3CCA0310804D30506A /* ExtensionCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7DDDF0D150C30D0D0FC0BFD /* ExtensionCatalog.swift */; };
330540BBA991E36DD4C4BF53 /* ExtensionsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46762A28D73339953019C0AA /* ExtensionsSettingsView.swift */; };
345D1FCC2085BD0D699DD236 /* FileSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF64F5FCB3872996AE57FDBB /* FileSearchCoordinator.swift */; };
362651FE29DD01EC4D846F5F /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F64F4362989516482853CDB /* AppSettings.swift */; };
364CB97064000DC67178DDE8 /* EdgeDissolve.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6F59F3C88C49BDED945F25 /* EdgeDissolve.swift */; };
@@ -66,43 +81,53 @@
3A22313E903883E55046F207 /* AppSettingsKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384485BD1794D25503534525 /* AppSettingsKey.swift */; };
3B02D6CE68E0E488955A19B5 /* SnippetTemplateEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = F58E564E6181EB15497E51CD /* SnippetTemplateEngine.swift */; };
3BC6CC35889FFC77D640C7CA /* SnippetsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2EF66267B0141279A43613 /* SnippetsSettingsView.swift */; };
3E2E0C5B564C828D7ECA2759 /* RegionCurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB0BD36800F3B1AC765F514 /* RegionCurrency.swift */; };
4109B5CF37DCA4BCDAE46412 /* ShellCommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBFE3C41897391C14DB5A55C /* ShellCommandRunner.swift */; };
410F1B5FFAA44378AC9EC2DB /* SystemSettingsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951E746BA1AD25D167A1775F /* SystemSettingsSettingsView.swift */; };
42E1CF2605A0E02D84932B26 /* Gunzip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1213B069324F16F5364FECD3 /* Gunzip.swift */; };
43885792945F28AF3CDE30BC /* WindowCommandCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06125F7DDE68BB4D52065C06 /* WindowCommandCoordinator.swift */; };
43DBCE7B5A84D0D6322124F5 /* MessageHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B2CB71671625A1F446316A1 /* MessageHUDController.swift */; };
4549280508130B232069071E /* BackupSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 112551581ECF83B53D405334 /* BackupSettingsView.swift */; };
45739848B8C2B00E0BFC208F /* FileSearchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28D1A69B721AA863AFFE176F /* FileSearchScope.swift */; };
45799EE1F41091B32544B6AE /* ExtensionManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30BBF211CE6A076EAF8D6C33 /* ExtensionManifest.swift */; };
4640C7CC8DCA6E6FA49394A8 /* CommandsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D303E193CFEFE455778C150 /* CommandsSettingsView.swift */; };
464F93E1F16A3242396F32F9 /* SearchScopes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4A7E45F7D1CC5FB8868715 /* SearchScopes.swift */; };
478500C6465327947EBD9D61 /* RaycastSnippetImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80806A442BADAC390ECD993 /* RaycastSnippetImport.swift */; };
47A443F25878A27DE6DF24AA /* NoteEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAFF79E9897975CAC5EAC0E /* NoteEditorView.swift */; };
47E13BC63F8BE11EDF25EA82 /* RootPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23A98172AA46F5C0C89C4AB /* RootPaletteView.swift */; };
48AE2C9989F8FA2AA99D2D61 /* CalcPercent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92B4495E56754B05B5F3551D /* CalcPercent.swift */; };
4D3E47D6F382933EB3B1AB2F /* ExtensionCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87F282D4BBC2F147D3A98FFB /* ExtensionCleanup.swift */; };
4E5F73461C417BE2B3472E6E /* WindowChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D61199EE7B60E91ACBDE2E2 /* WindowChrome.swift */; };
4EBFBA1CC7DA9B22C396DBEC /* CommandArgumentsRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1590A8769EF787763DE189B7 /* CommandArgumentsRow.swift */; };
4F938D799099931DDA1D1876 /* SectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC8371BCD366F2549E0B01B /* SectionHeader.swift */; };
507D6A26EA5451465B1BE5AF /* HotKeyAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F3BC11AD77D8A58671F83E /* HotKeyAction.swift */; };
50A3CB2A348F8E03D467B169 /* ExtensionRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54C4540AD6F188A2455473F /* ExtensionRuntime.swift */; };
5194FB6B47E004217CFD1ABB /* UninstallSearchRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = E54932083F6BC197453EA0C8 /* UninstallSearchRoot.swift */; };
52C4F57BB26F420CCD22C1D7 /* PaletteWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209D951FDD587A040F1F76F3 /* PaletteWindowController.swift */; };
5332040ADB923832DCA06E5C /* CalculatorCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518429EDBFBEA0B866CCAA81 /* CalculatorCoordinator.swift */; };
540F1433DC46BC97C9F80E06 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787059DAE8AB67FFE684C0D9 /* AboutView.swift */; };
54FC1A629F03AE0F234A01B2 /* ShortcutRecorderPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DADE9F769787D44F72A2071 /* ShortcutRecorderPopover.swift */; };
552B7230E78D2C05793EC013 /* ExtensionNodeShims.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9A0A3867D1B8E1D3284471 /* ExtensionNodeShims.swift */; };
555791AE6CDCF42ECA171B2C /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43F1080FB443A1D0616790E /* FavoritesStore.swift */; };
5770FB1A3FD29E27C25C944E /* SearchRelevance.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB9DB77DCE25C41D86BB955 /* SearchRelevance.swift */; };
57D7D94F9CB94B1152D44879 /* CursorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9163FDA8F7ADDEF49A62D72C /* CursorScreen.swift */; };
58D369977E702AD66F070913 /* SnippetMarkdownSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3331A5E8F892CC257AD31B /* SnippetMarkdownSerializer.swift */; };
5970306ABC24B03028E66D45 /* FileSearchIgnoreList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFF39C46E98D0808B188E2E /* FileSearchIgnoreList.swift */; };
5A91C58340D02ED0939BCAE2 /* CurrencyFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE35B5E7B1CC94F759116097 /* CurrencyFeed.swift */; };
5B5BCDD482A9058D38C49314 /* CalcUnits.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC67BADEB6FEC76C88585361 /* CalcUnits.swift */; };
5B90A314CF4801995EA0EE01 /* ExtensionHostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38765BC6C35D03659655BD2B /* ExtensionHostBridge.swift */; };
5C801A9043908577FB99A555 /* DialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32733B27AE0BD75742D1394F /* DialogView.swift */; };
5D3E51C5FDA9EE53AFD12181 /* ExtensionFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EC0C36F08176EE0304F49 /* ExtensionFormView.swift */; };
5D3F00F708106ADE1CE263B1 /* EmojiGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1A2CE5E4D5F10346B5F6F3 /* EmojiGridView.swift */; };
5DBD1E52658FB1C3EC37AA4F /* AppLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33994DC2FB0A6B23A403CA29 /* AppLauncher.swift */; };
5E155BEE2FC62267F516A343 /* CalculatorHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60CCB1A47E0022BAEDD1EE96 /* CalculatorHistoryView.swift */; };
5FB708EC91426C41762AB96B /* CalculatorHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D032AC22E53BB9997E492F /* CalculatorHistoryStore.swift */; };
5FC53F9968A5DFF24CD109DD /* VisibilityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17EA0B9FFCBAB10941EC7702 /* VisibilityStore.swift */; };
5FED92BC16B1463EBDE38D77 /* ShortcutRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D41B9E6F2C617D28C83AFF /* ShortcutRecorder.swift */; };
606809BC14D6613B847036D0 /* ExtensionStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CECB81C669337C8E6CF7FF /* ExtensionStorage.swift */; };
613827876C48EF88F2162C10 /* NotificationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FB75182A60F65BA1FB9BA8 /* NotificationToken.swift */; };
614DAAC9331F9C1DE4D76F0D /* EmojiScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B6CA2ED2DDD9A89C7EE874 /* EmojiScreen.swift */; };
6293B6B6B50E09CA75B74654 /* NotesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AF26598BE61F114D5999541 /* NotesSettingsView.swift */; };
62C75AA7315CDEFE82ECD21B /* ExtensionTintColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7751EBF0F632A123B4A2363 /* ExtensionTintColors.swift */; };
633DCA2F00A274C375EB4423 /* LauncherRankingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDB1EEB97CC5F6B0B81FFD6 /* LauncherRankingStore.swift */; };
64AD2324BDA6293464C98913 /* EmojiCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45EB979ECD44738BCD9E2F90 /* EmojiCoordinator.swift */; };
6527B404C220924885C070B3 /* AppWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9979DED5579535D1F0A32DBF /* AppWindowController.swift */; };
@@ -110,11 +135,15 @@
68F25E0E441706E9F591B302 /* NotesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 525AE0062E8E8F00E21BAEF9 /* NotesWindowController.swift */; };
68FCC30764DD44ED72EFB6BA /* TinycastApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B629EC3B716FEE881BB0075 /* TinycastApp.swift */; };
691ADED3F9F3F730E1F36DB8 /* FileSearchSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03757CDEFC14359CAEF4CE93 /* FileSearchSession.swift */; };
694B7E1A8C7C2D6DF26D3BF9 /* RaycastRuntime.generated.js in Resources */ = {isa = PBXBuildFile; fileRef = 74284B4E771DA0B2869C914E /* RaycastRuntime.generated.js */; };
6A0C45313C7B73349C7B17E0 /* ThinScrollbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A6748260B2DF011CCAFCEF /* ThinScrollbar.swift */; };
6B8351E3D100E81F169BCFED /* UninstallPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD838940E655559E32BE8EA /* UninstallPlan.swift */; };
6BAE3112ED110F1363617A79 /* NoteTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F088EC0B4D97B3C9D0D0569 /* NoteTextView.swift */; };
6C052B83FAE453D1011E3FB4 /* PopoverMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAE24CC14F7DFECD1BD7FB1 /* PopoverMenu.swift */; };
6CDA82FD63F87D07E30E3F82 /* Zlib.swift in Sources */ = {isa = PBXBuildFile; fileRef = F097C9983DF27A99694C8BC5 /* Zlib.swift */; };
6FC9F74D9119AC25D178D748 /* ExtensionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 810AA3AD31C7E11932E34F28 /* ExtensionRegistry.swift */; };
71AA40FC4B8C57FFDE5F8DFC /* SystemAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE72CE02F853ACE956627347 /* SystemAction.swift */; };
7320E4B2ABFAE8C1D76A3526 /* ExtensionArgumentsAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 154222C91EFDB4D4490B6CE2 /* ExtensionArgumentsAccessory.swift */; };
74ACDD85B9F03C36E9258695 /* SettingsSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AA1305D43303E055DEE1E9 /* SettingsSplitViewController.swift */; };
74BE9E8319C8302EF525E6B2 /* QuicklinkStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 685FB7FD7E4E5BE6606440D0 /* QuicklinkStore.swift */; };
75AED41394E66D6E9CBDA7EA /* NotesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F84B7743B803F55C41D5C4A /* NotesRepository.swift */; };
@@ -147,11 +176,15 @@
935F7433BB0C0ECDE6FA3BF3 /* ClipboardScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 548C83C4086CD9FB8E85B222 /* ClipboardScreen.swift */; };
939F3944AE8D36A8C337D46F /* Signposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9044F107ADD5896746DBC35 /* Signposts.swift */; };
94887F8E18AB2C08A8273897 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7B3EB88881CEEFCE71448D /* Permissions.swift */; };
9608AFDEDC3B330E64F53437 /* ExtensionStoreClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 410684C9DEB3754962C20C9C /* ExtensionStoreClient.swift */; };
9802E5495E65CCB318ACF5F2 /* RaycastImportV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658C57A4CE41C0FA80BBE41D /* RaycastImportV1.swift */; };
9969E6858C4FDC5CCCDEB5D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C45190564C9D9F1722E83943 /* Assets.xcassets */; };
9A15181AAF90211C807592E3 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA90C004E55AB1BBC12D9F96 /* VisualEffectView.swift */; };
9A80D2997DCF835AF225A586 /* RunningAppsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5FA545F484D7666E9A5F14 /* RunningAppsMonitor.swift */; };
9B05C47A7414D04E20237B09 /* RenderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A1D354C1785923E612848C /* RenderNode.swift */; };
9B3BC1DCC478DA7517F04182 /* CalculatorHistoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86545F54669A075FBDA5D1F1 /* CalculatorHistoryScreen.swift */; };
9DB09F96E6F8C226BD9AB1C9 /* SymbolCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387BE675F580496656E72559 /* SymbolCatalog.swift */; };
9E5B694969D3F99BF7D0D9E1 /* ExtensionImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D20FAFD9F3CC0E5802A966 /* ExtensionImage.swift */; };
9F82DA5A46BB39A6C6DD075D /* DialogRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28D150930C930F1FB5E2A31B /* DialogRequest.swift */; };
9F8806D46EBAF7058FC2EAEC /* PaletteRowIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD814D567DFEFD303B1750 /* PaletteRowIndex.swift */; };
A0A247FFDB1EA278C0F6F813 /* BackupActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8CBA97041AAA96C0B7FF2E /* BackupActions.swift */; };
@@ -163,33 +196,43 @@
A500AF3BF8910B335A7A936C /* HUDPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1752929BA1ACC0BC54865C /* HUDPanel.swift */; };
A5354210CDCB796D2D988B82 /* RaycastImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F474B4E9FD7738D97AB65E /* RaycastImport.swift */; };
A54766FEEBC9A4E7BE546226 /* AppPickerPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79E1BEE80A3B75140D10D740 /* AppPickerPopover.swift */; };
A5693BFABA85B873EC7F1E0B /* ExtensionCommandScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52146CB01C791AB02518273D /* ExtensionCommandScreen.swift */; };
A64891F5587F2C11E4A2E2D2 /* ClipboardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197035E213BEBA67DDA3377B /* ClipboardManager.swift */; };
A6A8CD2B71A15625F47498EA /* WindowMover.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA99178E0DCF6CBB521FB098 /* WindowMover.swift */; };
A769FFB50D3ED9E458028255 /* SettingsBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52998BAB1D8287CF14E46EF0 /* SettingsBackup.swift */; };
A7CEBFBFBB46554ACAD73CFB /* QuicklinkArgumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D3044106BC42824C79E1803 /* QuicklinkArgumentsView.swift */; };
A7D7F8F64B0937D20D97F428 /* ExtensionAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755EEABA1DA9CA4CE2D0B4F /* ExtensionAppearance.swift */; };
A7F4CD74AEBEFF9F07103CD7 /* EmojiIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316A544BF6628C0465DE28D8 /* EmojiIndex.swift */; };
AAFD2C9276D38719E85B675F /* Memo.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB76FDBFE1E306C2A5B1A29B /* Memo.swift */; };
AB500AC3DEBFF6EB1CE42812 /* DialogController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94507F8F095D3EBD1A9B3007 /* DialogController.swift */; };
ACB45791BFE60896455293B4 /* FileSearchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC86115B040B8C574EAD0B5 /* FileSearchPolicy.swift */; };
ACD8CAE659B1C49EC8652576 /* HotKeyCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26AD346C8FD5F2D4F00FF2B6 /* HotKeyCenter.swift */; };
ACE645630E536C93CA102050 /* VolumeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90559D7DB9F6F3874CAFCDE /* VolumeHUDView.swift */; };
AD9753D6EAF202B6E31510EB /* ClipboardFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682E7F6534CB4014969D98E0 /* ClipboardFilter.swift */; };
AD98F015439106F0E9D32015 /* RightClick.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EFE2C8A212D6979C43E9F7 /* RightClick.swift */; };
ADE29D44A9504AC24FC17E72 /* CalcDateTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A97022BE0F95B5D19F0C26 /* CalcDateTime.swift */; };
AE3144FCC55FADC48955D17A /* KeyShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E16AEF81320A101E384098E /* KeyShortcut.swift */; };
AF3A21A4FDF29BA834696F14 /* RaycastImportV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E3AA9E226F39DBB232501F /* RaycastImportV2.swift */; };
B073D5A79D50982E89C30F20 /* Snippet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B3FC48B58E1005453E7E18 /* Snippet.swift */; };
B2404EBFB52B8199DC2536F6 /* HotKeyBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B00A698366E83C7352BC1E /* HotKeyBinding.swift */; };
B566FC125D9D193F234EC1AB /* ExtensionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988EA3698EA86BA53590C24E /* ExtensionFetcher.swift */; };
B56F252262AA1BD3E2631F0C /* FileSearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 391B8024E8719506361B45F7 /* FileSearchQuery.swift */; };
B6525300EEDACE4F9DC3179E /* CalcCurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B0F5891A80B604FBE21897B /* CalcCurrency.swift */; };
B8CECD85524BCCCBC0CDC2B5 /* ExtensionCommandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2DDA019FE0EF38A91AA495 /* ExtensionCommandView.swift */; };
BBAB44C0E70AABE70A048889 /* QuicklinkArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = F616D2EE62AEC6782E77364D /* QuicklinkArchive.swift */; };
BC755BD99ECA1F2D382D5C94 /* SettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C845A6718CFBD1AFE93BA903 /* SettingsDetailView.swift */; };
BD45A99F26C015BF00AC77D5 /* ApplicationsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 289FC415E1D1776DD1C4EB9E /* ApplicationsSettingsView.swift */; };
BE1540DC6EE5F095F89CED5F /* EmojiGridGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0966995230FE7B7072C0018B /* EmojiGridGeometry.swift */; };
BEA4A58DE854DC330E90B5B9 /* ExtensionAppearanceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AA26F487C06DC6F03F78B13 /* ExtensionAppearanceStore.swift */; };
BEAAD72E748375E0039AC90F /* CommandID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164CB3E1BB5B7D87AF8E7023 /* CommandID.swift */; };
BF04DEAF1B660EE399B43962 /* UninstallScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7F606D7F71B6FC0CC6ABC8 /* UninstallScanner.swift */; };
BFA8B1578664272E7CBFF806 /* ExtensionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B0D967615846F9C4EC3E44F /* ExtensionDetailView.swift */; };
C0642512B709EF0517ABF4CB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C37CCD024267EB21876C7E9 /* AppDelegate.swift */; };
C215695FE671BC591DC99C2B /* DialogPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB241B179A08518424F7C1B /* DialogPanel.swift */; };
C385AEF3C8C5A6B2BACC1D9A /* OverflowFade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88499DF2F503F2A3A19DEF07 /* OverflowFade.swift */; };
C4B9E9B3E965BFDFD31EDC3D /* LauncherCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858E08BB313EC330C2D5E50C /* LauncherCoordinator.swift */; };
C5F60D8681235B95DAA3314F /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418DA899A1546F3B4AC44777 /* GeneralSettingsView.swift */; };
C9A7290682E9D0881713C009 /* ExtensionGridGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85C82C66132C7723272E873 /* ExtensionGridGeometry.swift */; };
CA6BA9C381351D0B2CADFB9D /* IconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CBCCE63360E4590CF5884A /* IconCache.swift */; };
CAE4C44A9D2EC4AC51FD3150 /* UninstallTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EBAD0766FF9BFC6A4396474 /* UninstallTarget.swift */; };
CB95B0CBF0DFEDFCEA837053 /* ImageThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36C6775BBA0FC6F134296606 /* ImageThumbnail.swift */; };
@@ -199,6 +242,7 @@
CCC775255627E23DC5B567AE /* CustomCommandEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40049CE320BEE3A4388CDFF /* CustomCommandEditorSheet.swift */; };
CE15888F3C15EE2706E108A0 /* AppDisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BBDB6A6CE9564ADA2D16F53 /* AppDisplayName.swift */; };
CEE0B612FFDB0AFFEF23A8A1 /* SearchScopesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADC45597561DCD90F51506F7 /* SearchScopesSection.swift */; };
CF1015EA1573EFBBA647B93B /* ExtensionRegistriesSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA081CD5CEA054447D0691F /* ExtensionRegistriesSheet.swift */; };
CFBD94098A1F79035DD53ED4 /* SelectionFollowing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2702F140F8DBF373D2ED11A5 /* SelectionFollowing.swift */; };
D02DD53E9BE6C679EA0C0375 /* NoteSwitcherInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8AD14D51F74D1891D5532B0 /* NoteSwitcherInteraction.swift */; };
D07983DDEA61E5F2B8681F01 /* QuicklinkCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83CD7B0E866AEF486F9EC5E9 /* QuicklinkCoordinator.swift */; };
@@ -210,7 +254,9 @@
D736A0FE574A343AA0FD3911 /* AppIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E58EBB08450BA3426DF0565 /* AppIndex.swift */; };
D9113113F828FD6983715D6B /* UninstallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F2B0F5FDD503E6BB739A47 /* UninstallView.swift */; };
DA1EE0A8933EADD357FCE32C /* QuicklinkArgumentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E48A8D5E2BC00E9E1CD5BF /* QuicklinkArgumentSession.swift */; };
DA781EE329E4D88B9C72788C /* ExtensionActionsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A178C17DB989B6D2D71C021E /* ExtensionActionsPanel.swift */; };
DB6B294B3D15B0AABCE5427E /* PaletteScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 445B0374FD5D74DBE23243CD /* PaletteScreen.swift */; };
DCAC5BBB64C93F559B25E6FD /* ExtensionAnimatedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBF31AF26A4786B234ABB64 /* ExtensionAnimatedImage.swift */; };
DD25D0E22E5729CF612FA1AE /* WindowDragHandle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F1545635C1E94D5E7DFECF1 /* WindowDragHandle.swift */; };
DDFA439EA7942183414375DC /* AppPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A96350EBE68EC79497FA2D /* AppPaths.swift */; };
DEBCF434AB244DAD43DB8A6A /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA9F5F7226AD5A91835AF5C /* LaunchAtLogin.swift */; };
@@ -229,17 +275,19 @@
EA89498DA03BDFA3858144BF /* AppActionsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C045C66BB654E4CB407A673 /* AppActionsMenu.swift */; };
EBCDF88CD59A4B6F21F945C6 /* Quicklink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA3385A84985A52E2DA3596 /* Quicklink.swift */; };
EDD7861202ECC1B0FA3B278E /* FileSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7420F642776452B469182D0 /* FileSearchResult.swift */; };
F226F68554B1FBDDC1893C42 /* MiscellaneousSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A954E3DF28F609AAA3BEE216 /* MiscellaneousSettingsView.swift */; };
F71FC7195AC8D6626BEE8931 /* NotesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91151402A1A4586B2496BF35 /* NotesPanel.swift */; };
F51785CF5A6EC0B87D6802C9 /* ExtensionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCBDD92DEAD780A1DC423CA /* ExtensionScreen.swift */; };
F85CE4CDCF7589ACAEF2CA20 /* AppIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E23F92F4B4DEEE55077C7C /* AppIconView.swift */; };
F8D69CE2A8C04DEEC1810A1F /* UninstallRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D134673CB1B563A3758FC37 /* UninstallRunner.swift */; };
F8EC987B884BD2ED6A6690A9 /* SnippetKeywordListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301BA9123035D6BFB808F763 /* SnippetKeywordListener.swift */; };
FA05D882203673A9FEE5F421 /* UninstallCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AAFBFA70EF62B67F7357E0E /* UninstallCoordinator.swift */; };
FA386C9B6DA24481565D506D /* ExtensionPackageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ADFFB5180991764FB13FE0B /* ExtensionPackageManager.swift */; };
FAF6C108845548DDC7F978CB /* SnippetExpansionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102C22092D130BD8E1A07CD6 /* SnippetExpansionCoordinator.swift */; };
FB1D83A8BE027AC4F328D601 /* QuicklinkListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0935D49C0A0175E54E69682B /* QuicklinkListScreen.swift */; };
FCA73C81470EF9A50B016504 /* SettingsToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBC5A9553716C8F03ED0216 /* SettingsToolbarController.swift */; };
FD49FAFAA34FC1A41AFAC10F /* SettingsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEDDA8820165672AF8464E25 /* SettingsComponents.swift */; };
FD4DDDCDC32AF921CDBCFB18 /* Paster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3B9645DB2293FA144B5AA2 /* Paster.swift */; };
FD8DE9FDC0B6D5D94B59C4DB /* ExtensionAppearancePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6C8B2F6592B65CB6576D247 /* ExtensionAppearancePicker.swift */; };
FE9CFF847072FAA9C7D5B660 /* RaycastV1Decoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFD491421DFE25892C0FC37 /* RaycastV1Decoder.swift */; };
FF934DD9FBD8F26BAB72ACD1 /* SymbolImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EC07E163CE4F3D11FE7225 /* SymbolImage.swift */; };
FFE6952D79F28B62E189C097 /* DoubleTapMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2258D4A6B6C22AB7C5D421BE /* DoubleTapMonitor.swift */; };
@@ -249,27 +297,34 @@
02BCAB1F3B6930FE0B819F6D /* OnboardingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingState.swift; sourceTree = "<group>"; };
03757CDEFC14359CAEF4CE93 /* FileSearchSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchSession.swift; sourceTree = "<group>"; };
03D29ADBBE565419B1E98281 /* SettingsSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSidebarView.swift; sourceTree = "<group>"; };
045EC0C36F08176EE0304F49 /* ExtensionFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFormView.swift; sourceTree = "<group>"; };
06125F7DDE68BB4D52065C06 /* WindowCommandCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCommandCoordinator.swift; sourceTree = "<group>"; };
08279257B0F5995E86FF2C03 /* EntryIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryIconView.swift; sourceTree = "<group>"; };
08A1D354C1785923E612848C /* RenderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderNode.swift; sourceTree = "<group>"; };
0935D49C0A0175E54E69682B /* QuicklinkListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkListScreen.swift; sourceTree = "<group>"; };
09633E6F9C37F89267341D1D /* VolumeHUDController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeHUDController.swift; sourceTree = "<group>"; };
0966995230FE7B7072C0018B /* EmojiGridGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiGridGeometry.swift; sourceTree = "<group>"; };
0A97C1CFD02D55E955AFA72B /* WindowActionMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowActionMemory.swift; sourceTree = "<group>"; };
0ADFFB5180991764FB13FE0B /* ExtensionPackageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionPackageManager.swift; sourceTree = "<group>"; };
0C3B9645DB2293FA144B5AA2 /* Paster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paster.swift; sourceTree = "<group>"; };
0EDB1EEB97CC5F6B0B81FFD6 /* LauncherRankingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherRankingStore.swift; sourceTree = "<group>"; };
0F5AFB680B55BBAFF2207F99 /* NotesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesCoordinator.swift; sourceTree = "<group>"; };
102C22092D130BD8E1A07CD6 /* SnippetExpansionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetExpansionCoordinator.swift; sourceTree = "<group>"; };
112551581ECF83B53D405334 /* BackupSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupSettingsView.swift; sourceTree = "<group>"; };
1213B069324F16F5364FECD3 /* Gunzip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gunzip.swift; sourceTree = "<group>"; };
121A2ACAEC4D2D25D3112634 /* LauncherItemsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherItemsSection.swift; sourceTree = "<group>"; };
132C5A534C02305C1A29CC74 /* LauncherList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherList.swift; sourceTree = "<group>"; };
132FBA3F0A0B4F6C796B901B /* NoteSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteSwitcherView.swift; sourceTree = "<group>"; };
14F474B4E9FD7738D97AB65E /* RaycastImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaycastImport.swift; sourceTree = "<group>"; };
1534DB79CC23709ABC5E9D9B /* PaletteMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteMode.swift; sourceTree = "<group>"; };
158C000B68FECE948434B296 /* NotePresentationGeneration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotePresentationGeneration.swift; sourceTree = "<group>"; };
154222C91EFDB4D4490B6CE2 /* ExtensionArgumentsAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionArgumentsAccessory.swift; sourceTree = "<group>"; };
1590A8769EF787763DE189B7 /* CommandArgumentsRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandArgumentsRow.swift; sourceTree = "<group>"; };
164CB3E1BB5B7D87AF8E7023 /* CommandID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandID.swift; sourceTree = "<group>"; };
17EA0B9FFCBAB10941EC7702 /* VisibilityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityStore.swift; sourceTree = "<group>"; };
1868F87E31E20A4775C84E6A /* HyperKeyTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HyperKeyTap.swift; sourceTree = "<group>"; };
188A6D3C5B6BC67716F15F83 /* PaletteState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteState.swift; sourceTree = "<group>"; };
197035E213BEBA67DDA3377B /* ClipboardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardManager.swift; sourceTree = "<group>"; };
1AA26F487C06DC6F03F78B13 /* ExtensionAppearanceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionAppearanceStore.swift; sourceTree = "<group>"; };
1B865FF16D3C395A8D509B94 /* QuicklinkLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkLauncher.swift; sourceTree = "<group>"; };
201FCD08ACA933378876385B /* ClipboardStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardStore.swift; sourceTree = "<group>"; };
209D951FDD587A040F1F76F3 /* PaletteWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteWindowController.swift; sourceTree = "<group>"; };
@@ -288,6 +343,7 @@
296F67E23BF91CFBC6D66385 /* SystemActionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemActionCoordinator.swift; sourceTree = "<group>"; };
29A8B82DCB1B7D131BF0908A /* PaletteDropGuideController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteDropGuideController.swift; sourceTree = "<group>"; };
2AEDBB526AB2C0406D8226D5 /* SystemActionRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemActionRunner.swift; sourceTree = "<group>"; };
2B0D967615846F9C4EC3E44F /* ExtensionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionDetailView.swift; sourceTree = "<group>"; };
2B0F5891A80B604FBE21897B /* CalcCurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcCurrency.swift; sourceTree = "<group>"; };
2B2CB71671625A1F446316A1 /* MessageHUDController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageHUDController.swift; sourceTree = "<group>"; };
2B629EC3B716FEE881BB0075 /* TinycastApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TinycastApp.swift; sourceTree = "<group>"; };
@@ -295,9 +351,11 @@
2C5FFE395B9164BDC77B3470 /* SettingsBackupCoverage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBackupCoverage.swift; sourceTree = "<group>"; };
2D303E193CFEFE455778C150 /* CommandsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsSettingsView.swift; sourceTree = "<group>"; };
301BA9123035D6BFB808F763 /* SnippetKeywordListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetKeywordListener.swift; sourceTree = "<group>"; };
30BBF211CE6A076EAF8D6C33 /* ExtensionManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionManifest.swift; sourceTree = "<group>"; };
316A544BF6628C0465DE28D8 /* EmojiIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiIndex.swift; sourceTree = "<group>"; };
31BAD6EE1BA3036C4B5A3D19 /* HUDPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDPresenter.swift; sourceTree = "<group>"; };
32733B27AE0BD75742D1394F /* DialogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogView.swift; sourceTree = "<group>"; };
32D20FAFD9F3CC0E5802A966 /* ExtensionImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionImage.swift; sourceTree = "<group>"; };
33994DC2FB0A6B23A403CA29 /* AppLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLauncher.swift; sourceTree = "<group>"; };
364BEC0805A8F5C39444EAF5 /* SettingsNavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigationState.swift; sourceTree = "<group>"; };
36B6DAC3B9FF387367C3FAB4 /* UninstallSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallSession.swift; sourceTree = "<group>"; };
@@ -306,6 +364,8 @@
37A48095F6487DEE76663F88 /* UninstallProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallProtection.swift; sourceTree = "<group>"; };
37B00EC4FD8D4D0CD2FE596A /* SnippetKeywordPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetKeywordPolicy.swift; sourceTree = "<group>"; };
384485BD1794D25503534525 /* AppSettingsKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsKey.swift; sourceTree = "<group>"; };
38765BC6C35D03659655BD2B /* ExtensionHostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHostBridge.swift; sourceTree = "<group>"; };
387BE675F580496656E72559 /* SymbolCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymbolCatalog.swift; sourceTree = "<group>"; };
38A97022BE0F95B5D19F0C26 /* CalcDateTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcDateTime.swift; sourceTree = "<group>"; };
391B8024E8719506361B45F7 /* FileSearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchQuery.swift; sourceTree = "<group>"; };
3AA3385A84985A52E2DA3596 /* Quicklink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quicklink.swift; sourceTree = "<group>"; };
@@ -313,26 +373,35 @@
3C504229760FFFFA9D8A7799 /* SettingsHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHistory.swift; sourceTree = "<group>"; };
3E16AEF81320A101E384098E /* KeyShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyShortcut.swift; sourceTree = "<group>"; };
3E4A7E45F7D1CC5FB8868715 /* SearchScopes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScopes.swift; sourceTree = "<group>"; };
410684C9DEB3754962C20C9C /* ExtensionStoreClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionStoreClient.swift; sourceTree = "<group>"; };
418527DD2F2370A1B82A13B7 /* PaletteDropGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteDropGuideView.swift; sourceTree = "<group>"; };
418DA899A1546F3B4AC44777 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };
4395BACF833C810961A248F0 /* BarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarButton.swift; sourceTree = "<group>"; };
43F3BC11AD77D8A58671F83E /* HotKeyAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyAction.swift; sourceTree = "<group>"; };
445B0374FD5D74DBE23243CD /* PaletteScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteScreen.swift; sourceTree = "<group>"; };
45D4BA6E1BD8EB1F96A8927F /* Tinycast.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tinycast.entitlements; sourceTree = "<group>"; };
45EB979ECD44738BCD9E2F90 /* EmojiCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCoordinator.swift; sourceTree = "<group>"; };
463FE27F894A8777C8ACCF1D /* FileSearchList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchList.swift; sourceTree = "<group>"; };
46762A28D73339953019C0AA /* ExtensionsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionsSettingsView.swift; sourceTree = "<group>"; };
46CECB81C669337C8E6CF7FF /* ExtensionStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionStorage.swift; sourceTree = "<group>"; };
471E1C3C250FD0A89E2C6430 /* ExtensionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionListView.swift; sourceTree = "<group>"; };
4AA61A2FAF4E49E466DA8F37 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = "<group>"; };
4CB0BD36800F3B1AC765F514 /* RegionCurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionCurrency.swift; sourceTree = "<group>"; };
4DA7B926405DAC632C056418 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
4EF9F16C3F5567240FF03122 /* CalcQuantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcQuantity.swift; sourceTree = "<group>"; };
4FA081CD5CEA054447D0691F /* ExtensionRegistriesSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionRegistriesSheet.swift; sourceTree = "<group>"; };
4FAE24CC14F7DFECD1BD7FB1 /* PopoverMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverMenu.swift; sourceTree = "<group>"; };
50E2E570E54DEEF29FF2B65E /* NotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesView.swift; sourceTree = "<group>"; };
518429EDBFBEA0B866CCAA81 /* CalculatorCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorCoordinator.swift; sourceTree = "<group>"; };
525AE0062E8E8F00E21BAEF9 /* NotesWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesWindowController.swift; sourceTree = "<group>"; };
52146CB01C791AB02518273D /* ExtensionCommandScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCommandScreen.swift; sourceTree = "<group>"; };
52998BAB1D8287CF14E46EF0 /* SettingsBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBackup.swift; sourceTree = "<group>"; };
52BD814D567DFEFD303B1750 /* PaletteRowIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteRowIndex.swift; sourceTree = "<group>"; };
5392500F86E23C099D33561D /* Tinycast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tinycast.app; sourceTree = BUILT_PRODUCTS_DIR; };
548C83C4086CD9FB8E85B222 /* ClipboardScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardScreen.swift; sourceTree = "<group>"; };
54E48A8D5E2BC00E9E1CD5BF /* QuicklinkArgumentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkArgumentSession.swift; sourceTree = "<group>"; };
5BBDB6A6CE9564ADA2D16F53 /* AppDisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDisplayName.swift; sourceTree = "<group>"; };
5D2DDA019FE0EF38A91AA495 /* ExtensionCommandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCommandView.swift; sourceTree = "<group>"; };
5D3044106BC42824C79E1803 /* QuicklinkArgumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkArgumentsView.swift; sourceTree = "<group>"; };
5DADE9F769787D44F72A2071 /* ShortcutRecorderPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorderPopover.swift; sourceTree = "<group>"; };
5F0A68B2040582996EFBA877 /* CommandCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandCatalog.swift; sourceTree = "<group>"; };
@@ -346,6 +415,7 @@
658C57A4CE41C0FA80BBE41D /* RaycastImportV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaycastImportV1.swift; sourceTree = "<group>"; };
6621A88681E993D49EEF07A3 /* tinycast.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = tinycast.icon; sourceTree = "<group>"; };
67B6CA2ED2DDD9A89C7EE874 /* EmojiScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiScreen.swift; sourceTree = "<group>"; };
682E7F6534CB4014969D98E0 /* ClipboardFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardFilter.swift; sourceTree = "<group>"; };
685FB7FD7E4E5BE6606440D0 /* QuicklinkStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkStore.swift; sourceTree = "<group>"; };
6AF26598BE61F114D5999541 /* NotesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesSettingsView.swift; sourceTree = "<group>"; };
6C045C66BB654E4CB407A673 /* AppActionsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActionsMenu.swift; sourceTree = "<group>"; };
@@ -355,6 +425,8 @@
72DF1178CD8AD726AC6F9874 /* CalculatorCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorCardView.swift; sourceTree = "<group>"; };
72F0F0DDA5FC1F6ED59E73E9 /* DoubleTapModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTapModifier.swift; sourceTree = "<group>"; };
73E25B87C692D5FEE1FBF994 /* PanelTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelTransition.swift; sourceTree = "<group>"; };
74284B4E771DA0B2869C914E /* RaycastRuntime.generated.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = RaycastRuntime.generated.js; sourceTree = "<group>"; };
7732FB0ED92C8FBFE22599CE /* ExtensionStoreResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionStoreResponse.swift; sourceTree = "<group>"; };
7817B7A8760B36FDA43F48FC /* CalcEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcEngine.swift; sourceTree = "<group>"; };
787059DAE8AB67FFE684C0D9 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
79BC68E042182F7068220BB3 /* QuicklinksSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinksSettingsView.swift; sourceTree = "<group>"; };
@@ -362,6 +434,7 @@
7B42664C60B760276FBAAD61 /* EmojiSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSettingsView.swift; sourceTree = "<group>"; };
7B711492A6DA473E6DC9646C /* RaycastImportSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaycastImportSelection.swift; sourceTree = "<group>"; };
7BFFB1471D30C915BBC5DCBA /* SnippetRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetRepository.swift; sourceTree = "<group>"; };
7C0FB3A9CA762A7A8DB99BA6 /* ExtensionPaletteModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionPaletteModifiers.swift; sourceTree = "<group>"; };
7D35CA0655717E1798AFDA7B /* FrequentEmojiStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequentEmojiStore.swift; sourceTree = "<group>"; };
7D61199EE7B60E91ACBDE2E2 /* WindowChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowChrome.swift; sourceTree = "<group>"; };
7E7DAF3FFEE6CAB656B0FA8A /* UninstallRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallRules.swift; sourceTree = "<group>"; };
@@ -369,6 +442,7 @@
7F64F4362989516482853CDB /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
7F84B7743B803F55C41D5C4A /* NotesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesRepository.swift; sourceTree = "<group>"; };
802BD2F4CC057228F2528217 /* PalettePlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PalettePlacement.swift; sourceTree = "<group>"; };
810AA3AD31C7E11932E34F28 /* ExtensionRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionRegistry.swift; sourceTree = "<group>"; };
83C54F3A609CD1C2CDF304FB /* DoubleTapDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTapDetector.swift; sourceTree = "<group>"; };
83CD7B0E866AEF486F9EC5E9 /* QuicklinkCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkCoordinator.swift; sourceTree = "<group>"; };
847C792F7F5F31259EB56ACC /* FileSearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchScreen.swift; sourceTree = "<group>"; };
@@ -376,6 +450,8 @@
858E08BB313EC330C2D5E50C /* LauncherCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherCoordinator.swift; sourceTree = "<group>"; };
85B00A698366E83C7352BC1E /* HotKeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyBinding.swift; sourceTree = "<group>"; };
86545F54669A075FBDA5D1F1 /* CalculatorHistoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorHistoryScreen.swift; sourceTree = "<group>"; };
87F282D4BBC2F147D3A98FFB /* ExtensionCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCleanup.swift; sourceTree = "<group>"; };
88499DF2F503F2A3A19DEF07 /* OverflowFade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverflowFade.swift; sourceTree = "<group>"; };
8AC2CD86BAA6567B66B2B4DB /* FileSearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchSettingsView.swift; sourceTree = "<group>"; };
8AFD491421DFE25892C0FC37 /* RaycastV1Decoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaycastV1Decoder.swift; sourceTree = "<group>"; };
8CBD6D8BC6951A482BDF6169 /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = "<group>"; };
@@ -396,6 +472,7 @@
95BFA41B0D7F369A8CF1E013 /* HyperKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HyperKey.swift; sourceTree = "<group>"; };
95E3AA9E226F39DBB232501F /* RaycastImportV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaycastImportV2.swift; sourceTree = "<group>"; };
976A1F05AD82DB8A04B3C3F9 /* MessageHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageHUDView.swift; sourceTree = "<group>"; };
988EA3698EA86BA53590C24E /* ExtensionFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFetcher.swift; sourceTree = "<group>"; };
9979DED5579535D1F0A32DBF /* AppWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWindowController.swift; sourceTree = "<group>"; };
9A7F606D7F71B6FC0CC6ABC8 /* UninstallScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallScanner.swift; sourceTree = "<group>"; };
9AAFBFA70EF62B67F7357E0E /* UninstallCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallCoordinator.swift; sourceTree = "<group>"; };
@@ -405,13 +482,17 @@
9D134673CB1B563A3758FC37 /* UninstallRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallRunner.swift; sourceTree = "<group>"; };
9D4C3D9FA2F775DC650985D8 /* HoverArming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverArming.swift; sourceTree = "<group>"; };
A09528C8D92B1F3524DEE9D3 /* NoteWindowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteWindowLayout.swift; sourceTree = "<group>"; };
9F9A0A3867D1B8E1D3284471 /* ExtensionNodeShims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionNodeShims.swift; sourceTree = "<group>"; };
A09A0608AC763EBC26216118 /* RaycastFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaycastFormat.swift; sourceTree = "<group>"; };
A0BAA0471DD8CE4352F45FED /* ExtensionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCoordinator.swift; sourceTree = "<group>"; };
A1306524F57602D90FCF153C /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
A178C17DB989B6D2D71C021E /* ExtensionActionsPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionActionsPanel.swift; sourceTree = "<group>"; };
A5DFA4E0FDAC12379F2D38B5 /* CalloutPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalloutPlacement.swift; sourceTree = "<group>"; };
A7751EBF0F632A123B4A2363 /* ExtensionTintColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionTintColors.swift; sourceTree = "<group>"; };
A7DDDF0D150C30D0D0FC0BFD /* ExtensionCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCatalog.swift; sourceTree = "<group>"; };
A80495AE6BF7CE22BD6B20EE /* SnippetArgumentsPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetArgumentsPrompt.swift; sourceTree = "<group>"; };
A8AD14D51F74D1891D5532B0 /* NoteSwitcherInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteSwitcherInteraction.swift; sourceTree = "<group>"; };
A90559D7DB9F6F3874CAFCDE /* VolumeHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeHUDView.swift; sourceTree = "<group>"; };
A954E3DF28F609AAA3BEE216 /* MiscellaneousSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiscellaneousSettingsView.swift; sourceTree = "<group>"; };
AA7B3EB88881CEEFCE71448D /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = "<group>"; };
AA99178E0DCF6CBB521FB098 /* WindowMover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowMover.swift; sourceTree = "<group>"; };
AAD838940E655559E32BE8EA /* UninstallPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallPlan.swift; sourceTree = "<group>"; };
@@ -419,18 +500,21 @@
ACC8371BCD366F2549E0B01B /* SectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionHeader.swift; sourceTree = "<group>"; };
ADC45597561DCD90F51506F7 /* SearchScopesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScopesSection.swift; sourceTree = "<group>"; };
ADD4CE330F75BC5FE5EAD99E /* Tooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tooltip.swift; sourceTree = "<group>"; };
AE35B5E7B1CC94F759116097 /* CurrencyFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyFeed.swift; sourceTree = "<group>"; };
AEEB022D080E6DAF703D8132 /* SystemActionsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemActionsSettingsView.swift; sourceTree = "<group>"; };
B0ABE4AF80DD5F5F8E91F63E /* SpotlightNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightNames.swift; sourceTree = "<group>"; };
B21678BAD7BA4A4E300FDA2C /* WindowManagementSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManagementSettingsView.swift; sourceTree = "<group>"; };
B235DFF451FF4D52A8E44D00 /* ShortcutCaptureSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutCaptureSession.swift; sourceTree = "<group>"; };
B3C7D17F97002FD58E8A5F53 /* PermissionsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsSettingsView.swift; sourceTree = "<group>"; };
B40049CE320BEE3A4388CDFF /* CustomCommandEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommandEditorSheet.swift; sourceTree = "<group>"; };
B45A6AB1413A8A08EF7F0A9E /* ClipboardFilterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardFilterButton.swift; sourceTree = "<group>"; };
B6A6748260B2DF011CCAFCEF /* ThinScrollbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThinScrollbar.swift; sourceTree = "<group>"; };
B6A96350EBE68EC79497FA2D /* AppPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaths.swift; sourceTree = "<group>"; };
B6BE90FB09366A3518C2D4E5 /* EmojiData.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiData.generated.swift; sourceTree = "<group>"; };
B710F99B5B4E42FFB3EAA8FB /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = "<group>"; };
B7AA1305D43303E055DEE1E9 /* SettingsSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSplitViewController.swift; sourceTree = "<group>"; };
B97A8C3E88FEBCBBEADF415B /* LauncherScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherScreen.swift; sourceTree = "<group>"; };
B9F568F2F6EF857274F1A9D8 /* ExtensionIconCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionIconCache.swift; sourceTree = "<group>"; };
BA9F3FA9925873D25EAF7D03 /* PalettePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PalettePanel.swift; sourceTree = "<group>"; };
BDB241B179A08518424F7C1B /* DialogPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogPanel.swift; sourceTree = "<group>"; };
BE3331A5E8F892CC257AD31B /* SnippetMarkdownSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetMarkdownSerializer.swift; sourceTree = "<group>"; };
@@ -439,23 +523,29 @@
BFB023D167225228757CC4B2 /* HealthTicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthTicker.swift; sourceTree = "<group>"; };
C004F2C2EFDD340A73C6AB02 /* PaletteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteCoordinator.swift; sourceTree = "<group>"; };
C45190564C9D9F1722E83943 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
C54C4540AD6F188A2455473F /* ExtensionRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionRuntime.swift; sourceTree = "<group>"; };
C5BA06E4CC7E8D40E3285F16 /* KeyCapChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCapChip.swift; sourceTree = "<group>"; };
C72AE5D924466E1E80818A98 /* ExtensionStoreSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionStoreSheet.swift; sourceTree = "<group>"; };
C7B3FC48B58E1005453E7E18 /* Snippet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Snippet.swift; sourceTree = "<group>"; };
C80806A442BADAC390ECD993 /* RaycastSnippetImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaycastSnippetImport.swift; sourceTree = "<group>"; };
C845A6718CFBD1AFE93BA903 /* SettingsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDetailView.swift; sourceTree = "<group>"; };
CBFF39C46E98D0808B188E2E /* FileSearchIgnoreList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchIgnoreList.swift; sourceTree = "<group>"; };
CC67BADEB6FEC76C88585361 /* CalcUnits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcUnits.swift; sourceTree = "<group>"; };
CCCBDD92DEAD780A1DC423CA /* ExtensionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionScreen.swift; sourceTree = "<group>"; };
CF64F5FCB3872996AE57FDBB /* FileSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchCoordinator.swift; sourceTree = "<group>"; };
CFBF31AF26A4786B234ABB64 /* ExtensionAnimatedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionAnimatedImage.swift; sourceTree = "<group>"; };
D0508EBDFD6E59370AB2E5B7 /* UninstallScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallScreen.swift; sourceTree = "<group>"; };
D23A98172AA46F5C0C89C4AB /* RootPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootPaletteView.swift; sourceTree = "<group>"; };
D2D032AC22E53BB9997E492F /* CalculatorHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorHistoryStore.swift; sourceTree = "<group>"; };
D43F1080FB443A1D0616790E /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = "<group>"; };
D8B10DA3A9B1CBD9A03F3DE5 /* NoteDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteDocument.swift; sourceTree = "<group>"; };
D755EEABA1DA9CA4CE2D0B4F /* ExtensionAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionAppearance.swift; sourceTree = "<group>"; };
DA8CBA97041AAA96C0B7FF2E /* BackupActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupActions.swift; sourceTree = "<group>"; };
DB1752929BA1ACC0BC54865C /* HUDPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDPanel.swift; sourceTree = "<group>"; };
DB5FA545F484D7666E9A5F14 /* RunningAppsMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningAppsMonitor.swift; sourceTree = "<group>"; };
DBD9A72B34E336446F312B30 /* QuicklinkEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkEditorSheet.swift; sourceTree = "<group>"; };
DCBC5A9553716C8F03ED0216 /* SettingsToolbarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsToolbarController.swift; sourceTree = "<group>"; };
DD8CDDBAC59480E1A7559278 /* ExtensionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionManager.swift; sourceTree = "<group>"; };
DE1A2CE5E4D5F10346B5F6F3 /* EmojiGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiGridView.swift; sourceTree = "<group>"; };
DF0880635E294D9DF55BAADA /* CustomCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommand.swift; sourceTree = "<group>"; };
E1FB75182A60F65BA1FB9BA8 /* NotificationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToken.swift; sourceTree = "<group>"; };
@@ -468,16 +558,22 @@
E779B6EF738591CB935F3F95 /* WindowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLayout.swift; sourceTree = "<group>"; };
E7BBE5D84D3914059887FAEF /* NoteFileMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteFileMonitor.swift; sourceTree = "<group>"; };
E9D41B9E6F2C617D28C83AFF /* ShortcutRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorder.swift; sourceTree = "<group>"; };
EA9A0079D05A5D9B21A1CFCE /* FocusRelease.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusRelease.swift; sourceTree = "<group>"; };
EB76FDBFE1E306C2A5B1A29B /* Memo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Memo.swift; sourceTree = "<group>"; };
EC2EF66267B0141279A43613 /* SnippetsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetsSettingsView.swift; sourceTree = "<group>"; };
EEAFF79E9897975CAC5EAC0E /* NoteEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteEditorView.swift; sourceTree = "<group>"; };
ED5AAA98FFF7A4D34D7B490D /* ExtensionBootConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionBootConfig.swift; sourceTree = "<group>"; };
F023729C84682E9231F3D73B /* ExtensionInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionInstaller.swift; sourceTree = "<group>"; };
F0311153A4A3557C6FFEAAAB /* HotKeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyManager.swift; sourceTree = "<group>"; };
F097C9983DF27A99694C8BC5 /* Zlib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zlib.swift; sourceTree = "<group>"; };
F524FA7B33C0F861995EB1E6 /* QuicklinkArgumentsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkArgumentsScreen.swift; sourceTree = "<group>"; };
F58E564E6181EB15497E51CD /* SnippetTemplateEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetTemplateEngine.swift; sourceTree = "<group>"; };
F616D2EE62AEC6782E77364D /* QuicklinkArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkArchive.swift; sourceTree = "<group>"; };
F6C8B2F6592B65CB6576D247 /* ExtensionAppearancePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionAppearancePicker.swift; sourceTree = "<group>"; };
F7420F642776452B469182D0 /* FileSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchResult.swift; sourceTree = "<group>"; };
F77FF4F18AAA5D514A674878 /* CalcParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcParser.swift; sourceTree = "<group>"; };
F7EFE2C8A212D6979C43E9F7 /* RightClick.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightClick.swift; sourceTree = "<group>"; };
F85C82C66132C7723272E873 /* ExtensionGridGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionGridGeometry.swift; sourceTree = "<group>"; };
F9044F107ADD5896746DBC35 /* Signposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signposts.swift; sourceTree = "<group>"; };
FA90C004E55AB1BBC12D9F96 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = "<group>"; };
FBF6EF3A2C35F45B82D40968 /* ClipboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardView.swift; sourceTree = "<group>"; };
@@ -511,6 +607,7 @@
02ADF9B2610482EE749E5C66 /* Platform */ = {
isa = PBXGroup;
children = (
7073D3C441D37F94E780B1D7 /* Compression */,
DD0FEE8E2D95BD51907D383E /* Images */,
2575583134BE03EA9FA719CD /* ActivationPolicy.swift */,
5BBDB6A6CE9564ADA2D16F53 /* AppDisplayName.swift */,
@@ -521,11 +618,23 @@
EB76FDBFE1E306C2A5B1A29B /* Memo.swift */,
E1FB75182A60F65BA1FB9BA8 /* NotificationToken.swift */,
AA7B3EB88881CEEFCE71448D /* Permissions.swift */,
4CB0BD36800F3B1AC765F514 /* RegionCurrency.swift */,
F9044F107ADD5896746DBC35 /* Signposts.swift */,
);
path = Platform;
sourceTree = "<group>";
};
08C16F52EE13C1A75EAAB00F /* Settings */ = {
isa = PBXGroup;
children = (
F6C8B2F6592B65CB6576D247 /* ExtensionAppearancePicker.swift */,
4FA081CD5CEA054447D0691F /* ExtensionRegistriesSheet.swift */,
46762A28D73339953019C0AA /* ExtensionsSettingsView.swift */,
C72AE5D924466E1E80818A98 /* ExtensionStoreSheet.swift */,
);
path = Settings;
sourceTree = "<group>";
};
0ED457107377ECF8147C6B9D /* Onboarding */ = {
isa = PBXGroup;
children = (
@@ -558,6 +667,7 @@
4EF9F16C3F5567240FF03122 /* CalcQuantity.swift */,
CC67BADEB6FEC76C88585361 /* CalcUnits.swift */,
E5C7F0C39D24F068A8F84864 /* CurrencyData.generated.swift */,
AE35B5E7B1CC94F759116097 /* CurrencyFeed.swift */,
);
path = Model;
sourceTree = "<group>";
@@ -597,6 +707,7 @@
isa = PBXGroup;
children = (
8F6F59F3C88C49BDED945F25 /* EdgeDissolve.swift */,
88499DF2F503F2A3A19DEF07 /* OverflowFade.swift */,
622E1F1C01B954DCA2199BCD /* ScrollIntent.swift */,
2702F140F8DBF373D2ED11A5 /* SelectionFollowing.swift */,
648F0F43FEEE537F6451756B /* SelectionReveal.swift */,
@@ -632,6 +743,8 @@
children = (
6F218021C90FC685A2DF21CE /* Interaction */,
16F1DA610F3D792F6B08AFE5 /* Scrolling */,
4395BACF833C810961A248F0 /* BarButton.swift */,
08279257B0F5995E86FF2C03 /* EntryIconView.swift */,
C5BA06E4CC7E8D40E3285F16 /* KeyCapChip.swift */,
4FAE24CC14F7DFECD1BD7FB1 /* PopoverMenu.swift */,
BEDDA8820165672AF8464E25 /* SettingsComponents.swift */,
@@ -681,7 +794,6 @@
children = (
106B33C0ABA57694D12D02EC /* Model */,
0F27EAD6217BA0F1BCA8DD78 /* Service */,
7F5E5C2ED2F2125CE73A336A /* Settings */,
9262C41AED795D978F925FE9 /* UI */,
);
path = Calculator;
@@ -691,6 +803,7 @@
isa = PBXGroup;
children = (
E5B89168050723B7221F04A2 /* ClipboardCoordinator.swift */,
B45A6AB1413A8A08EF7F0A9E /* ClipboardFilterButton.swift */,
548C83C4086CD9FB8E85B222 /* ClipboardScreen.swift */,
FBF6EF3A2C35F45B82D40968 /* ClipboardView.swift */,
);
@@ -781,6 +894,21 @@
path = UI;
sourceTree = "<group>";
};
5ABBFC5F0A2FD6E0E22EB87B /* Model */ = {
isa = PBXGroup;
children = (
D755EEABA1DA9CA4CE2D0B4F /* ExtensionAppearance.swift */,
ED5AAA98FFF7A4D34D7B490D /* ExtensionBootConfig.swift */,
F85C82C66132C7723272E873 /* ExtensionGridGeometry.swift */,
30BBF211CE6A076EAF8D6C33 /* ExtensionManifest.swift */,
0ADFFB5180991764FB13FE0B /* ExtensionPackageManager.swift */,
810AA3AD31C7E11932E34F28 /* ExtensionRegistry.swift */,
7732FB0ED92C8FBFE22599CE /* ExtensionStoreResponse.swift */,
08A1D354C1785923E612848C /* RenderNode.swift */,
);
path = Model;
sourceTree = "<group>";
};
5AD8D02BFB02EE99DA2CFCB0 /* Settings */ = {
isa = PBXGroup;
children = (
@@ -830,6 +958,7 @@
6F218021C90FC685A2DF21CE /* Interaction */ = {
isa = PBXGroup;
children = (
EA9A0079D05A5D9B21A1CFCE /* FocusRelease.swift */,
73E25B87C692D5FEE1FBF994 /* PanelTransition.swift */,
F7EFE2C8A212D6979C43E9F7 /* RightClick.swift */,
8F1545635C1E94D5E7DFECF1 /* WindowDragHandle.swift */,
@@ -837,6 +966,14 @@
path = Interaction;
sourceTree = "<group>";
};
7073D3C441D37F94E780B1D7 /* Compression */ = {
isa = PBXGroup;
children = (
F097C9983DF27A99694C8BC5 /* Zlib.swift */,
);
path = Compression;
sourceTree = "<group>";
};
71757362845446986390E32B /* Model */ = {
isa = PBXGroup;
children = (
@@ -881,7 +1018,6 @@
isa = PBXGroup;
children = (
DA8CBA97041AAA96C0B7FF2E /* BackupActions.swift */,
1213B069324F16F5364FECD3 /* Gunzip.swift */,
95E3AA9E226F39DBB232501F /* RaycastImportV2.swift */,
91D0784E49BC5899A585F285 /* Scrypt.swift */,
);
@@ -910,14 +1046,6 @@
path = Launcher;
sourceTree = "<group>";
};
7F5E5C2ED2F2125CE73A336A /* Settings */ = {
isa = PBXGroup;
children = (
A954E3DF28F609AAA3BEE216 /* MiscellaneousSettingsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
83DF8416682FC5121DD7FD6E /* Dialog */ = {
isa = PBXGroup;
children = (
@@ -948,6 +1076,17 @@
path = Panes;
sourceTree = "<group>";
};
8A16DA3DB6733A27E0DF4059 /* Extensions */ = {
isa = PBXGroup;
children = (
5ABBFC5F0A2FD6E0E22EB87B /* Model */,
DA2B58692CED3E980CCBEE18 /* Service */,
08C16F52EE13C1A75EAAB00F /* Settings */,
A4BE2A27D71EDA81D3336B09 /* UI */,
);
path = Extensions;
sourceTree = "<group>";
};
8C33B7F733F716420E5387F2 /* Settings */ = {
isa = PBXGroup;
children = (
@@ -994,6 +1133,7 @@
005F026A8F7D5E209B1CE917 /* Clipboard */,
486F4CE5090E1ABA5AC16451 /* CustomCommands */,
15CFCD6B1429C5F0944CBCC4 /* Emoji */,
8A16DA3DB6733A27E0DF4059 /* Extensions */,
C6A3AB65E150BE9AFAF0EC2E /* FileSearch */,
3B3C887C1093082A211134ED /* HotKeys */,
7DE06EE636C14E50EE172D9E /* Launcher */,
@@ -1063,6 +1203,27 @@
path = Service;
sourceTree = "<group>";
};
A4BE2A27D71EDA81D3336B09 /* UI */ = {
isa = PBXGroup;
children = (
1590A8769EF787763DE189B7 /* CommandArgumentsRow.swift */,
A178C17DB989B6D2D71C021E /* ExtensionActionsPanel.swift */,
CFBF31AF26A4786B234ABB64 /* ExtensionAnimatedImage.swift */,
154222C91EFDB4D4490B6CE2 /* ExtensionArgumentsAccessory.swift */,
52146CB01C791AB02518273D /* ExtensionCommandScreen.swift */,
5D2DDA019FE0EF38A91AA495 /* ExtensionCommandView.swift */,
A0BAA0471DD8CE4352F45FED /* ExtensionCoordinator.swift */,
2B0D967615846F9C4EC3E44F /* ExtensionDetailView.swift */,
045EC0C36F08176EE0304F49 /* ExtensionFormView.swift */,
32D20FAFD9F3CC0E5802A966 /* ExtensionImage.swift */,
471E1C3C250FD0A89E2C6430 /* ExtensionListView.swift */,
7C0FB3A9CA762A7A8DB99BA6 /* ExtensionPaletteModifiers.swift */,
CCCBDD92DEAD780A1DC423CA /* ExtensionScreen.swift */,
A7751EBF0F632A123B4A2363 /* ExtensionTintColors.swift */,
);
path = UI;
sourceTree = "<group>";
};
A4EF9B5FDF7432E13D31FB87 /* Model */ = {
isa = PBXGroup;
children = (
@@ -1108,6 +1269,14 @@
path = Model;
sourceTree = "<group>";
};
AAC55CF3217A6A536330E9CF /* Resources */ = {
isa = PBXGroup;
children = (
74284B4E771DA0B2869C914E /* RaycastRuntime.generated.js */,
);
path = Resources;
sourceTree = "<group>";
};
B7F695C73111F7B44A3C240A /* Service */ = {
isa = PBXGroup;
children = (
@@ -1134,6 +1303,7 @@
95CA98985928A88B33F82556 /* Features */,
BCE065D58E699AFB41A13CD3 /* Palette */,
02ADF9B2610482EE749E5C66 /* Platform */,
AAC55CF3217A6A536330E9CF /* Resources */,
CACF5EC3FE229BE408F2449F /* Windows */,
C45190564C9D9F1722E83943 /* Assets.xcassets */,
4DA7B926405DAC632C056418 /* Info.plist */,
@@ -1229,6 +1399,22 @@
E7BBE5D84D3914059887FAEF /* NoteFileMonitor.swift */,
7F84B7743B803F55C41D5C4A /* NotesRepository.swift */,
FC2B77DA46386E7E459406E4 /* NotesStore.swift */,
DA2B58692CED3E980CCBEE18 /* Service */ = {
isa = PBXGroup;
children = (
1AA26F487C06DC6F03F78B13 /* ExtensionAppearanceStore.swift */,
A7DDDF0D150C30D0D0FC0BFD /* ExtensionCatalog.swift */,
87F282D4BBC2F147D3A98FFB /* ExtensionCleanup.swift */,
988EA3698EA86BA53590C24E /* ExtensionFetcher.swift */,
38765BC6C35D03659655BD2B /* ExtensionHostBridge.swift */,
B9F568F2F6EF857274F1A9D8 /* ExtensionIconCache.swift */,
F023729C84682E9231F3D73B /* ExtensionInstaller.swift */,
DD8CDDBAC59480E1A7559278 /* ExtensionManager.swift */,
9F9A0A3867D1B8E1D3284471 /* ExtensionNodeShims.swift */,
C54C4540AD6F188A2455473F /* ExtensionRuntime.swift */,
46CECB81C669337C8E6CF7FF /* ExtensionStorage.swift */,
410684C9DEB3754962C20C9C /* ExtensionStoreClient.swift */,
387BE675F580496656E72559 /* SymbolCatalog.swift */,
);
path = Service;
sourceTree = "<group>";
@@ -1302,6 +1488,7 @@
isa = PBXGroup;
children = (
5F0A68B2040582996EFBA877 /* CommandCatalog.swift */,
164CB3E1BB5B7D87AF8E7023 /* CommandID.swift */,
0EDB1EEB97CC5F6B0B81FFD6 /* LauncherRankingStore.swift */,
ABB9DB77DCE25C41D86BB955 /* SearchRelevance.swift */,
3E4A7E45F7D1CC5FB8868715 /* SearchScopes.swift */,
@@ -1368,6 +1555,7 @@
FE2191441B39CE41CC3052ED /* Model */ = {
isa = PBXGroup;
children = (
682E7F6534CB4014969D98E0 /* ClipboardFilter.swift */,
201FCD08ACA933378876385B /* ClipboardStore.swift */,
);
path = Model;
@@ -1434,6 +1622,7 @@
buildActionMask = 2147483647;
files = (
9969E6858C4FDC5CCCDEB5D8 /* Assets.xcassets in Resources */,
694B7E1A8C7C2D6DF26D3BF9 /* RaycastRuntime.generated.js in Resources */,
36784C964DB27B94A62EEAA3 /* tinycast.icon in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -1462,6 +1651,7 @@
BD45A99F26C015BF00AC77D5 /* ApplicationsSettingsView.swift in Sources */,
A0A247FFDB1EA278C0F6F813 /* BackupActions.swift in Sources */,
4549280508130B232069071E /* BackupSettingsView.swift in Sources */,
0820C7539E3328338C3C8FBD /* BarButton.swift in Sources */,
B6525300EEDACE4F9DC3179E /* CalcCurrency.swift in Sources */,
ADE29D44A9504AC24FC17E72 /* CalcDateTime.swift in Sources */,
309B1313B6B40D1342362492 /* CalcEngine.swift in Sources */,
@@ -1478,14 +1668,19 @@
279F586CC34205EBA8ACA892 /* CalloutPlacement.swift in Sources */,
80E77985425032856DEECDFD /* CalloutShape.swift in Sources */,
A320745F576C5915217DF113 /* ClipboardCoordinator.swift in Sources */,
AD9753D6EAF202B6E31510EB /* ClipboardFilter.swift in Sources */,
1F8F2135470F74627A644ACC /* ClipboardFilterButton.swift in Sources */,
A64891F5587F2C11E4A2E2D2 /* ClipboardManager.swift in Sources */,
935F7433BB0C0ECDE6FA3BF3 /* ClipboardScreen.swift in Sources */,
D4A45373662D6658706E4DA4 /* ClipboardSettingsView.swift in Sources */,
3155DE3A2195764DDEA97F26 /* ClipboardStore.swift in Sources */,
03C41F6394089BAEC7E630D6 /* ClipboardView.swift in Sources */,
4EBFBA1CC7DA9B22C396DBEC /* CommandArgumentsRow.swift in Sources */,
89872774514D83EC2BD60F1E /* CommandCatalog.swift in Sources */,
BEAAD72E748375E0039AC90F /* CommandID.swift in Sources */,
4640C7CC8DCA6E6FA49394A8 /* CommandsSettingsView.swift in Sources */,
2EBB42C7EB17F37628765B17 /* CurrencyData.generated.swift in Sources */,
5A91C58340D02ED0939BCAE2 /* CurrencyFeed.swift in Sources */,
17F61DEF6389FBCFCCFB467C /* CurrencyRateStore.swift in Sources */,
57D7D94F9CB94B1152D44879 /* CursorScreen.swift in Sources */,
8CC7A4A5D4205843219A81C8 /* CustomCommand.swift in Sources */,
@@ -1507,6 +1702,43 @@
A7F4CD74AEBEFF9F07103CD7 /* EmojiIndex.swift in Sources */,
614DAAC9331F9C1DE4D76F0D /* EmojiScreen.swift in Sources */,
0C1CACEF014462D3F581CE54 /* EmojiSettingsView.swift in Sources */,
017FB0EDDC9AF28ED81EA6A8 /* EntryIconView.swift in Sources */,
DA781EE329E4D88B9C72788C /* ExtensionActionsPanel.swift in Sources */,
DCAC5BBB64C93F559B25E6FD /* ExtensionAnimatedImage.swift in Sources */,
A7D7F8F64B0937D20D97F428 /* ExtensionAppearance.swift in Sources */,
FD8DE9FDC0B6D5D94B59C4DB /* ExtensionAppearancePicker.swift in Sources */,
BEA4A58DE854DC330E90B5B9 /* ExtensionAppearanceStore.swift in Sources */,
7320E4B2ABFAE8C1D76A3526 /* ExtensionArgumentsAccessory.swift in Sources */,
1B9BC6B00770D1F002E62E0D /* ExtensionBootConfig.swift in Sources */,
32238C3CCA0310804D30506A /* ExtensionCatalog.swift in Sources */,
4D3E47D6F382933EB3B1AB2F /* ExtensionCleanup.swift in Sources */,
A5693BFABA85B873EC7F1E0B /* ExtensionCommandScreen.swift in Sources */,
B8CECD85524BCCCBC0CDC2B5 /* ExtensionCommandView.swift in Sources */,
1F0E7F2EF27CB82211EDCB4C /* ExtensionCoordinator.swift in Sources */,
BFA8B1578664272E7CBFF806 /* ExtensionDetailView.swift in Sources */,
B566FC125D9D193F234EC1AB /* ExtensionFetcher.swift in Sources */,
5D3E51C5FDA9EE53AFD12181 /* ExtensionFormView.swift in Sources */,
C9A7290682E9D0881713C009 /* ExtensionGridGeometry.swift in Sources */,
5B90A314CF4801995EA0EE01 /* ExtensionHostBridge.swift in Sources */,
0288221BA580DFA8F5B116B7 /* ExtensionIconCache.swift in Sources */,
9E5B694969D3F99BF7D0D9E1 /* ExtensionImage.swift in Sources */,
312BCD2850AFD26E8B532937 /* ExtensionInstaller.swift in Sources */,
2F2360730BE4992D5745FAD3 /* ExtensionListView.swift in Sources */,
23E8792D69F3F1C539BC3BC6 /* ExtensionManager.swift in Sources */,
45799EE1F41091B32544B6AE /* ExtensionManifest.swift in Sources */,
552B7230E78D2C05793EC013 /* ExtensionNodeShims.swift in Sources */,
FA386C9B6DA24481565D506D /* ExtensionPackageManager.swift in Sources */,
2D51D92BE8EAB5440F4C8517 /* ExtensionPaletteModifiers.swift in Sources */,
CF1015EA1573EFBBA647B93B /* ExtensionRegistriesSheet.swift in Sources */,
6FC9F74D9119AC25D178D748 /* ExtensionRegistry.swift in Sources */,
50A3CB2A348F8E03D467B169 /* ExtensionRuntime.swift in Sources */,
F51785CF5A6EC0B87D6802C9 /* ExtensionScreen.swift in Sources */,
606809BC14D6613B847036D0 /* ExtensionStorage.swift in Sources */,
9608AFDEDC3B330E64F53437 /* ExtensionStoreClient.swift in Sources */,
0120AF7AB3AD249C484C8A03 /* ExtensionStoreResponse.swift in Sources */,
1A831E78CC5D84D543763B9C /* ExtensionStoreSheet.swift in Sources */,
62C75AA7315CDEFE82ECD21B /* ExtensionTintColors.swift in Sources */,
330540BBA991E36DD4C4BF53 /* ExtensionsSettingsView.swift in Sources */,
555791AE6CDCF42ECA171B2C /* FavoritesStore.swift in Sources */,
345D1FCC2085BD0D699DD236 /* FileSearchCoordinator.swift in Sources */,
5970306ABC24B03028E66D45 /* FileSearchIgnoreList.swift in Sources */,
@@ -1519,9 +1751,9 @@
84A1F72DA5038C3725DCA201 /* FileSearchService.swift in Sources */,
691ADED3F9F3F730E1F36DB8 /* FileSearchSession.swift in Sources */,
24EFA87B2617537B6CAAD52B /* FileSearchSettingsView.swift in Sources */,
303FC5342B61A7862EBC1C32 /* FocusRelease.swift in Sources */,
7833FD6E0A6036954FE285CE /* FrequentEmojiStore.swift in Sources */,
C5F60D8681235B95DAA3314F /* GeneralSettingsView.swift in Sources */,
42E1CF2605A0E02D84932B26 /* Gunzip.swift in Sources */,
A500AF3BF8910B335A7A936C /* HUDPanel.swift in Sources */,
68537C3544E4EBC40E3ACD39 /* HUDPresenter.swift in Sources */,
14D056C9DA105D9D89A9691E /* HealthTicker.swift in Sources */,
@@ -1546,7 +1778,6 @@
AAFD2C9276D38719E85B675F /* Memo.swift in Sources */,
43DBCE7B5A84D0D6322124F5 /* MessageHUDController.swift in Sources */,
8D529C8F3AB579941067FE98 /* MessageHUDView.swift in Sources */,
F226F68554B1FBDDC1893C42 /* MiscellaneousSettingsView.swift in Sources */,
8D20CD7B6918873960F84291 /* NoteDocument.swift in Sources */,
47A443F25878A27DE6DF24AA /* NoteEditorView.swift in Sources */,
387201DB768A56DA6C180E05 /* NoteFileMonitor.swift in Sources */,
@@ -1568,6 +1799,7 @@
E6E8E3E2ECD0CD9A982115F3 /* OnboardingCoordinator.swift in Sources */,
19317D6C8A55E3F85BB7AE1F /* OnboardingState.swift in Sources */,
0DD808F80666846F4E74BAD8 /* OnboardingView.swift in Sources */,
C385AEF3C8C5A6B2BACC1D9A /* OverflowFade.swift in Sources */,
86C36C0885D5820E70184CF6 /* PaletteCoordinator.swift in Sources */,
8DCF330D30E59FFEE8C8E8CF /* PaletteDropGuideController.swift in Sources */,
A47E49315D60D94CF75E22C9 /* PaletteDropGuideView.swift in Sources */,
@@ -1603,6 +1835,8 @@
AF3A21A4FDF29BA834696F14 /* RaycastImportV2.swift in Sources */,
478500C6465327947EBD9D61 /* RaycastSnippetImport.swift in Sources */,
FE9CFF847072FAA9C7D5B660 /* RaycastV1Decoder.swift in Sources */,
3E2E0C5B564C828D7ECA2759 /* RegionCurrency.swift in Sources */,
9B05C47A7414D04E20237B09 /* RenderNode.swift in Sources */,
AD98F015439106F0E9D32015 /* RightClick.swift in Sources */,
47E13BC63F8BE11EDF25EA82 /* RootPaletteView.swift in Sources */,
9A80D2997DCF835AF225A586 /* RunningAppsMonitor.swift in Sources */,
@@ -1643,6 +1877,7 @@
3BC6CC35889FFC77D640C7CA /* SnippetsSettingsView.swift in Sources */,
7BDB7CF7F1352015F6AC96C3 /* SnippetsStore.swift in Sources */,
089C174783A60216FCC952B3 /* SpotlightNames.swift in Sources */,
9DB09F96E6F8C226BD9AB1C9 /* SymbolCatalog.swift in Sources */,
FF934DD9FBD8F26BAB72ACD1 /* SymbolImage.swift in Sources */,
71AA40FC4B8C57FFDE5F8DFC /* SystemAction.swift in Sources */,
A11EB7D1852FB3D56DDBE7C3 /* SystemActionCoordinator.swift in Sources */,
@@ -1679,6 +1914,7 @@
19B0314193376FD1E4AC11A5 /* WindowLayout.swift in Sources */,
0360C7038877B9CB656DB697 /* WindowManagementSettingsView.swift in Sources */,
A6A8CD2B71A15625F47498EA /* WindowMover.swift in Sources */,
6CDA82FD63F87D07E30E3F82 /* Zlib.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+21 -4
View File
@@ -33,6 +33,7 @@ final class AppCore {
let uninstall = UninstallSession()
let quicklinkArguments = QuicklinkArgumentSession()
let notesStore: NotesStore
let extensions: ExtensionManager
/// Set when a quicklink editor should open with Settings; the pane consumes it.
var pendingQuicklinkEdit: QuicklinkEditRequest?
@@ -63,6 +64,9 @@ final class AppCore {
session: uninstall, palette: palette, paletteCoordinator: paletteCoordinator,
appIndex: appIndex, runningApps: runningApps, hotKeys: hotKeys, favorites: favorites,
visibility: visibility, ranking: launcherRanking, core: self)
@ObservationIgnored private(set) lazy var extensionCoordinator = ExtensionCoordinator(
extensions: extensions, palette: palette, paletteCoordinator: paletteCoordinator,
settingsCoordinator: settingsCoordinator, settings: settings, core: self)
@ObservationIgnored private(set) lazy var windowCommandCoordinator = WindowCommandCoordinator(
settings: settings, paletteCoordinator: paletteCoordinator, windowMover: windowMover)
@ObservationIgnored private(set) lazy var customCommandCoordinator = CustomCommandCoordinator(
@@ -98,7 +102,8 @@ final class AppCore {
quicklinkCoordinator: quicklinkCoordinator,
windowCommandCoordinator: windowCommandCoordinator,
snippetExpansion: snippetExpansion, fileSearchCoordinator: fileSearchCoordinator,
notesCoordinator: notesCoordinator, core: self)
notesCoordinator: notesCoordinator, extensionCoordinator: extensionCoordinator,
core: self)
@ObservationIgnored private(set) lazy var clipboardCoordinator = ClipboardCoordinator(
clipboardStore: clipboardStore, palette: palette, windowController: windowController,
paletteCoordinator: paletteCoordinator, core: self)
@@ -125,6 +130,7 @@ final class AppCore {
appIndex = AppIndex(ranking: launcherRanking)
let clipboardManager = ClipboardManager(store: clipboardStore, settings: settings)
self.clipboardManager = clipboardManager
extensions = ExtensionManager(clipboardStore: clipboardStore)
snippetsStore = SnippetsStore()
snippetTextInjector = SnippetTextInjector(
clipboardManager: clipboardManager,
@@ -158,6 +164,8 @@ final class AppCore {
clipboardManager.start()
appIndex.start(settings: settings)
extensions.start(appIndex: appIndex, coordinator: extensionCoordinator)
extensionCoordinator.applyEnabled()
fileSearchCoordinator.applyEnabled()
fileSearchCoordinator.applyPolicy()
notesCoordinator.applyEnabled()
@@ -199,6 +207,12 @@ final class AppCore {
hotKeys.onOpenQuicklink = { [weak self] id in
self?.quicklinkCoordinator.openQuicklink(id: id)
}
hotKeys.onRunExtensionCommand = { [weak self] entryID in
self?.extensionCoordinator.runExtensionCommand(entryID: entryID)
}
extensions.onDidUninstall = { [weak self] entryIDs in
self?.extensionCoordinator.removeExtensionReferences(entryIDs: entryIDs)
}
hotKeys.displayName = { [weak self] action in self?.hotKeyDisplayName(for: action) }
KeyShortcut.displayedHyperChord = { [settings] in
guard settings.hyperKey != .none else { return nil }
@@ -253,6 +267,8 @@ final class AppCore {
return customCommands.command(id: id)?.name
case .quicklink(let id):
return quicklinks.quicklink(id: id)?.name
case .extensionCommand(let entryID):
return appIndex.apps.first { $0.kind == .extensionCommand && $0.id == entryID }?.name
case .togglePalette, .toggleClipboard, .toggleEmoji, .searchFiles, .systemAction,
.showNotes, .createNote, .searchNotes, .windowCommand:
return nil
@@ -347,12 +363,13 @@ final class AppCore {
/// `tone` styles the glyph, `confirmRole` the button; separate on purpose.
func confirm(
title: String, message: String, symbol: String, confirmTitle: String,
tone: DialogTone = .danger, confirmRole: DialogAction.Role = .destructive
title: String, message: String?, symbol: String, confirmTitle: String,
tone: DialogTone = .danger, confirmRole: DialogAction.Role = .destructive,
dismissTitle: String = "Cancel"
) async -> Bool {
await dialogs.confirm(
title: title, message: message, symbol: symbol, tone: tone, confirmTitle: confirmTitle,
confirmRole: confirmRole)
confirmRole: confirmRole, dismissTitle: dismissTitle)
}
/// A failure with one usable second option; `true` when the user takes it.
+40
View File
@@ -0,0 +1,40 @@
import SwiftUI
/// A bar control's hover chrome. The footer's buttons are capsules; one that opens a menu takes
/// that menu's own corner instead, so the control and what it opens read as one piece.
enum BarButtonChrome {
case capsule
case menu
var shape: AnyShape {
switch self {
case .capsule:
return AnyShape(Capsule())
case .menu:
return AnyShape(
RoundedRectangle(cornerRadius: Theme.Radius.menuRow, style: .continuous))
}
}
}
/// A palette bar control: bare label at rest, a faint fill on hover.
/// Hover lives here, so a sweep across one never re-renders the view that owns it.
struct BarButton<Label: View>: View {
var chrome: BarButtonChrome = .capsule
let action: () -> Void
@ViewBuilder let label: Label
@State private var hovered = false
var body: some View {
let shape = chrome.shape
return Button(action: action) {
label
.padding(.horizontal, Theme.Spacing.md)
.frame(height: Theme.Size.barButtonHeight)
.contentShape(shape)
.background(shape.fill(hovered ? Theme.Colors.rowHover : Color.clear))
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
}
}
+34
View File
@@ -0,0 +1,34 @@
import SwiftUI
/// Draws an `EntryIcon`, seeded from the cache so a warm glyph paints on the first frame.
struct EntryIconView: View {
let source: EntryIcon
/// Only `.file` needs it, and only the launcher has one to give.
var fileURL: URL = URL(fileURLWithPath: "/")
@State private var image: NSImage?
init(source: EntryIcon, fileURL: URL = URL(fileURLWithPath: "/")) {
self.source = source
self.fileURL = fileURL
_image = State(initialValue: IconCache.cached(source, fileURL: fileURL))
}
var body: some View {
Group {
if let image {
Image(nsImage: image).resizable()
} else {
RoundedRectangle(cornerRadius: Theme.Radius.row, style: .continuous)
.fill(Color.white.opacity(0.06))
}
}
// Keyed on the glyph, not the entry: re-skinning leaves an id untouched.
.task(id: source) {
if let warm = IconCache.cached(source, fileURL: fileURL) {
image = warm
return
}
image = await IconCache.loadAsync(source, fileURL: fileURL)
}
}
}
@@ -0,0 +1,45 @@
import AppKit
import SwiftUI
/// Clicking away from a text field leaves it focused: a SwiftUI text field on macOS holds first
/// responder until another focusable view claims it, and blank form space claims nothing. So the
/// window is watched for clicks that land outside the open field editor, and focus is dropped there.
///
/// A local event monitor rather than a gesture: a gesture over the form would either swallow the
/// click or fire alongside the one that is focusing a *different* field, and take its focus away.
private struct FocusReleaseOnOutsideClick: ViewModifier {
@State private var monitor: Any?
func body(content: Content) -> some View {
content
.onAppear {
guard monitor == nil else { return }
monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { event in
release(on: event)
return event
}
}
.onDisappear {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
}
}
private func release(on event: NSEvent) {
guard let window = event.window,
// Only while something is actually being edited: the field editor is the responder.
let editor = window.firstResponder as? NSTextView, editor.isFieldEditor
else { return }
let hit = window.contentView?.hitTest(event.locationInWindow)
// A click on the field being edited, or on another text control, is that control's business.
guard let hit, !hit.isDescendant(of: editor), !(hit is NSTextView) else { return }
window.makeFirstResponder(nil)
}
}
extension View {
/// Drops keyboard focus when a click lands outside the field being edited.
func releasesFocusOnOutsideClick() -> some View {
modifier(FocusReleaseOnOutsideClick())
}
}
+3 -1
View File
@@ -53,6 +53,8 @@ struct PopoverMenu: View {
var header: String?
let items: [PopoverMenuItem]
@Binding var selection: Int
/// Fixed, never intrinsic: a menu whose width tracked its longest row would jitter as it changes.
var width: CGFloat = Theme.Size.menuWidth
let onActivate: (Int) -> Void
var body: some View {
@@ -78,7 +80,7 @@ struct PopoverMenu: View {
}
}
.padding(Theme.Spacing.sm)
.frame(width: Theme.Size.menuWidth)
.frame(width: width)
// Glass carries its own elevation, so a drop shadow on top reads heavy.
.glassEffect(
.regular, in: RoundedRectangle(cornerRadius: Theme.Radius.menuPanel, style: .continuous)
@@ -0,0 +1,46 @@
import SwiftUI
/// Bottom fade for a Settings list: marks content below the edge, and clears once the list rests.
struct OverflowFadeMask: ViewModifier {
/// Short enough to read as an edge treatment rather than as a dimmed final row.
var band: CGFloat = 24
/// Content hidden below the visible area, 0 once the list rests against the bottom.
@State private var overflow: CGFloat = 0
func body(content: Content) -> some View {
content
.onScrollGeometryChange(for: CGFloat.self) { geo in
geo.contentSize.height + geo.contentInsets.bottom - geo.containerSize.height
- geo.contentOffset.y
} action: { _, new in
overflow = max(0, new)
}
.mask(
GeometryReader { geo in
LinearGradient(
stops: stops(height: geo.size.height),
startPoint: .top, endPoint: .bottom
)
}
)
}
private func stops(height: CGFloat) -> [Gradient.Stop] {
// Eased over one band, so the last row never snaps between opaque and faded in one step.
let strength = min(overflow / band, 1)
guard strength > 0, height > band else { return [.init(color: .black, location: 0)] }
return [
.init(color: .black, location: 0),
.init(color: .black, location: 1 - band / height),
.init(color: .black.opacity(1 - strength), location: 1)
]
}
}
extension View {
/// Attach before `thinScrollbar`. Not `edgeDissolve`, which is tuned to the palette's bars.
func overflowFade() -> some View {
modifier(OverflowFadeMask())
}
}
+13 -1
View File
@@ -74,13 +74,23 @@ struct FeatureSwitchSection: View {
struct SettingsFilterField: View {
let prompt: String
@Binding var query: String
/// The field is plain-styled and so has no bezel of its own: without this, only the glyphs
/// themselves are a target, and clicking the rest of the row does nothing.
@FocusState private var focused: Bool
var body: some View {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(prompt, text: $query)
// `prompt:`, not the title argument: inside a `Form` a text field's title is rendered as
// its label in the left-hand column, which turns the placeholder into a heading. And
// `labelsHidden`, or the form reserves that column for the empty title anyway and the
// field starts halfway across the row, nowhere near the magnifying glass.
TextField("", text: $query, prompt: Text(prompt))
.textFieldStyle(.plain)
.labelsHidden()
.focused($focused)
.pointerStyle(.horizontalText)
if !query.isEmpty {
Button {
query = ""
@@ -92,5 +102,7 @@ struct SettingsFilterField: View {
.accessibilityLabel("Clear search")
}
}
.contentShape(.rect)
.onTapGesture { focused = true }
}
}
+8
View File
@@ -62,6 +62,8 @@ enum Theme {
static let dropGuideDash: CGFloat = 4
static let dropGuideWidth: CGFloat = 2
static let bottomBarHeight: CGFloat = 52
/// A `BarButton`'s hover capsule, shared by the footer group and the header's filter.
static let barButtonHeight: CGFloat = 28
static let rowIcon: CGFloat = 24
static let keyCap: CGFloat = 18
/// Settings shortcut-recorder keycap — smaller than the palette's `keyCap` chip.
@@ -90,6 +92,8 @@ enum Theme {
static let clipboardListWidth: CGFloat = 290
static let emojiCell: CGFloat = 56
static let menuWidth: CGFloat = 276
/// The clipboard type filter's menu; `menuWidth` is far too wide for five short rows.
static let clipboardFilterMenuWidth: CGFloat = 200
/// A menu row's glyph slot, sized so symbol and app-icon rows read the same.
static let menuIcon: CGFloat = 20
/// Opening size and the resize floor; tall enough that the sidebar's rows never scroll.
@@ -101,6 +105,8 @@ enum Theme {
static let settingsRowIcon: CGFloat = 20
/// Settings editor modals (Custom Commands, Snippets): fixed width, intrinsic height.
static let editorSheetWidth: CGFloat = 480
/// Label column of an extension's `Form`, so every field's input starts on one line.
static let formLabelWidth: CGFloat = 110
/// The multi-line box inside those modals; it scrolls rather than grows the sheet.
static let editorTextHeight: CGFloat = 120
/// The argument prompt's field column, kept under the alert's natural width.
@@ -152,6 +158,8 @@ enum Theme {
static let compactKeyCap = Font.caption2
static let heroKeyCap = Font.body
static let bar = Font.callout.weight(.medium)
/// A dropdown control's trailing chevron, deliberately smaller than the label it follows.
static let disclosure = Font.caption.weight(.semibold)
static let menuRow = Font.body
static let menuShortcut = Font.callout
static let menuIcon = Font.body
@@ -52,7 +52,7 @@ enum RaycastV1Decoder {
// PKCS#7 unpads cleanly ~1 in 256 times, so the gzip header is the real check.
let magic = [UInt8](plaintext.prefix(3))
guard magic.count == 3, magic[0] == 0x1f, magic[1] == 0x8b, magic[2] == 0x08,
let json = try? Gunzip.decompress(plaintext)
let json = try? Zlib.gunzip(plaintext)
else { throw RaycastImportError.incorrectPassphrase }
return json
}
@@ -45,6 +45,7 @@ struct SettingsBackup: Codable {
// Carried, unlike `snippetsEnabled`: opening a link grants no permission class of its own.
var quicklinksEnabled: Bool?
var quicklinksShowInLauncher: Bool?
var extensionsShowInLauncher: Bool?
var quicklinkOpensNewWindow: Bool?
var quicklinkSelectionFallback: String?
var quicklinkConfirmsBeforeDelete: Bool?
@@ -114,6 +115,7 @@ extension SettingsBackup {
windowCycleOnRepeat: s.windowCycleOnRepeat,
quicklinksEnabled: s.quicklinksEnabled,
quicklinksShowInLauncher: s.quicklinksShowInLauncher,
extensionsShowInLauncher: s.extensionsShowInLauncher,
quicklinkOpensNewWindow: s.quicklinkOpensNewWindow,
quicklinkSelectionFallback: s.quicklinkSelectionFallback.rawValue,
quicklinkConfirmsBeforeDelete: s.quicklinkConfirmsBeforeDelete)
@@ -295,6 +297,10 @@ extension SettingsBackup {
settings.quicklinksEnabled = flag
count += 1
}
if let flag = s.extensionsShowInLauncher {
settings.extensionsShowInLauncher = flag
count += 1
}
if let flag = s.quicklinksShowInLauncher {
settings.quicklinksShowInLauncher = flag
count += 1
@@ -31,7 +31,8 @@ enum SettingsBackupCoverage {
"quicklinksShowInLauncher": .quicklinksShowInLauncher,
"quicklinkOpensNewWindow": .quicklinkOpensNewWindow,
"quicklinkSelectionFallback": .quicklinkSelectionFallback,
"quicklinkConfirmsBeforeDelete": .quicklinkConfirmsBeforeDelete
"quicklinkConfirmsBeforeDelete": .quicklinkConfirmsBeforeDelete,
"extensionsShowInLauncher": .extensionsShowInLauncher
]
/// The `SettingsData` fields no `AppSettings` key stands behind, and what they read instead.
@@ -44,6 +45,12 @@ enum SettingsBackupCoverage {
static let deliberatelyExcluded: [String: String] = [
AppSettingsKey.snippetsEnabled.rawValue:
"Doubles as keyword-expansion consent; an import must not enable keystroke listening.",
AppSettingsKey.extensionPackageManager.rawValue:
"Names a tool on this Mac; the machine a backup lands on may not have it.",
AppSettingsKey.extensionRegistries.rawValue:
"A registry is a source of executable code; adding one has to be a deliberate act.",
AppSettingsKey.extensionsEnabled.rawValue:
"Doubles as consent to run third-party JavaScript; an import must not switch it on.",
AppSettingsKey.palettePosition.rawValue:
"Machine-local geometry: a point restored onto another display layout lands nowhere."
]
@@ -1,77 +0,0 @@
import Compression
import Foundation
enum GunzipError: Error { case notGzip, corrupt }
/// Gzip (RFC 1952); `Compression` speaks raw DEFLATE, so the wrapper is parsed here.
enum Gunzip {
// Cap inflated output: the outer envelope inflates before any authentication.
static let defaultMaxOutput = 64 * 1024 * 1024
static func decompress(_ data: Data, maxOutput: Int = defaultMaxOutput) throws -> Data {
let bytes = [UInt8](data)
// Magic (1f 8b) + deflate method (08) + the 10-byte fixed header and 8-byte trailer.
guard bytes.count >= 18, bytes[0] == 0x1f, bytes[1] == 0x8b, bytes[2] == 0x08 else {
throw GunzipError.notGzip
}
let flags = bytes[3]
var index = 10
if flags & 0x04 != 0 { // FEXTRA: 2-byte length + payload
guard index + 2 <= bytes.count else { throw GunzipError.corrupt }
index += 2 + (Int(bytes[index]) | Int(bytes[index + 1]) << 8)
}
if flags & 0x08 != 0 { index = try skipCString(bytes, from: index) } // FNAME
if flags & 0x10 != 0 { index = try skipCString(bytes, from: index) } // FCOMMENT
if flags & 0x02 != 0 { index += 2 } // FHCRC
guard index < bytes.count - 8 else { throw GunzipError.corrupt }
// Slice `bytes`, not `data`: `data` may be a slice whose indices start elsewhere.
return try rawInflate(Data(bytes[index..<(bytes.count - 8)]), maxOutput: maxOutput)
}
private static func skipCString(_ bytes: [UInt8], from start: Int) throws -> Int {
var i = start
while i < bytes.count {
if bytes[i] == 0 { return i + 1 }
i += 1
}
throw GunzipError.corrupt
}
private static func rawInflate(_ deflate: Data, maxOutput: Int) throws -> Data {
let bufferSize = 256 * 1024
let dst = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { dst.deallocate() }
// The C struct has no zero-arg init; the placeholders are overwritten first.
var stream = compression_stream(
dst_ptr: dst, dst_size: bufferSize, src_ptr: dst, src_size: 0, state: nil)
guard
compression_stream_init(&stream, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB)
!= COMPRESSION_STATUS_ERROR
else { throw GunzipError.corrupt }
defer { compression_stream_destroy(&stream) }
let output: Data? = deflate.withUnsafeBytes { raw in
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return Data() }
stream.src_ptr = base
stream.src_size = raw.count
var out = Data()
while true {
stream.dst_ptr = dst
stream.dst_size = bufferSize
let status = compression_stream_process(
&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue))
switch status {
case COMPRESSION_STATUS_OK, COMPRESSION_STATUS_END:
out.append(dst, count: bufferSize - stream.dst_size)
if out.count > maxOutput { return nil }
if status == COMPRESSION_STATUS_END { return out }
default:
return nil
}
}
}
guard let output else { throw GunzipError.corrupt }
return output
}
}
@@ -11,7 +11,7 @@ enum RaycastImportV2 {
// MARK: - Decrypt
static func decrypt(_ raw: Data, passphrase: String) throws -> Data {
guard let envelopeData = try? Gunzip.decompress(raw),
guard let envelopeData = try? Zlib.gunzip(raw),
let env = try? JSONSerialization.jsonObject(with: envelopeData) as? [String: Any],
let dataHex = env["data"] as? String,
let enc = env["encryption"] as? [String: String],
@@ -32,7 +32,7 @@ enum RaycastImportV2 {
} catch {
throw RaycastImportError.incorrectPassphrase
}
guard let plaintext = try? Gunzip.decompress(plaintextGz) else {
guard let plaintext = try? Zlib.gunzip(plaintextGz) else {
throw RaycastImportError.corrupt
}
return plaintext
@@ -26,12 +26,6 @@ struct CurrencyRates: Codable, Equatable, Sendable {
}
}
/// The consent gate as a type; `.off` ships by default. See docs/features/calculator.md#consent.
enum CurrencySource: Equatable, Sendable {
case off
case on(CurrencyRates?)
}
enum CalcCurrency {
enum ConversionParse: Equatable {
case value(input: Double, from: CurrencyDef, to: CurrencyDef, output: Double)
@@ -47,9 +41,7 @@ enum CalcCurrency {
static let categoryName = "Currency"
/// `expr currency (to|in|->) currency`, shaped like `CalcUnits.parseConversion`, run after it.
static func parseConversion(_ tokens: [CalcToken], source: CurrencySource) -> ConversionParse? {
// The consent gate, before any parsing: without it the feature does not exist.
guard case .on(let rates) = source else { return nil }
static func parseConversion(_ tokens: [CalcToken], rates: CurrencyRates?) -> ConversionParse? {
let tokens = amountFirst(tokens)
guard tokens.count >= 3, CalcUnits.isConnector(tokens[tokens.count - 2]),
case .ident(let toName) = tokens[tokens.count - 1],
@@ -121,18 +113,55 @@ enum CalcCurrency {
"SAR": ["riyal", "riyals"] // 2
]
/// Hand-written because no standards body names a coin. docs/features/calculator.md
static let crypto: [(code: String, name: String, aliases: [String])] = [
("ADA", "Cardano", ["cardano"]),
("AVAX", "Avalanche", ["avalanche"]),
("BCH", "Bitcoin Cash", []),
("BNB", "BNB", ["binance"]),
("BSV", "Bitcoin SV", []),
("BTC", "Bitcoin", ["bitcoin"]),
("DASH", "Dash", []),
("DOGE", "Dogecoin", ["dogecoin"]),
("DOT", "Polkadot", ["polkadot"]),
("EOS", "EOS", []),
("ETC", "Ethereum Classic", []),
("ETH", "Ethereum", ["ethereum", "ether"]),
("LTC", "Litecoin", ["litecoin"]),
("LUNA", "Terra", ["terra"]),
("NEO", "Neo", []),
("POL", "Polygon", ["polygon"]),
("SHIB", "Shiba Inu", ["shiba"]),
("SOL", "Solana", ["solana"]),
("TRX", "TRON", ["tron"]),
("USDT", "Tether", ["tether"]),
("XLM", "Stellar", ["stellar"]),
("XMR", "Monero", ["monero"]),
("XRP", "XRP", ["ripple"])
]
/// `CurrencyRateStore` builds its request from this, so the two lists cannot drift apart.
static let cryptoCodes: [String] = crypto.map(\.code)
/// Lookup by lowercased ident, generated data first so `contested` above is applied last.
static let byName: [String: CurrencyDef] = {
var defs: [String: CurrencyDef] = [:]
var table: [String: CurrencyDef] = [:]
defs.reserveCapacity(CurrencyData.all.count)
table.reserveCapacity(CurrencyData.all.count + CurrencyData.aliases.count)
defs.reserveCapacity(CurrencyData.all.count + crypto.count)
table.reserveCapacity(CurrencyData.all.count + CurrencyData.aliases.count + crypto.count)
for entry in CurrencyData.all {
let def = CurrencyDef(code: entry.code, name: entry.name)
defs[entry.code] = def
table[entry.code.lowercased()] = def
}
for (word, code) in CurrencyData.aliases { table[word] = defs[code] }
// After the generated nouns, so a ticker beats one: `sol` is Solana, `soles` stays PEN.
for entry in crypto {
let def = CurrencyDef(code: entry.code, name: entry.name)
defs[entry.code] = def
table[entry.code.lowercased()] = def
for word in entry.aliases { table[word] = def }
}
for (code, words) in contested {
guard let def = defs[code] else { continue }
for word in words { table[word] = def }
@@ -32,14 +32,17 @@ struct CalcResult: Equatable, Sendable {
/// Raw query to answer, or nil when it isn't calculator input. See docs/features/calculator.md.
enum CalcEngine {
/// Live clock. `currency` defaults to `.off`, so forgetting to pass one disables the feature.
static func evaluate(_ raw: String, currency: CurrencySource = .off) -> CalcResult? {
evaluate(raw, now: Date(), calendar: .current, currency: currency)
/// Live clock. `rates` is nil until a snapshot lands, which the currency paths report as such.
static func evaluate(
_ raw: String, rates: CurrencyRates? = nil, region: String? = nil
) -> CalcResult? {
evaluate(raw, now: Date(), calendar: .current, rates: rates, region: region)
}
/// `now`/`calendar` are injected so the date/time paths are deterministic under the harness.
/// `now`/`calendar`/`region` are injected so every path is deterministic under the harness.
static func evaluate(
_ raw: String, now: Date, calendar: Calendar, currency: CurrencySource = .off
_ raw: String, now: Date, calendar: Calendar, rates: CurrencyRates? = nil,
region: String? = nil
) -> CalcResult? {
let query = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty, query.count <= 256 else { return nil }
@@ -50,7 +53,7 @@ enum CalcEngine {
guard let tokens = CalcTokenizer.tokenize(query), !tokens.isEmpty else { return nil }
if let partial = partialResult(
tokens, query: query, now: now, calendar: calendar, currency: currency)
tokens, query: query, now: now, calendar: calendar, rates: rates, region: region)
{
return partial
}
@@ -99,12 +102,12 @@ enum CalcEngine {
}
// Typed arithmetic first, so aliases such as `pounds` keep winning in multi-term exprs.
if let quantity = CalcQuantity.evaluate(tokens, query: query, currency: currency) {
if let quantity = CalcQuantity.evaluate(tokens, query: query, rates: rates, region: region) {
return quantity
}
// After units, so `10 pounds to kg` stays weight. nil outright without consent.
if let conversion = CalcCurrency.parseConversion(tokens, source: currency) {
// After units, so `10 pounds to kg` stays weight.
if let conversion = CalcCurrency.parseConversion(tokens, rates: rates) {
switch conversion {
case .value(let input, let from, let to, let output):
let amount = CalcFormatter.currency(output)
@@ -170,7 +173,7 @@ enum CalcEngine {
/// A trailing operator keeps the last complete prefix on the card while the user still types.
private static func partialResult(
_ tokens: [CalcToken], query: String, now: Date, calendar: Calendar,
currency: CurrencySource
rates: CurrencyRates?, region: String?
) -> CalcResult? {
guard let trailing = tokens.last, let operatorText = partialOperatorText(trailing) else {
return nil
@@ -179,7 +182,7 @@ enum CalcEngine {
guard !prefixTokens.isEmpty else { return nil }
if let quantity = CalcQuantity.evaluate(
prefixTokens, query: tokenQuery(prefixTokens), currency: currency,
prefixTokens, query: tokenQuery(prefixTokens), rates: rates, region: region,
preserveStandaloneUnit: true)
{
return replacingExpression(
@@ -188,7 +191,7 @@ enum CalcEngine {
// A conversion's echo drops its target, so echo the typed text; the badges name both.
if let complete = evaluate(
tokenQuery(prefixTokens), now: now, calendar: calendar, currency: currency)
tokenQuery(prefixTokens), now: now, calendar: calendar, rates: rates, region: region)
{
return replacingExpression(complete, with: prettyExpression(query))
}
@@ -1,9 +1,9 @@
import Foundation
/// Typed arithmetic for measurements and consent-gated currencies.
/// Typed arithmetic for measurements and currencies.
enum CalcQuantity {
static func evaluate(
_ tokens: [CalcToken], query: String, currency: CurrencySource,
_ tokens: [CalcToken], query: String, rates: CurrencyRates?, region: String? = nil,
preserveStandaloneUnit: Bool = false
) -> CalcResult? {
let split = splitConversion(tokens)
@@ -11,7 +11,7 @@ enum CalcQuantity {
return nil
}
var parser = QuantityParser(tokens: split.expressionTokens, currency: currency)
var parser = QuantityParser(tokens: split.expressionTokens, rates: rates)
guard let value = parser.parse() else {
guard let message = parser.issue else { return nil }
return CalcResult(expression: query, payload: .error(message: message))
@@ -19,27 +19,23 @@ enum CalcQuantity {
guard parser.dimensionCount > 0 else { return nil }
if parser.usedCurrency {
switch currency {
case .off:
return nil
case .on(nil):
guard let rates else {
return CalcResult(
expression: query,
payload: .error(
message: "Exchange rates unavailable — check your connection."))
case .on(let rates?):
if let code = parser.currencyCodes.first(where: { rates.rate(for: $0) == nil }) {
return CalcResult(
expression: query,
payload: .error(message: "No exchange rate for \(code)."))
}
}
if let code = parser.currencyCodes.first(where: { rates.rate(for: $0) == nil }) {
return CalcResult(
expression: query,
payload: .error(message: "No exchange rate for \(code)."))
}
}
if let targetName = split.targetName {
return convertedResult(
value, targetName: targetName, expressionTokens: split.expressionTokens,
query: query, currency: currency)
query: query, rates: rates)
}
switch value.kind {
@@ -63,18 +59,34 @@ enum CalcQuantity {
return measurementResult(
value.amount, unit: unit, expression: expressionText(split.expressionTokens))
case .currency(let definition):
guard parser.operationCount == 0 else {
return currencyResult(
value.amount, definition: definition,
expression: expressionText(split.expressionTokens))
}
let expression = "\(CalcFormatter.display(value.amount)) \(definition.code)"
// A bare amount names no target, so the Mac's own currency becomes one once it's typed.
guard !preserveStandaloneUnit, let target = regionTarget(region, from: definition),
let output = rates?.convert(value.amount, from: definition.code, to: target.code)
else {
return currencyResult(value.amount, definition: definition, expression: expression)
}
return currencyResult(
value.amount, definition: definition,
expression:
parser.operationCount == 0
? "\(CalcFormatter.display(value.amount)) \(definition.code)"
: expressionText(split.expressionTokens))
output, definition: target, expression: expression, sourceBadge: definition.name)
}
}
/// Nil where converting says nothing: no region, an unknown one, or the currency already typed.
private static func regionTarget(_ region: String?, from: CurrencyDef) -> CurrencyDef? {
guard let target = region.flatMap({ CalcCurrency.byName[$0.lowercased()] }),
target.code != from.code
else { return nil }
return target
}
private static func convertedResult(
_ value: QuantityValue, targetName: String, expressionTokens: [CalcToken],
query: String, currency: CurrencySource
query: String, rates: CurrencyRates?
) -> CalcResult? {
let expression = expressionText(expressionTokens)
switch value.kind {
@@ -90,13 +102,13 @@ enum CalcQuantity {
guard output.isFinite else { return nil }
return measurementResult(output, unit: to, expression: expression)
}
if case .on = currency, CalcCurrency.byName[targetName] != nil {
if CalcCurrency.byName[targetName] != nil {
return conversionError(
query, from: from.category.displayName, to: CalcCurrency.categoryName)
}
return nil
case .currency(let from):
if case .on(let rates) = currency, let to = CalcCurrency.byName[targetName] {
if let to = CalcCurrency.byName[targetName] {
guard let rates else {
return CalcResult(
expression: query,
@@ -137,12 +149,13 @@ enum CalcQuantity {
}
private static func currencyResult(
_ amount: Double, definition: CurrencyDef, expression: String
_ amount: Double, definition: CurrencyDef, expression: String,
sourceBadge: String = "Expression"
) -> CalcResult {
let formatted = CalcFormatter.currency(amount)
return CalcResult(
expression: expression,
sourceBadge: "Expression", targetBadge: definition.name,
sourceBadge: sourceBadge, targetBadge: definition.name,
payload: .value(
display: "\(CalcFormatter.grouped(formatted)) \(definition.code)",
copyText: "\(formatted) \(definition.code)"))
@@ -288,7 +301,7 @@ private struct QuantityValue {
private struct QuantityParser {
let tokens: [CalcToken]
let currency: CurrencySource
let rates: CurrencyRates?
var position = 0
var operationCount = 0
var dimensionCount = 0
@@ -302,11 +315,6 @@ private struct QuantityParser {
position < tokens.count ? tokens[position] : nil
}
private var currencyEnabled: Bool {
if case .on = currency { return true }
return false
}
mutating func parse() -> QuantityValue? {
guard let value = parseExpression(minBindingPower: 0), position == tokens.count,
value.effective.isFinite
@@ -547,7 +555,7 @@ private struct QuantityParser {
position += 1
return value
case .ident(let name):
guard currencyEnabled, CalcUnits.byName[name] == nil,
guard CalcUnits.byName[name] == nil,
let definition = CalcCurrency.byName[name],
let amount = number(at: position + 1)
else { return nil }
@@ -564,7 +572,7 @@ private struct QuantityParser {
if let unit = CalcUnits.byName[name] {
return .unit(unit)
}
guard currencyEnabled, let definition = CalcCurrency.byName[name] else { return nil }
guard let definition = CalcCurrency.byName[name] else { return nil }
recordCurrency(definition.code)
return .currency(definition)
}
@@ -574,7 +582,6 @@ private struct QuantityParser {
) -> Double? {
recordCurrency(from.code)
recordCurrency(to.code)
guard case .on(let rates) = currency else { return nil }
guard let rates else {
issue = "Exchange rates unavailable — check your connection."
return nil
@@ -605,8 +612,7 @@ private struct QuantityParser {
case .number, .compactNumber, .intLiteral:
return true
case .ident(let name):
return currencyEnabled && CalcUnits.byName[name] == nil
&& CalcCurrency.byName[name] != nil
return CalcUnits.byName[name] == nil && CalcCurrency.byName[name] != nil
&& number(at: position + 1) != nil
default:
return false
@@ -1,14 +1,14 @@
// Generated by Scripts/gen-currencies.js — do not edit by hand.
// Codes from https://api.frankfurter.dev/v2/currencies (live as of 2026-08-07); names, signs and nouns from Unicode CLDR (en).
// Ambiguous signs and nouns are deliberately absent — CalcCurrency.swift decides those.
// Codes from the fiat rate feed (as of 2026-08-13), minus those Unicode CLDR records as retired;
// names, signs and nouns from CLDR (en). Ambiguous signs and nouns are deliberately absent,
// and so is crypto — CalcCurrency.swift decides both.
enum CurrencyData {
/// Every currency the rate feed still publishes, as (ISO 4217 code, display name).
/// Every fiat currency the rate feed prices, as (ISO 4217 code, display name).
static let all: [(code: String, name: String)] = [
("AED", "UAE Dirham"),
("AFN", "Afghan Afghani"),
("ALL", "Albanian Lek"),
("AMD", "Armenian Dram"),
("ANG", "Netherlands Antillean Guilder"),
("AOA", "Angolan Kwanza"),
("ARS", "Argentine Peso"),
("AUD", "Australian Dollar"),
@@ -31,6 +31,7 @@ enum CurrencyData {
("CAD", "Canadian Dollar"),
("CDF", "Congolese Franc"),
("CHF", "Swiss Franc"),
("CLF", "Chilean Unit of Account (UF)"),
("CLP", "Chilean Peso"),
("CNH", "Chinese Yuan (offshore)"),
("CNY", "Chinese Yuan"),
@@ -95,7 +96,6 @@ enum CurrencyData {
("MMK", "Myanmar Kyat"),
("MNT", "Mongolian Tugrik"),
("MOP", "Macanese Pataca"),
("MRO", "Mauritanian Ouguiya (1973–2017)"),
("MRU", "Mauritanian Ouguiya"),
("MUR", "Mauritian Rupee"),
("MVR", "Maldivian Rufiyaa"),
@@ -132,9 +132,7 @@ enum CurrencyData {
("SLE", "Sierra Leonean Leone"),
("SOS", "Somali Shilling"),
("SRD", "Surinamese Dollar"),
("SSP", "South Sudanese Pound"),
("STN", "São Tomé & Príncipe Dobra"),
("SVC", "Salvadoran Colón"),
("SYP", "Syrian Pound"),
("SZL", "Swazi Lilangeni"),
("THB", "Thai Baht"),
@@ -162,13 +160,10 @@ enum CurrencyData {
("XCG", "Caribbean guilder"),
("XDR", "Special Drawing Rights"),
("XOF", "West African CFA Franc"),
("XPD", "Palladium"),
("XPF", "CFP Franc"),
("XPT", "Platinum"),
("YER", "Yemeni Rial"),
("ZAR", "South African Rand"),
("ZMW", "Zambian Kwacha"),
("ZWG", "Zimbabwean Gold"),
]
/// Currency sign → ISO code, lowercased to match the tokenizer's ident form. Only signs CLDR
@@ -222,7 +217,8 @@ enum CurrencyData {
"bolivianos": "BOB",
"cedi": "GHS",
"cedis": "GHS",
"colones": "SVC",
"colon": "CRC",
"colón": "CRC",
"colons": "CRC",
"colóns": "CRC",
"cordoba": "NIO",
@@ -246,10 +242,13 @@ enum CurrencyData {
"florin": "AWG",
"forint": "HUF",
"forints": "HUF",
"gold": "XAU",
"gourde": "HTG",
"gourdes": "HTG",
"guarani": "PYG",
"guaranis": "PYG",
"guilder": "XCG",
"guilders": "XCG",
"hryvnia": "UAH",
"hryvnias": "UAH",
"kina": "PGK",
@@ -293,10 +292,8 @@ enum CurrencyData {
"ouguiyas": "MRU",
"paanga": "TOP",
"paʻanga": "TOP",
"palladium": "XPD",
"pataca": "MOP",
"patacas": "MOP",
"platinum": "XPT",
"pula": "BWP",
"pulas": "BWP",
"quetzal": "GTQ",
@@ -0,0 +1,58 @@
import Foundation
/// Decodes the rate feeds into one snapshot. Pure, so the harness covers it; the store does the IO.
enum CurrencyFeed {
/// Fiat quotes are keyed `<base><code>` and omit the base's own row.
private struct FiatPayload: Decodable {
let success: Bool
let source: String
let quotes: [String: Double]
}
/// Crypto is quoted the other way round — one unit costs this much of `target`.
private struct CryptoPayload: Decodable {
let success: Bool
let target: String
let rates: [String: Double]
}
/// One units-per-base table from both feeds; `complete` is false when the coins didn't land.
static func snapshot(
fiat: Data, crypto: Data?, now: Date
) throws -> (rates: CurrencyRates, complete: Bool) {
let payload = try JSONDecoder().decode(FiatPayload.self, from: fiat)
let base = payload.source
guard payload.success, base.count == 3 else { throw URLError(.cannotParseResponse) }
var rates: [String: Double] = [:]
rates.reserveCapacity(payload.quotes.count + 1)
for (pair, rate) in payload.quotes where usable(rate) {
guard pair.count == 6, pair.hasPrefix(base) else { continue }
rates[String(pair.dropFirst(3))] = rate
}
guard !rates.isEmpty else { throw URLError(.cannotParseResponse) }
rates[base] = 1
var coins = 0
// Last, so the coin feed's own price wins for a symbol the fiat table also quotes.
if let crypto, let payload = try? JSONDecoder().decode(CryptoPayload.self, from: crypto),
payload.success, payload.target == base
{
for (code, price) in payload.rates where usable(price) && usable(1 / price) {
rates[code] = 1 / price
coins += 1
}
}
return (CurrencyRates(base: base, rates: rates, fetchedAt: now), coins > 0)
}
/// Only whole snapshots are persisted, so a cached one pricing no coin predates them entirely.
static func pricesCoins(_ snapshot: CurrencyRates) -> Bool {
CalcCurrency.cryptoCodes.contains { snapshot.rates[$0] != nil }
}
private static func usable(_ rate: Double) -> Bool {
rate > 0 && rate.isFinite
}
}
@@ -1,52 +1,45 @@
import Foundation
/// The consented, cacheless exchange-rate fetcher. See docs/features/calculator.md#consent.
/// The cacheless exchange-rate fetcher. See docs/features/calculator.md#exchange-rates.
@MainActor
@Observable
final class CurrencyRateStore {
/// Frankfurter: no key, no quota, and the same feed `CurrencyData.generated.swift` comes from.
static let provider = "Frankfurter"
static let providerURL = URL(string: "https://frankfurter.dev")!
private nonisolated static let endpoint = URL(
string: "https://api.frankfurter.dev/v2/rates?base=USD")!
/// Daily, measured from the persisted snapshot, so relaunching never re-fetches a fresh one.
static let refreshInterval: TimeInterval = 24 * 3600
private nonisolated static let fiatEndpoint = URL(
string: "https://backend.raycast.com/api/v1/currencies")!
/// Asked for by the app's own crypto table, so the request and the table cannot drift apart.
private nonisolated static let cryptoEndpoint = URL(
string: "https://backend.raycast.com/api/v1/currencies/crypto?symbols="
+ CalcCurrency.cryptoCodes.joined(separator: ","))!
/// Daily, measured from `completedAt`, so relaunching never re-fetches a snapshot still fresh.
private static let refreshInterval: TimeInterval = 24 * 3600
/// Shorter retry, so a machine offline at launch picks rates up soon after it reconnects.
private static let retryInterval: TimeInterval = 15 * 60
private static let retryInterval: TimeInterval = 30 * 60
/// Consent. Deliberately not in `AppSettings`, so no import can grant network access.
private(set) var isEnabled: Bool
/// The newest snapshot, nil when none has landed and always nil without consent.
/// The newest snapshot, nil until the first one lands.
private(set) var rates: CurrencyRates?
private static let consentKey = "currencyRatesEnabled"
private let defaults = UserDefaults.standard
private let fileURL: URL
@ObservationIgnored private var pump: Task<Void, Never>?
/// Drives the schedule: a partial snapshot answers, but only a whole one resets the clock.
@ObservationIgnored private var completedAt: Date?
init() {
// Absent reads as false, which is the only safe default for a network feature.
isEnabled = defaults.bool(forKey: Self.consentKey)
fileURL = AppPaths.caches().appendingPathComponent("currency-rates.json")
// Guard 1 — a disabled feature doesn't even read back a snapshot left on disk.
guard isEnabled, let data = try? Data(contentsOf: fileURL) else { return }
rates = try? JSONDecoder().decode(CurrencyRates.self, from: data)
guard let data = try? Data(contentsOf: fileURL),
let cached = try? JSONDecoder().decode(CurrencyRates.self, from: data)
else { return }
rates = cached
// A cache from before coins existed still prices fiat, but has to be replaced at once.
if CurrencyFeed.pricesCoins(cached) { completedAt = cached.fetchedAt }
}
/// Guard 2, the read path: without consent the engine gets `.off`, so there is no card.
var source: CurrencySource { isEnabled ? .on(rates) : .off }
/// Guard 3 — no consent, no loop, so `AppCore.start()` can call this unconditionally.
func start() {
guard isEnabled else { return }
// Replace rather than bail: an exited loop leaves a non-nil task that would block restart.
pump?.cancel()
pump = Task { [weak self] in
while !Task.isCancelled, let self, self.isEnabled {
while !Task.isCancelled, let self {
// Clamped, so a future-stamped snapshot can't park the loop past one interval.
let age = max(0, self.rates.map { Date().timeIntervalSince($0.fetchedAt) } ?? .infinity)
let age = max(0, self.completedAt.map { Date().timeIntervalSince($0) } ?? .infinity)
guard age >= Self.refreshInterval else {
try? await Task.sleep(for: .seconds(Self.refreshInterval - age))
continue
@@ -57,40 +50,17 @@ final class CurrencyRateStore {
}
}
/// The toggle's only entry point; disabling also drops the snapshot and deletes the cache.
func setEnabled(_ enabled: Bool) {
guard enabled != isEnabled else { return }
isEnabled = enabled
defaults.set(enabled, forKey: Self.consentKey)
if enabled {
start()
} else {
pump?.cancel()
pump = nil
rates = nil
try? FileManager.default.removeItem(at: fileURL)
}
}
/// "Update Now". Returns whether a table landed, so the pane can report a failed fetch.
func refreshNow() async -> Bool {
guard isEnabled else { return false }
return await fetchAndStore()
}
private func fetchAndStore() async -> Bool {
// Guard 4, the network boundary: the pump may have slept through a revocation.
guard isEnabled, let fetched = try? await Self.fetch() else { return false }
// Re-check after the await: consent can be withdrawn while the request is in flight.
guard isEnabled else { return false }
rates = fetched
if let data = try? JSONEncoder().encode(fetched) {
try? data.write(to: fileURL, options: .atomic)
}
guard let result = await Self.fetch() else { return false }
rates = result.rates
// Never persist a coin-less run: `pricesCoins` reads the cache as whole by definition.
guard result.complete, let data = try? JSONEncoder().encode(result.rates) else { return false }
completedAt = result.rates.fetchedAt
try? data.write(to: fileURL, options: .atomic)
return true
}
/// Cacheless, never `URLSession.shared`, so revoking consent leaves no second on-disk copy.
/// Cacheless, never `URLSession.shared`, so the snapshot on disk stays the only copy.
private nonisolated static let session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
@@ -98,30 +68,19 @@ final class CurrencyRateStore {
}()
/// Off-main via `URLSession`; only the plain-value `CurrencyRates` crosses back.
private nonisolated static func fetch() async throws -> CurrencyRates {
let request = URLRequest(url: endpoint, timeoutInterval: 20)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
// Frankfurter v2 answers with one flat row per pair rather than a keyed table.
let rows = try JSONDecoder().decode([RateRow].self, from: data)
guard let base = rows.first?.base else { throw URLError(.cannotParseResponse) }
var rates: [String: Double] = [:]
rates.reserveCapacity(rows.count + 1)
for row in rows where row.rate > 0 && row.rate.isFinite && row.base == base {
rates[row.quote] = row.rate
}
guard !rates.isEmpty else { throw URLError(.cannotParseResponse) }
rates[base] = 1
return CurrencyRates(base: base, rates: rates, fetchedAt: Date())
private nonisolated static func fetch() async -> (rates: CurrencyRates, complete: Bool)? {
async let fiat = body(of: fiatEndpoint)
async let crypto = body(of: cryptoEndpoint)
let (fiatData, cryptoData) = await (fiat, crypto)
guard let fiatData else { return nil }
return try? CurrencyFeed.snapshot(fiat: fiatData, crypto: cryptoData, now: Date())
}
private struct RateRow: Decodable {
let base: String
let quote: String
let rate: Double
private nonisolated static func body(of url: URL) async -> Data? {
let request = URLRequest(url: url, timeoutInterval: 20)
guard let (data, response) = try? await session.data(for: request),
let http = response as? HTTPURLResponse, http.statusCode == 200
else { return nil }
return data
}
}
@@ -1,120 +0,0 @@
import SwiftUI
/// The catch-all pane, home to currency conversion. See docs/features/calculator.md#consent.
struct MiscellaneousSettingsView: View {
@Environment(AppCore.self) private var core
private var currencyRates: CurrencyRateStore { core.currencyRates }
@State private var askingConsent = false
@State private var refreshing = false
@State private var refreshFailed = false
var body: some View {
Form {
Section {
// Not bound to the setting: flipping on opens the sheet, so it springs back.
Toggle(
isOn: Binding(
get: { currencyRates.isEnabled },
set: { wantsOn in
if wantsOn {
askingConsent = true
} else {
currencyRates.setEnabled(false)
}
})
) {
Text("Currency Conversion")
Text(conversionStatus)
}
if currencyRates.isEnabled {
LabeledContent {
Button("Update Now") {
refreshing = true
Task {
let landed = await currencyRates.refreshNow()
refreshFailed = !landed
refreshing = false
}
}
.disabled(refreshing)
} label: {
Text("Exchange Rates")
Text(ratesStatus)
}
}
} header: {
Text("Calculator")
}
}
.formStyle(.grouped)
.sheet(isPresented: $askingConsent) {
CurrencyConsentSheet(
onCancel: { askingConsent = false },
onAccept: {
askingConsent = false
currencyRates.setEnabled(true)
})
}
}
/// Carries the off-state promise: nothing is contacted until the switch is on.
private var conversionStatus: String {
let examples = "Convert inline — \"100 dollars to yen\", \"€20 to GBP\"."
return currencyRates.isEnabled ? examples : "\(examples) Off — no service is contacted."
}
private var ratesStatus: String {
if refreshing { return "Updating…" }
if refreshFailed { return "Couldn't reach \(CurrencyRateStore.provider). Try again." }
guard let fetched = currencyRates.rates?.fetchedAt else {
return "\(CurrencyRateStore.provider) · not downloaded yet."
}
let stamp = fetched.formatted(date: .abbreviated, time: .shortened)
return "\(CurrencyRateStore.provider) · updated \(stamp). Refreshes daily."
}
}
/// The consent step: who is contacted, how often, what leaves, and a checkable provider link.
private struct CurrencyConsentSheet: View {
let onCancel: () -> Void
let onAccept: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xl) {
HStack(spacing: Theme.Spacing.lg) {
Image(systemName: "network")
.font(.system(size: 22, weight: .medium))
.foregroundStyle(.green)
Text("Turn on currency conversion?")
.font(.headline)
}
Text(
"Tinycast downloads exchange rates from \(CurrencyRateStore.provider) once a day and "
+ "keeps a copy on your Mac. No account, no identifiers, nothing you type. "
+ "Turning it off deletes the cached rates."
)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: Theme.Spacing.lg) {
Link(destination: CurrencyRateStore.providerURL) {
HStack(spacing: Theme.Spacing.xs) {
Text(CurrencyRateStore.providerURL.host() ?? "Provider")
Image(systemName: "arrow.up.right.square")
}
.font(.callout)
}
Spacer()
Button("Not Now", action: onCancel)
.keyboardShortcut(.cancelAction)
Button("Enable", action: onAccept)
.keyboardShortcut(.defaultAction)
}
}
.padding(Theme.Spacing.xxl)
.frame(width: 420)
}
}
@@ -1,33 +1,24 @@
import SwiftUI
/// One-deep memo over `CalcEngine.evaluate`, keyed on consent plus the snapshot's `fetchedAt`.
/// One-deep memo over `CalcEngine.evaluate`, keyed on the rate snapshot's `fetchedAt`.
@MainActor
enum CalcMemo {
private struct Cache {
let query: String
let enabled: Bool
let stamp: Date?
let region: String?
let result: CalcResult?
}
private static var cache: Cache?
static func evaluate(_ query: String, currency: CurrencySource) -> CalcResult? {
let enabled: Bool
let stamp: Date?
switch currency {
case .off:
enabled = false
stamp = nil
case .on(let rates):
enabled = true
stamp = rates?.fetchedAt
}
if let cache, cache.query == query, cache.enabled == enabled, cache.stamp == stamp {
static func evaluate(_ query: String, rates: CurrencyRates?) -> CalcResult? {
let region = RegionCurrency.code
if let cache, cache.query == query, cache.stamp == rates?.fetchedAt, cache.region == region {
return cache.result
}
let result = CalcEngine.evaluate(query, currency: currency)
cache = Cache(query: query, enabled: enabled, stamp: stamp, result: result)
let result = CalcEngine.evaluate(query, rates: rates, region: region)
cache = Cache(query: query, stamp: rates?.fetchedAt, region: region, result: result)
return result
}
}
@@ -21,7 +21,7 @@ struct CalculatorHistoryScreen: PaletteScreen {
}
}
private var calc: CalcResult? { CalcMemo.evaluate(vm.query, currency: currencyRates.source) }
private var calc: CalcResult? { CalcMemo.evaluate(vm.query, rates: currencyRates.rates) }
private var entries: [CalcHistoryEntry] { history.search(vm.query) }
var rows: [Row] {
@@ -0,0 +1,139 @@
import Foundation
/// The clipboard list's type filter. See docs/features/clipboard.md#type-filter.
enum ClipboardFilter: CaseIterable, Sendable {
case all
case text
case image
case link
case email
var title: String {
switch self {
case .all: return "All Types"
case .text: return "Text Only"
case .image: return "Images Only"
case .link: return "Links Only"
case .email: return "Emails Only"
}
}
/// Also the header button's glyph, so it states the active filter without opening the menu.
var systemImage: String {
switch self {
case .all: return "list.bullet"
case .text: return "textformat"
case .image: return "photo"
case .link: return "link"
case .email: return "at"
}
}
/// What an empty list says, so a filter hiding every entry explains itself.
var emptyMessage: String {
switch self {
case .all: return "Clipboard history is empty"
case .text: return "No text in clipboard history"
case .image: return "No images in clipboard history"
case .link: return "No links in clipboard history"
case .email: return "No email addresses in clipboard history"
}
}
/// The five are exclusive: a copied URL is a link rather than a narrower kind of text.
func matches(_ item: ClipboardItem) -> Bool {
switch self {
case .all: return true
case .image: return item.kind == .image
case .text: return item.textForm == .plain
case .link: return item.textForm == .link
case .email: return item.textForm == .email
}
}
/// Identity for `all`, so an unfiltered list never pays for a copy.
func apply(to items: [ClipboardItem]) -> [ClipboardItem] {
self == .all ? items : items.filter(matches)
}
}
extension ClipboardItem {
/// What a text entry holds; `Kind` says how an entry is stored, this says what it is.
enum TextForm: Sendable {
case plain
case link
case email
}
/// Derived and never persisted, so reclassifying is a code change rather than a migration.
var textForm: TextForm? {
guard kind == .text, let text else { return nil }
return Self.classify(text)
}
/// A link or an address is one short token, so anything longer is prose by definition.
private static let detectionLimit = 2048
/// A scheme-less link needs a TLD people actually copy, or every `report.pdf` reads as one.
private static let commonTopLevelDomains: Set<String> = [
"com", "org", "net", "edu", "gov", "io", "co", "ai", "app", "dev", "me", "info", "biz",
"xyz", "tv", "ly", "gg", "to", "uk", "us", "eu", "de", "fr", "es", "it", "nl", "se", "no",
"fi", "dk", "ch", "at", "be", "ie", "cz", "ru", "ua", "tr", "cn", "jp", "kr", "hk", "sg",
"au", "nz", "ca", "mx", "br", "ar", "za"
]
private static func classify(_ text: String) -> TextForm {
// `utf8.count` is O(1); `count` would walk graphemes across a multi-MB copy.
guard text.utf8.count <= detectionLimit else { return .plain }
let token = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty, !token.contains(where: \.isWhitespace) else { return .plain }
if let form = schemeForm(of: token) { return form }
if isAddress(token) { return .email }
return isBareDomain(token) ? .link : .plain
}
/// `mailto:` is an address; any other `scheme://` is a link, so `vscode://` needs no allowlist.
private static func schemeForm(of token: String) -> TextForm? {
if token.range(of: "mailto:", options: [.caseInsensitive, .anchored]) != nil {
return .email
}
guard let separator = token.range(of: "://") else { return nil }
let scheme = token[token.startIndex..<separator.lowerBound]
guard !scheme.isEmpty,
scheme.allSatisfy({ $0.isASCII && ($0.isLetter || $0 == "+" || $0 == "-" || $0 == ".") })
else { return nil }
return .link
}
/// `local@domain.tld`: one `@`, a non-empty local part, and a host-shaped domain.
private static func isAddress(_ token: String) -> Bool {
let parts = token.split(separator: "@", omittingEmptySubsequences: false)
guard parts.count == 2, !parts[0].isEmpty else { return false }
return topLevelLabel(of: parts[1]) != nil
}
/// A heuristic, and deliberately so: its worst case files a row under the wrong type.
private static func isBareDomain(_ token: String) -> Bool {
let host = token.prefix { !"/?#:".contains($0) }
if host.range(of: "www.", options: [.caseInsensitive, .anchored]) != nil { return true }
// Hosts are canonically lower case, which is what keeps `Safari.app` out of the links.
guard !host.contains(where: \.isUppercase), let tld = topLevelLabel(of: host) else {
return false
}
return commonTopLevelDomains.contains(String(tld))
}
/// The last label of a host, or nil unless every label is one — which is what rejects
/// an address-shaped token like `@apple.com` before it can read as a bare domain.
private static func topLevelLabel(of host: Substring) -> Substring? {
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
guard labels.count >= 2, labels.allSatisfy(isHostLabel), let tld = labels.last,
tld.count >= 2, tld.allSatisfy({ $0.isASCII && $0.isLetter })
else { return nil }
return tld
}
private static func isHostLabel(_ label: Substring) -> Bool {
!label.isEmpty && label.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }
}
}
@@ -102,7 +102,8 @@ final class ClipboardStore {
var maxAge: TimeInterval = ClipboardRetention.threeMonths.maxAge
/// One-entry memo so repeated renders reuse the FTS result; cleared when `items` changes.
@ObservationIgnored private var searchCache: (query: String, result: [ClipboardItem])?
@ObservationIgnored private var searchCache:
(query: String, filter: ClipboardFilter, result: [ClipboardItem])?
/// Same memo for the empty query, so the pinned split runs once per mutation.
@ObservationIgnored private var orderedCache: [ClipboardItem]?
@@ -279,20 +280,28 @@ final class ClipboardStore {
return URL(fileURLWithPath: path)
}
/// Display order for `query`: pinned entries first, each block newest-first.
func search(_ query: String) -> [ClipboardItem] {
/// Display order for `query` under `filter`: pinned entries first, each block newest-first.
func search(_ query: String, filter: ClipboardFilter) -> [ClipboardItem] {
let q = query.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return orderedItems }
if let searchCache, searchCache.query == q { return searchCache.result }
// Pins are matched in memory: all resident, and the LIMIT would otherwise drop one.
let result = pinnedItems.filter { $0.matches(q) } + runSearch(q).filter { !$0.isPinned }
searchCache = (q, result)
// The filter joins the key: `rows` rebuilds per render, so a query-only memo goes stale.
if let searchCache, searchCache.query == q, searchCache.filter == filter {
return searchCache.result
}
// Filtering after the split leaves a matching pin in the Pinned section, in pin order.
let result = filter.apply(to: unfiltered(q))
searchCache = (q, filter, result)
return result
}
/// Row index of `item` for `query`, so the palette can follow a row that moved.
func rowIndex(of item: ClipboardItem, in query: String) -> Int? {
search(query).firstIndex { $0.id == item.id }
/// Row index of `item` as currently listed, so the palette can follow a row that moved.
func rowIndex(of item: ClipboardItem, in query: String, filter: ClipboardFilter) -> Int? {
search(query, filter: filter).firstIndex { $0.id == item.id }
}
private func unfiltered(_ q: String) -> [ClipboardItem] {
guard !q.isEmpty else { return orderedItems }
// Pins are matched in memory: all resident, and the LIMIT would otherwise drop one.
return pinnedItems.filter { $0.matches(q) } + runSearch(q).filter { !$0.isPinned }
}
private func runSearch(_ q: String) -> [ClipboardItem] {
@@ -78,6 +78,8 @@ final class ClipboardCoordinator {
/// Select `item`'s row as currently filtered; a moved row isn't always index 0.
private func selectClip(_ item: ClipboardItem) {
palette.selection = clipboardStore.rowIndex(of: item, in: palette.query) ?? 0
palette.selection =
clipboardStore.rowIndex(
of: item, in: palette.query, filter: palette.clipboardFilter) ?? 0
}
}
@@ -0,0 +1,26 @@
import SwiftUI
/// The clipboard header's type filter control: it states the active filter and toggles its menu.
struct ClipboardFilterButton: View {
let filter: ClipboardFilter
let isOpen: Bool
let action: () -> Void
var body: some View {
BarButton(chrome: .menu, action: action) {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: filter.systemImage)
.font(Theme.Typography.bar)
.symbolRenderingMode(.hierarchical)
Text(filter.title)
.font(Theme.Typography.bar)
.lineLimit(1)
// Points at the menu it opens, the way a native pop-up's chevron does.
Image(systemName: isOpen ? "chevron.up" : "chevron.down")
.font(Theme.Typography.disclosure)
}
.foregroundStyle(Theme.Colors.textSecondary)
}
.help("Filter by type ⌘P")
}
}
@@ -8,7 +8,7 @@ struct ClipboardScreen: PaletteScreen {
let openActions: () -> Void
let scrollToFollow: () -> Void
var rows: [ClipboardItem] { store.search(vm.query) }
var rows: [ClipboardItem] { store.search(vm.query, filter: vm.clipboardFilter) }
var primaryActionTitle: String { vm.pasteTarget?.pasteTitle ?? "Paste" }
@@ -42,7 +42,7 @@ struct ClipboardScreen: PaletteScreen {
return true
}
/// ⌘P — mirrors the Actions menu row; pinning lifts the row into the Pinned section.
/// ⌘. — mirrors the Actions menu row; pinning lifts the row into the Pinned section.
func pin(at selection: Int) -> Bool {
guard let item = item(at: selection) else { return false }
core.clipboardCoordinator.togglePinnedClip(item)
@@ -88,7 +88,8 @@ struct ClipboardScreen: PaletteScreen {
let rows = rows
// Empty history: centre one message across the panel, not in the list column.
if rows.isEmpty {
EmptyResults(text: "Clipboard history is empty")
// Names the filter, so one hiding every entry doesn't read as an empty history.
EmptyResults(text: vm.clipboardFilter.emptyMessage)
} else {
let selected = item(at: selection)
HStack(spacing: 0) {
@@ -144,12 +145,12 @@ enum ClipboardActionsMenu {
]
if item.isPinned {
items.append(
PopoverMenuItem(title: "Unpin Entry", systemImage: "pin.slash", shortcut: "⌘P") {
PopoverMenuItem(title: "Unpin Entry", systemImage: "pin.slash", shortcut: "⌘.") {
core.clipboardCoordinator.togglePinnedClip(item)
})
} else {
items.append(
PopoverMenuItem(title: "Pin Entry", systemImage: "pin", shortcut: "⌘P") {
PopoverMenuItem(title: "Pin Entry", systemImage: "pin", shortcut: "⌘.") {
core.clipboardCoordinator.togglePinnedClip(item)
})
}
@@ -0,0 +1,30 @@
import Foundation
/// A native-looking stand-in for whatever icon an extension shipped: an SF Symbol from a curated set,
/// on a tinted tile. Chosen in Settings › Extensions, applied to every command of that extension.
struct ExtensionAppearance: Codable, Equatable, Hashable, Sendable {
var symbol: String
var tint: ExtensionTint
static let fallback = ExtensionAppearance(symbol: "puzzlepiece.extension", tint: .purple)
}
/// The tile colours on offer — the same family the Settings sidebar uses, so an overridden extension
/// looks like it belongs rather than like a sticker.
enum ExtensionTint: String, CaseIterable, Identifiable, Codable, Sendable {
// Declaration order is swatch order: around the wheel, then the earths and neutrals.
case red, maroon, rose, pink, purple, indigo, blue, cyan, teal, mint
case green, lime, yellow, orange, tan, brown, gray, slate
var id: String { rawValue }
/// Shown as the swatch tooltip — "tan" alone doesn't say much.
var title: String {
switch self {
case .tan: return "Light Brown"
case .maroon: return "Maroon"
case .slate: return "Slate"
default: return rawValue.capitalized
}
}
}
@@ -0,0 +1,112 @@
import Foundation
/// Machine facts the Node shims answer from without a host call. Sent once, when the engine boots.
struct ExtensionBootConfig: Sendable {
var arch: String
var release: String
var hostname: String
var username: String
var shell: String
var homeDirectory: String
var temporaryDirectory: String
var workingDirectory: String
var cpuCount: Int
var totalMemory: Double
var environmentVariables: [String: String]
static func current(supportDirectory: URL) -> ExtensionBootConfig {
let info = ProcessInfo.processInfo
var arch = "arm64"
#if arch(x86_64)
arch = "x64"
#endif
// A GUI app inherits a bare environment; extensions shelling out expect a login-ish PATH.
var variables = info.environment
variables["PATH"] =
(variables["PATH"].map { $0 + ":" } ?? "")
+ "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
variables["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path
return ExtensionBootConfig(
arch: arch,
release: info.operatingSystemVersionString,
hostname: info.hostName,
username: NSUserName(),
shell: info.environment["SHELL"] ?? "/bin/zsh",
homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path,
temporaryDirectory: FileManager.default.temporaryDirectory.path,
workingDirectory: supportDirectory.path,
cpuCount: info.processorCount,
totalMemory: Double(info.physicalMemory),
environmentVariables: variables)
}
func jsonString() -> String {
ExtensionRuntime.jsonString(
from: [
"node": [
"arch": arch,
"release": release,
"hostname": hostname,
"username": username,
"shell": shell,
"homedir": homeDirectory,
"tmpdir": temporaryDirectory,
"cwd": workingDirectory,
"cpus": cpuCount,
"totalmem": totalMemory,
"env": environmentVariables,
"execPath": ""
]
])
}
}
/// Everything one command needs at mount time: its `environment`, resolved preferences, the cache
/// namespaces it may read synchronously, and its launch arguments.
struct ExtensionLaunchContext: Sendable {
var extensionName: String
var extensionTitle: String
var commandName: String
var commandMode: ExtensionCommandMode
var assetsPath: String
var supportPath: String
var preferences: [String: ExtensionPreferenceValue]
var caches: [String: [String: String]]
var arguments: [String: String]
var fallbackText: String?
func jsonString() -> String {
var environment: [String: Any] = [
"extensionName": extensionName,
"commandName": commandName,
"commandMode": commandMode.rawValue,
"assetsPath": assetsPath,
"supportPath": supportPath,
"isDevelopment": false,
// Extensions gate features on this; report the API level the shim implements.
"raycastVersion": ExtensionRuntimeVersion.raycastAPI,
"textSize": "medium",
"appearance": "dark",
"launchType": "userInitiated",
"canAccess": false
]
environment["ownerOrAuthorName"] = extensionTitle
var launchProps: [String: Any] = ["launchType": "userInitiated", "arguments": arguments]
if let fallbackText { launchProps["fallbackText"] = fallbackText }
return ExtensionRuntime.jsonString(
from: [
"environment": environment,
"preferences": preferences.mapValues(\.jsonValue),
"caches": caches,
"launchProps": launchProps
])
}
}
enum ExtensionRuntimeVersion {
/// The @raycast/api version the bundled shim tracks. Surfaced as `environment.raycastVersion`.
static let raycastAPI = "1.104.0"
}
@@ -0,0 +1,52 @@
/// Flat-index navigation over a `Grid`'s sections, so ↑/↓ move a whole row and keep their column.
struct ExtensionGridGeometry {
/// Selectable cells per section, in visible order. An empty section is never included.
let counts: [Int]
let columns: Int
/// Flat index each section begins at.
private let starts: [Int]
init(counts: [Int], columns: Int) {
self.counts = counts
self.columns = max(1, columns)
var starts: [Int] = []
var running = 0
for count in counts {
starts.append(running)
running += count
}
self.starts = starts
}
var cellCount: Int { counts.reduce(0, +) }
func down(from index: Int) -> Int {
guard let section = section(of: index) else { return index }
let local = index - starts[section]
let below = local + columns
if below < counts[section] { return starts[section] + below }
// A shorter last row keeps the selection in this section rather than skipping past it.
if local / columns < (counts[section] - 1) / columns {
return starts[section] + counts[section] - 1
}
guard section + 1 < counts.count else { return index }
return starts[section + 1] + min(local % columns, counts[section + 1] - 1)
}
func up(from index: Int) -> Int {
guard let section = section(of: index) else { return index }
let local = index - starts[section]
if local >= columns { return starts[section] + local - columns }
guard section > 0 else { return index }
let previous = counts[section - 1]
let lastRowStart = ((previous - 1) / columns) * columns
return starts[section - 1] + min(lastRowStart + local % columns, previous - 1)
}
private func section(of index: Int) -> Int? {
for (position, start) in starts.enumerated().reversed() where index >= start {
return index < start + counts[position] ? position : nil
}
return nil
}
}
@@ -0,0 +1,243 @@
import Foundation
/// What a command renders into. Only `view` and `noView` run today; the rest are recognised so the
/// launcher can label them and explain why they don't open.
enum ExtensionCommandMode: String, Sendable, Codable {
case view
case noView = "no-view"
case menuBar = "menu-bar"
/// The string the JS runtime expects (it only distinguishes mounted from headless).
var runtimeName: String { self == .view ? "view" : "no-view" }
var isSupported: Bool { self != .menuBar }
var unsupportedReason: String? {
self == .menuBar
? "Menu bar commands aren't supported yet — Tinycast only runs view and no-view commands."
: nil
}
}
/// One entry from a manifest's `preferences` array.
struct ExtensionPreferenceSchema: Sendable, Hashable {
enum Kind: String, Sendable {
case textfield
case password
case checkbox
case dropdown
case appPicker
case file
case directory
init(raw: String?) {
self = Kind(rawValue: raw ?? "textfield") ?? .textfield
}
}
struct Option: Sendable, Hashable {
let title: String
let value: String
}
let name: String
let title: String?
let label: String?
let description: String?
let placeholder: String?
let kind: Kind
let required: Bool
let options: [Option]
/// Already resolved for macOS: a manifest default can be platform-keyed
/// (`{"macOS": "/var/run/docker.sock", "Windows": "…"}`).
let defaultValue: ExtensionPreferenceValue?
/// The value to use when the user hasn't set one — falls back to a type-appropriate empty.
var effectiveDefault: ExtensionPreferenceValue {
defaultValue ?? (kind == .checkbox ? .bool(false) : .string(""))
}
var displayTitle: String { title ?? label ?? name }
init?(json: Any) {
guard let dict = json as? [String: Any], let name = dict["name"] as? String else { return nil }
self.name = name
title = dict["title"] as? String
label = dict["label"] as? String
description = dict["description"] as? String
placeholder = dict["placeholder"] as? String
kind = Kind(raw: dict["type"] as? String)
required = dict["required"] as? Bool ?? false
options = (dict["data"] as? [[String: Any]] ?? []).compactMap { entry in
guard let value = entry["value"] as? String else { return nil }
return Option(title: entry["title"] as? String ?? value, value: value)
}
defaultValue = ExtensionPreferenceValue(manifestDefault: dict["default"])
}
}
/// A preference value as it round-trips between the manifest, `UserDefaults` and JS.
enum ExtensionPreferenceValue: Sendable, Hashable {
case string(String)
case bool(Bool)
case number(Double)
var jsonValue: Any {
switch self {
case .string(let value): return value
case .bool(let value): return value
case .number(let value): return value
}
}
var stringValue: String {
switch self {
case .string(let value): return value
case .bool(let value): return value ? "true" : "false"
case .number(let value): return value == value.rounded() ? String(Int(value)) : String(value)
}
}
var boolValue: Bool {
switch self {
case .string(let value): return value == "true"
case .bool(let value): return value
case .number(let value): return value != 0
}
}
init?(manifestDefault raw: Any?) {
switch raw {
case let value as String:
self = .string(value)
case let value as NSNumber:
self =
CFGetTypeID(value) == CFBooleanGetTypeID()
? .bool(value.boolValue) : .number(value.doubleValue)
case let value as [String: Any]:
// Platform-keyed default; Tinycast is macOS-only.
guard let macOS = value["macOS"] else { return nil }
self.init(manifestDefault: macOS)
default:
return nil
}
}
}
/// One argument the launcher collects before a command starts.
struct ExtensionCommandArgument: Sendable, Hashable {
let name: String
let type: String
let placeholder: String
let required: Bool
init?(json: Any) {
guard let dict = json as? [String: Any], let name = dict["name"] as? String else { return nil }
self.name = name
type = dict["type"] as? String ?? "text"
placeholder = dict["placeholder"] as? String ?? name
required = dict["required"] as? Bool ?? false
}
}
struct ExtensionCommand: Sendable, Hashable, Identifiable {
let name: String
let title: String
let subtitle: String?
let description: String
let mode: ExtensionCommandMode
let keywords: [String]
let icon: String?
let disabledByDefault: Bool
let preferences: [ExtensionPreferenceSchema]
let arguments: [ExtensionCommandArgument]
/// Unique within its extension; `ExtensionCommandRef` pairs it with the owner for a global id.
var id: String { name }
/// Every declared argument must reach the command, empty string when the user left it blank — that
/// is Raycast's contract, and extensions lean on it: `Number(args.seconds)` is `0` for `""` but
/// `NaN` for `undefined`, which silently corrupts whatever they compute from it.
func completeArguments(_ given: [String: String]) -> [String: String] {
var complete = given
for argument in arguments where complete[argument.name] == nil {
complete[argument.name] = ""
}
return complete
}
init?(json: Any) {
guard let dict = json as? [String: Any], let name = dict["name"] as? String,
let title = dict["title"] as? String
else { return nil }
self.name = name
self.title = title
subtitle = dict["subtitle"] as? String
description = dict["description"] as? String ?? ""
mode = ExtensionCommandMode(rawValue: dict["mode"] as? String ?? "view") ?? .view
keywords = dict["keywords"] as? [String] ?? []
icon = dict["icon"] as? String
disabledByDefault = dict["disabledByDefault"] as? Bool ?? false
preferences = (dict["preferences"] as? [Any] ?? []).compactMap(ExtensionPreferenceSchema.init(json:))
arguments = (dict["arguments"] as? [Any] ?? []).compactMap(ExtensionCommandArgument.init(json:))
}
}
/// A Raycast extension's `package.json`, reduced to what Tinycast uses.
struct ExtensionManifest: Sendable, Hashable {
let name: String
let title: String
let description: String
let author: String
let icon: String?
let categories: [String]
let platforms: [String]?
let commands: [ExtensionCommand]
let preferences: [ExtensionPreferenceSchema]
/// `platforms` is absent on older manifests, which predate Windows support and are macOS-only.
var supportsMacOS: Bool {
guard let platforms else { return true }
return platforms.contains { $0.caseInsensitiveCompare("macOS") == .orderedSame }
}
enum ParseError: LocalizedError {
case unreadable(URL)
case notAnExtension(URL)
var errorDescription: String? {
switch self {
case .unreadable(let url):
return "Couldn't read \(url.lastPathComponent)."
case .notAnExtension(let url):
return "\(url.lastPathComponent) doesn't contain a Raycast extension manifest."
}
}
}
static func load(directory: URL) throws -> ExtensionManifest {
let manifestURL = directory.appendingPathComponent("package.json")
guard let data = try? Data(contentsOf: manifestURL) else {
throw ParseError.unreadable(manifestURL)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let manifest = ExtensionManifest(json: json)
else { throw ParseError.notAnExtension(directory) }
return manifest
}
init?(json: [String: Any]) {
guard let name = json["name"] as? String else { return nil }
let commands = (json["commands"] as? [Any] ?? []).compactMap(ExtensionCommand.init(json:))
guard !commands.isEmpty else { return nil }
self.name = name
title = json["title"] as? String ?? name
description = json["description"] as? String ?? ""
author = json["author"] as? String ?? ""
icon = json["icon"] as? String
categories = json["categories"] as? [String] ?? []
platforms = json["platforms"] as? [String]
self.commands = commands
preferences = (json["preferences"] as? [Any] ?? []).compactMap(ExtensionPreferenceSchema.init(json:))
}
}
@@ -0,0 +1,96 @@
import Foundation
/// Installs an extension's dependencies before a build. Only a source registry needs one.
enum ExtensionPackageManager: String, CaseIterable, Identifiable, Sendable {
case automatic
case pnpm
case npm
case yarn
case bun
var id: String { rawValue }
var title: String {
switch self {
case .automatic: return "Automatic"
case .pnpm: return "pnpm"
case .npm: return "npm"
case .yarn: return "Yarn"
case .bun: return "Bun"
}
}
/// `automatic`'s order: fastest and most disk-frugal first, npm last as the one always there.
static let preferenceOrder: [ExtensionPackageManager] = [.pnpm, .bun, .yarn, .npm]
var executableName: String {
switch self {
case .automatic: return ""
case .pnpm: return "pnpm"
case .npm: return "npm"
case .yarn: return "yarn"
case .bun: return "bun"
}
}
/// Not the frozen-lockfile variants: a committed lockfile is often stale, and failing helps nobody.
var installArguments: [String] {
switch self {
case .automatic: return []
case .pnpm: return ["install", "--ignore-scripts"]
case .npm: return ["install", "--ignore-scripts", "--no-audit", "--no-fund"]
case .yarn: return ["install", "--ignore-scripts"]
case .bun: return ["install", "--ignore-scripts"]
}
}
/// Runs the manifest's `build` script, which for a Raycast extension is `ray build`.
var buildArguments: [String] {
switch self {
case .automatic: return []
case .pnpm: return ["run", "build"]
case .npm: return ["run", "build"]
case .yarn: return ["run", "build"]
case .bun: return ["run", "build"]
}
}
/// Where to look, for a GUI app inheriting none of a shell's PATH: Homebrew, Volta, asdf, fnm, nvm.
static let searchPaths: [String] = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
NSHomeDirectory() + "/.volta/bin",
NSHomeDirectory() + "/.bun/bin",
NSHomeDirectory() + "/.asdf/shims",
NSHomeDirectory() + "/.local/share/fnm/aliases/default/bin",
NSHomeDirectory() + "/.nvm/versions/node/current/bin",
NSHomeDirectory() + "/.yarn/bin",
NSHomeDirectory() + "/.npm-global/bin"
]
/// The executable, or nil when it isn't installed. `automatic` resolves to the first one found.
func resolve(in fileManager: FileManager = .default) -> (manager: ExtensionPackageManager, url: URL)? {
guard self != .automatic else {
for candidate in Self.preferenceOrder {
if let found = candidate.resolve(in: fileManager) { return found }
}
return nil
}
for directory in Self.searchPaths {
let candidate = URL(fileURLWithPath: directory).appendingPathComponent(executableName)
if fileManager.isExecutableFile(atPath: candidate.path) { return (self, candidate) }
}
return nil
}
/// `ray build` runs on Node, and bun is the one manager that doesn't imply it.
static func nodeURL(in fileManager: FileManager = .default) -> URL? {
for directory in searchPaths {
let candidate = URL(fileURLWithPath: directory).appendingPathComponent("node")
if fileManager.isExecutableFile(atPath: candidate.path) { return candidate }
}
return nil
}
}
@@ -0,0 +1,145 @@
import Foundation
/// Where extensions come from: the store serves a built bundle, a GitHub registry serves source.
struct ExtensionRegistry: Codable, Identifiable, Hashable, Sendable {
enum Kind: String, Codable, Sendable {
/// Raycast's own store, through the endpoint its website's search uses.
case raycastStore
/// A GitHub repository laid out like `raycast/extensions`: one directory per extension.
case github
}
var id: UUID
var kind: Kind
var name: String
/// GitHub only. Empty for the store.
var owner: String
var repository: String
/// The directory the per-extension folders sit in, without leading or trailing slashes.
var path: String
/// A branch, tag or commit; a branch means "whatever is there now".
var ref: String
var isEnabled: Bool
init(
id: UUID = UUID(), kind: Kind, name: String, owner: String = "", repository: String = "",
path: String = "extensions", ref: String = "main", isEnabled: Bool = true
) {
self.id = id
self.kind = kind
self.name = name
self.owner = owner
self.repository = repository
self.path = ExtensionRegistry.normalize(path)
self.ref = ref
self.isEnabled = isEnabled
}
/// Searched first, and the only source that needs no toolchain.
static let store = ExtensionRegistry(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
kind: .raycastStore, name: "Raycast Store")
/// The fallback if the store's endpoint goes. Off by default: it serves source, so it needs Node.
static let officialGitHub = ExtensionRegistry(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
kind: .github, name: "raycast/extensions", owner: "raycast", repository: "extensions",
path: "extensions", ref: "main", isEnabled: false)
static let defaults: [ExtensionRegistry] = [.store, .officialGitHub]
/// Neither built-in can be edited or deleted; a registry someone added themselves can.
var isBuiltIn: Bool { id == Self.store.id || id == Self.officialGitHub.id }
/// Said under a heading that already names the kind, so it carries what differs between rows.
var subtitle: String {
switch kind {
case .raycastStore: return "Installs without Node or a package manager."
case .github: return "\(owner)/\(repository) at \(ref)"
}
}
/// A pasted GitHub URL: the repository root, or the `/tree/<ref>/<path>` a browser copies.
static func parse(_ text: String, name: String? = nil) -> ExtensionRegistry? {
var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
for prefix in ["https://", "http://", "github.com/", "www.github.com/"] {
if trimmed.hasPrefix(prefix) { trimmed = String(trimmed.dropFirst(prefix.count)) }
}
if trimmed.hasPrefix("github.com/") { trimmed = String(trimmed.dropFirst(11)) }
if trimmed.hasSuffix(".git") { trimmed = String(trimmed.dropLast(4)) }
let parts = trimmed.split(separator: "/").map(String.init)
guard parts.count >= 2, !parts[0].isEmpty, !parts[1].isEmpty else { return nil }
let owner = parts[0]
let repository = parts[1]
var ref = "main"
var path = "extensions"
if parts.count > 3, parts[2] == "tree" {
ref = parts[3]
let rest = parts.dropFirst(4).joined(separator: "/")
if !rest.isEmpty { path = rest }
}
return ExtensionRegistry(
kind: .github, name: name?.isEmpty == false ? name! : "\(owner)/\(repository)",
owner: owner, repository: repository, path: path, ref: ref)
}
private static func normalize(_ path: String) -> String {
var trimmed = path.trimmingCharacters(in: .whitespaces)
while trimmed.hasPrefix("/") { trimmed.removeFirst() }
while trimmed.hasSuffix("/") { trimmed.removeLast() }
return trimmed
}
}
/// One extension as a registry describes it, before anything is downloaded.
struct ExtensionListing: Identifiable, Hashable, Sendable {
/// Where the bytes come from, and therefore what installing has to do.
enum Source: Hashable, Sendable {
/// A built zip; the store signs these, so the URL is fetched at install, never reused.
case prebuiltZip(URL)
/// A directory of source in a GitHub repository, at one commit or branch.
case githubFolder(owner: String, repository: String, path: String, ref: String)
}
let id: String
/// The manifest `name`, which is what an install is keyed by.
let name: String
let title: String
let summary: String
let author: String
let iconURL: URL?
let commandCount: Int
/// Nil where a registry doesn't count downloads, which is every GitHub one.
let downloadCount: Int?
let registryID: UUID
let registryName: String
let source: Source
var needsBuild: Bool {
if case .githubFolder = source { return true }
return false
}
var subtitle: String {
var parts: [String] = []
if !author.isEmpty { parts.append(author) }
parts.append("\(commandCount) command\(commandCount == 1 ? "" : "s")")
if let downloadCount, downloadCount > 0 {
parts.append("\(ExtensionListing.abbreviate(downloadCount)) installs")
}
return parts.joined(separator: " · ")
}
/// 124218 → "124k". Exact counts past a thousand are noise in a row.
static func abbreviate(_ count: Int) -> String {
switch count {
case ..<1_000: return "\(count)"
case ..<1_000_000: return "\(count / 1_000)k"
default:
let millions = Double(count) / 1_000_000
return String(format: millions < 10 ? "%.1fM" : "%.0fM", millions)
}
}
}
@@ -0,0 +1,226 @@
import Foundation
/// Decoding for the two registry APIs, kept apart from the networking so a harness can feed it
/// recorded payloads. Both are other people's endpoints: every field beyond the few an install
/// actually needs is optional, so a response that grows or loses a key still parses.
enum ExtensionStoreResponse {
// MARK: - Raycast's store
/// The endpoint the store's own website searches with. Unofficial, hence the GitHub fallback.
static func searchURL(query: String, page: Int) -> URL? {
var components = URLComponents(string: "https://www.raycast.com/frontend_api/extensions/search")
components?.queryItems = [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "page", value: String(page)),
// Tinycast is macOS-only, so windows-only extensions are noise in the results.
URLQueryItem(name: "platform", value: "macos")
]
return components?.url
}
private struct StorePayload: Decodable {
let data: [StoreEntry]
}
private struct StoreEntry: Decodable {
let id: String
let name: String
let title: String?
let description: String?
let author: Author?
let icons: Icons?
let commands: [Command]?
let downloadCount: Int?
let downloadURL: String?
let commitSha: String?
let relativePath: String?
let status: String?
struct Author: Decodable {
let name: String?
let handle: String?
}
struct Icons: Decodable {
let light: String?
let dark: String?
}
struct Command: Decodable {
let name: String?
}
enum CodingKeys: String, CodingKey {
case id, name, title, description, author, icons, commands, status
case downloadCount = "download_count"
case downloadURL = "download_url"
case commitSha = "commit_sha"
case relativePath = "relative_path"
}
}
/// Parses a store search into listings. An entry without a usable download is dropped rather than
/// listed as something that can't be installed.
static func parseStore(_ data: Data, registry: ExtensionRegistry) throws -> [ExtensionListing] {
let payload = try JSONDecoder().decode(StorePayload.self, from: data)
return payload.data.compactMap { entry -> ExtensionListing? in
// A de-listed extension is still returned by search; it can't be downloaded any more.
guard entry.status == nil || entry.status == "active" else { return nil }
guard let raw = entry.downloadURL, let url = URL(string: raw) else { return nil }
return ExtensionListing(
id: entry.id,
name: entry.name,
title: entry.title ?? entry.name,
summary: entry.description ?? "",
author: entry.author?.name ?? entry.author?.handle ?? "",
iconURL: (entry.icons?.light ?? entry.icons?.dark).flatMap(URL.init(string:)),
commandCount: entry.commands?.count ?? 0,
downloadCount: entry.downloadCount,
registryID: registry.id,
registryName: registry.name,
source: .prebuiltZip(url))
}
}
// MARK: - A GitHub registry
static func contentsURL(
owner: String, repository: String, path: String, ref: String
) -> URL? {
var components = URLComponents(
string: "https://api.github.com/repos/\(owner)/\(repository)/contents/\(path)")
components?.queryItems = [URLQueryItem(name: "ref", value: ref)]
return components?.url
}
static func treeURL(owner: String, repository: String, sha: String) -> URL? {
URL(string: "https://api.github.com/repos/\(owner)/\(repository)/git/trees/\(sha)")
}
/// A Git tree: what a directory holds, by sha rather than by path.
struct GitTree: Decodable, Sendable {
let tree: [Entry]
/// Set when GitHub gave up listing: what came back is a prefix, not the whole directory.
let truncated: Bool?
struct Entry: Decodable, Sendable {
let path: String
let type: String
let sha: String
var isDirectory: Bool { type == "tree" }
}
func directorySHA(named name: String) -> String? {
tree.first { $0.path == name && $0.isDirectory }?.sha
}
var directoryNames: [String] { tree.filter(\.isDirectory).map(\.path) }
}
/// A tree listing, or a thrown message when GitHub answered with an error instead.
static func parseTree(_ data: Data) throws -> GitTree {
if let tree = try? JSONDecoder().decode(GitTree.self, from: data) { return tree }
struct Message: Decodable { let message: String }
if let error = try? JSONDecoder().decode(Message.self, from: data) {
throw ExtensionStoreError.registryRejected(error.message)
}
throw ExtensionStoreError.malformedResponse
}
/// One entry of a GitHub directory listing.
struct GitHubEntry: Decodable, Hashable, Sendable {
let name: String
let path: String
let type: String
let size: Int?
let downloadURL: String?
var isDirectory: Bool { type == "dir" }
enum CodingKeys: String, CodingKey {
case name, path, type, size
case downloadURL = "download_url"
}
}
/// A directory listing, or a thrown message when GitHub answered with one instead — a bad ref and
/// a rate limit both arrive as a JSON object where an array was expected.
static func parseContents(_ data: Data) throws -> [GitHubEntry] {
if let entries = try? JSONDecoder().decode([GitHubEntry].self, from: data) { return entries }
struct Message: Decodable { let message: String }
if let error = try? JSONDecoder().decode(Message.self, from: data) {
throw ExtensionStoreError.registryRejected(error.message)
}
throw ExtensionStoreError.malformedResponse
}
/// The parts of an extension's `package.json` a listing shows, read straight from the repository.
static func parseManifestSummary(
_ data: Data, folder: String, registry: ExtensionRegistry
) -> ExtensionListing? {
struct Manifest: Decodable {
let name: String?
let title: String?
let description: String?
let author: String?
let icon: String?
let commands: [Command]?
struct Command: Decodable { let name: String? }
}
guard let manifest = try? JSONDecoder().decode(Manifest.self, from: data),
let name = manifest.name ?? manifest.title
else { return nil }
return ExtensionListing(
id: "\(registry.id.uuidString)/\(folder)",
name: name,
title: manifest.title ?? name,
summary: manifest.description ?? "",
author: manifest.author ?? "",
iconURL: manifest.icon.flatMap {
URL(
string:
"https://raw.githubusercontent.com/\(registry.owner)/\(registry.repository)"
+ "/\(registry.ref)/\(registry.path)/\(folder)/assets/\($0)")
},
commandCount: manifest.commands?.count ?? 0,
downloadCount: nil,
registryID: registry.id,
registryName: registry.name,
source: .githubFolder(
owner: registry.owner, repository: registry.repository,
path: "\(registry.path)/\(folder)", ref: registry.ref))
}
}
enum ExtensionStoreError: LocalizedError {
case malformedResponse
case registryRejected(String)
case downloadFailed(String)
case noPackageManager
case noNode
case buildFailed(String)
case notAnExtension
var errorDescription: String? {
switch self {
case .malformedResponse:
return "The registry answered with something this version doesn't understand."
case .registryRejected(let message):
return message
case .downloadFailed(let reason):
return "Download failed: \(reason)"
case .noPackageManager:
return
"This extension is source that has to be built, and no package manager was found. "
+ "Install pnpm, npm, Yarn or Bun, or pick one in Advanced."
case .noNode:
return
"This extension is source that has to be built, and Node wasn't found. Install "
+ "Node.js, or install this extension from the Raycast Store instead."
case .buildFailed(let output):
return "The extension didn't build: \(output)"
case .notAnExtension:
return "That download didn't contain a Raycast extension."
}
}
}
@@ -0,0 +1,242 @@
import Foundation
/// A prop value as it arrives from the extension runtime. `handler` is a callback the palette can
/// dispatch back; `node` is an element-valued prop the runtime hoisted out of a `__slot` (an
/// `ActionPanel` on `actions`, a `Detail.Metadata` on `metadata`, …).
enum RenderValue: Sendable, Equatable {
case string(String)
case number(Double)
case bool(Bool)
case date(Date)
case handler(String)
case array([RenderValue])
case object([String: RenderValue])
case node(RenderNode)
case null
var stringValue: String? {
switch self {
case .string(let value): return value
case .number(let value):
// Whole numbers arrive as Doubles; an accessory reading "3.0" would be wrong.
return value == value.rounded() && value.magnitude < 1e15
? String(Int(value)) : String(value)
case .bool(let value): return value ? "true" : "false"
default: return nil
}
}
var boolValue: Bool? {
switch self {
case .bool(let value): return value
case .number(let value): return value != 0
default: return nil
}
}
var doubleValue: Double? {
switch self {
case .number(let value): return value
case .string(let value): return Double(value)
case .bool(let value): return value ? 1 : 0
default: return nil
}
}
var dateValue: Date? {
if case .date(let date) = self { return date }
return nil
}
var handlerID: String? {
if case .handler(let id) = self { return id }
return nil
}
var arrayValue: [RenderValue]? {
if case .array(let values) = self { return values }
return nil
}
var objectValue: [String: RenderValue]? {
if case .object(let values) = self { return values }
return nil
}
var nodeValue: RenderNode? {
if case .node(let node) = self { return node }
return nil
}
/// A slot prop holding several nodes (`ActionPanel` children hoisted from a submenu) or one.
var nodesValue: [RenderNode] {
switch self {
case .node(let node): return [node]
case .array(let values): return values.compactMap(\.nodeValue)
default: return []
}
}
/// Plain-JSON form, for handing a value back to JS unchanged (a Form field's current value).
var jsonValue: Any {
switch self {
case .string(let value): return value
case .number(let value): return value
case .bool(let value): return value
case .date(let value): return ["$date": ISO8601DateFormatter().string(from: value)]
case .handler(let id): return ["$fn": id]
case .array(let values): return values.map(\.jsonValue)
case .object(let values): return values.mapValues(\.jsonValue)
case .node: return NSNull()
case .null: return NSNull()
}
}
/// Decode a JSON array — how host-call arguments cross from the JS queue to the main actor as
/// `Sendable` values.
static func arguments(from json: String) -> [RenderValue] {
ExtensionRuntime.jsonArray(from: json).map(RenderValue.init(json:))
}
init(json: Any) {
switch json {
case let value as String:
self = .string(value)
case let value as NSNumber:
// NSNumber erases Bool; CFBoolean identity is the only reliable discriminator.
if CFGetTypeID(value) == CFBooleanGetTypeID() {
self = .bool(value.boolValue)
} else {
self = .number(value.doubleValue)
}
case let value as [Any]:
self = .array(value.map(RenderValue.init(json:)))
case let value as [String: Any]:
if let handler = value["$fn"] as? String {
self = .handler(handler)
} else if let iso = value["$date"] as? String {
self = .date(RenderValue.parseDate(iso) ?? Date())
} else if let node = RenderNode(json: value), node.isElement {
self = .node(node)
} else {
self = .object(value.mapValues(RenderValue.init(json:)))
}
default:
self = .null
}
}
/// `Date.ISO8601FormatStyle` rather than `ISO8601DateFormatter`: the format style is `Sendable`, so it
/// can be a shared constant under strict concurrency. JS always sends fractional seconds; the
/// whole-second parse is the fallback for a hand-built value.
private static let fractionalISO = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
private static let plainISO = Date.ISO8601FormatStyle()
static func parseDate(_ text: String) -> Date? {
(try? fractionalISO.parse(text)) ?? (try? plainISO.parse(text))
}
}
/// One node of the tree an extension's React render produced.
struct RenderNode: Sendable, Equatable, Identifiable {
let id: Int
let type: String
let props: [String: RenderValue]
let children: [RenderNode]
/// Set only for text nodes (`type == "#text"`).
let text: String?
var isText: Bool { type == "#text" }
/// True for a real host element — the discriminator that keeps an ordinary prop object (`{type:
/// "day"}` on Action.PickDate) from being mistaken for a hoisted slot node.
var isElement: Bool { !isText && id > 0 }
init(
id: Int, type: String, props: [String: RenderValue] = [:], children: [RenderNode] = [],
text: String? = nil
) {
self.id = id
self.type = type
self.props = props
self.children = children
self.text = text
}
init?(json: [String: Any]) {
guard let type = json["type"] as? String else { return nil }
if type == "#text" {
self.init(id: 0, type: type, text: json["text"] as? String ?? "")
return
}
guard let id = json["id"] as? Int, let rawProps = json["props"] as? [String: Any],
let rawChildren = json["children"] as? [Any]
else { return nil }
self.init(
id: id, type: type,
props: rawProps.mapValues(RenderValue.init(json:)),
children: rawChildren.compactMap { ($0 as? [String: Any]).flatMap(RenderNode.init(json:)) })
}
// MARK: - Prop access
func string(_ key: String) -> String? { props[key]?.stringValue }
func bool(_ key: String) -> Bool? { props[key]?.boolValue }
func double(_ key: String) -> Double? { props[key]?.doubleValue }
func date(_ key: String) -> Date? { props[key]?.dateValue }
func handler(_ key: String) -> String? { props[key]?.handlerID }
func node(_ key: String) -> RenderNode? { props[key]?.nodeValue }
func nodes(_ key: String) -> [RenderNode] { props[key]?.nodesValue ?? [] }
func array(_ key: String) -> [RenderValue] { props[key]?.arrayValue ?? [] }
func object(_ key: String) -> [String: RenderValue]? { props[key]?.objectValue }
/// Concatenated text of this node's direct text children — how `Form.Description`-style content
/// and stray JSX strings arrive.
var textContent: String {
children.compactMap { $0.isText ? $0.text : nil }.joined()
}
/// Depth-first search for the first descendant of `type`, following hoisted slot props too.
func firstDescendant(ofType type: String) -> RenderNode? {
if self.type == type { return self }
for child in children {
if let hit = child.firstDescendant(ofType: type) { return hit }
}
for value in props.values {
for node in value.nodesValue {
if let hit = node.firstDescendant(ofType: type) { return hit }
}
}
return nil
}
}
/// The root the runtime pushes after every commit: one `__screen` per entry on the navigation stack.
struct RenderTree: Sendable, Equatable {
let screens: [RenderNode]
init(screens: [RenderNode]) {
self.screens = screens
}
init?(json: String) {
guard let data = json.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let children = root["children"] as? [Any]
else { return nil }
let nodes = children.compactMap { ($0 as? [String: Any]).flatMap(RenderNode.init(json:)) }
// A no-view command renders nothing; a view command wraps every screen in `__screen`.
screens = nodes.filter { $0.type == "__screen" }
}
/// The screen the palette shows — the top of the navigation stack.
var active: RenderNode? {
screens.last { $0.bool("active") == true } ?? screens.last
}
/// The single root component of the active screen (a `List`, `Detail`, `Form`, `Grid`, …).
var activeRoot: RenderNode? {
active?.children.first { !$0.isText }
}
var depth: Int { max(screens.count, 1) }
}
@@ -0,0 +1,47 @@
import Foundation
/// Persists the per-extension icon overrides. Keyed by manifest name, the same key
/// `ExtensionStorage` uses for preferences, so uninstalling and reinstalling keeps the choice.
@MainActor
@Observable
final class ExtensionAppearanceStore {
@ObservationIgnored private let defaults = UserDefaults.standard
@ObservationIgnored private let key = "extensionAppearances"
private(set) var overrides: [String: ExtensionAppearance]
init() {
if let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode([String: ExtensionAppearance].self, from: data)
{
overrides = decoded
} else {
overrides = [:]
}
}
func appearance(for extensionName: String) -> ExtensionAppearance? {
overrides[extensionName]
}
/// `nil` restores the extension's own icon.
func set(_ appearance: ExtensionAppearance?, for extensionName: String) {
if let appearance {
overrides[extensionName] = appearance
} else {
overrides.removeValue(forKey: extensionName)
}
persist()
}
/// Replace the whole map at once (used when importing a settings backup).
func replace(_ newOverrides: [String: ExtensionAppearance]) {
overrides = newOverrides
persist()
}
private func persist() {
guard let data = try? JSONEncoder().encode(overrides) else { return }
defaults.set(data, forKey: key)
}
}
@@ -0,0 +1,223 @@
import Foundation
/// An installed extension: its manifest plus where it lives on disk.
struct InstalledExtension: Sendable, Hashable, Identifiable {
let manifest: ExtensionManifest
let directory: URL
var id: String { manifest.name }
var title: String { manifest.title }
/// Assets usually live in `assets/`, but a few manifests point at the extension root.
var iconPath: String? {
guard let icon = manifest.icon else { return nil }
let candidates = [
directory.appendingPathComponent("assets").appendingPathComponent(icon),
directory.appendingPathComponent(icon)
]
return candidates.first { FileManager.default.fileExists(atPath: $0.path) }?.path
}
var assetsPath: String { directory.appendingPathComponent("assets").path }
/// The prebuilt CommonJS bundle for a command, or nil when the install is incomplete.
func bundleURL(for command: ExtensionCommand) -> URL? {
let url = directory.appendingPathComponent("\(command.name).js")
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
func command(named name: String) -> ExtensionCommand? {
manifest.commands.first { $0.name == name }
}
}
/// A specific command of a specific extension — what the launcher activates.
struct ExtensionCommandRef: Sendable, Hashable {
let extensionName: String
let commandName: String
/// The `AppEntry.id` an extension command is surfaced under.
var entryID: String { "extension:\(extensionName)/\(commandName)" }
init(extensionName: String, commandName: String) {
self.extensionName = extensionName
self.commandName = commandName
}
init?(entryID: String) {
guard entryID.hasPrefix("extension:") else { return nil }
let body = entryID.dropFirst("extension:".count)
guard let slash = body.lastIndex(of: "/") else { return nil }
extensionName = String(body[body.startIndex..<slash])
commandName = String(body[body.index(after: slash)...])
}
}
/// Finds and installs extensions, stored in Raycast's own layout so a built one imports as-is.
enum ExtensionCatalog {
/// Keyed by bundle id, so a Debug build never shares installs with a release channel.
static func extensionsDirectory() -> URL {
supportDirectory().appendingPathComponent("extensions", isDirectory: true)
}
static func storageDirectory() -> URL {
supportDirectory().appendingPathComponent("extension-data", isDirectory: true)
}
/// Per-extension `environment.supportPath` — an extension's own scratch directory.
static func supportPath(for name: String) -> URL {
supportRoot().appendingPathComponent(safeName(name), isDirectory: true)
}
static func supportRoot() -> URL {
supportDirectory().appendingPathComponent("extension-support", isDirectory: true)
}
/// npm-style names flattened to one path segment; a second copy that drifts orphans every file.
static func safeName(_ name: String) -> String {
name.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: "@", with: "")
}
private static func supportDirectory() -> URL {
let base =
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
.first ?? FileManager.default.homeDirectoryForCurrentUser
let bundleID = Bundle.main.bundleIdentifier ?? "com.tinycast.app"
return base.appendingPathComponent(bundleID, isDirectory: true)
}
/// `raycast-x` is Beta v2; both roots can exist, and switching leaves the other one empty.
static func raycastExtensionRoots() -> [URL] {
let home = FileManager.default.homeDirectoryForCurrentUser
return ["raycast", "raycast-x"].map {
home.appendingPathComponent(".config/\($0)/extensions", isDirectory: true)
}
}
/// The first root holding anything, so the other channel's empty one never reads as "not installed".
static func raycastExtensionsDirectory() -> URL? {
raycastExtensionRoots().first { root in
let entries = try? FileManager.default.contentsOfDirectory(
at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
return !(entries ?? []).isEmpty
}
}
/// An unreadable or half-written directory is skipped rather than failing the whole scan.
nonisolated static func scan() -> [InstalledExtension] {
let root = extensionsDirectory()
let entries =
(try? FileManager.default.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles])) ?? []
return
entries
.compactMap { directory -> InstalledExtension? in
guard let manifest = try? ExtensionManifest.load(directory: directory),
manifest.supportsMacOS
else { return nil }
return InstalledExtension(manifest: manifest, directory: directory)
}
.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
}
// MARK: - Install
enum InstallError: LocalizedError {
case notAnExtension(URL)
case noBuiltCommands(String)
case wrongPlatform(String)
case copyFailed(String)
var errorDescription: String? {
switch self {
case .notAnExtension(let url):
return
"\(url.lastPathComponent) doesn't contain a Raycast extension (no package.json with commands)."
case .noBuiltCommands(let name):
return
"\(name) has no built command bundles. Tinycast installs prebuilt extensions — run `ray build` in the extension folder first, or import one from an installed Raycast."
case .wrongPlatform(let name):
return "\(name) doesn't support macOS."
case .copyFailed(let reason):
return "Couldn't install the extension: \(reason)"
}
}
}
/// Manifest, built commands and `assets/` only — never `node_modules` or the multi-MB `.js.map`s.
@discardableResult
static func install(from source: URL) throws -> InstalledExtension {
guard let manifest = try? ExtensionManifest.load(directory: source) else {
throw InstallError.notAnExtension(source)
}
guard manifest.supportsMacOS else { throw InstallError.wrongPlatform(manifest.title) }
let fm = FileManager.default
let built = manifest.commands.filter {
fm.fileExists(atPath: source.appendingPathComponent("\($0.name).js").path)
}
guard !built.isEmpty else { throw InstallError.noBuiltCommands(manifest.title) }
let destination = extensionsDirectory().appendingPathComponent(
manifest.name.replacingOccurrences(of: "/", with: "-"), isDirectory: true)
do {
if fm.fileExists(atPath: destination.path) { try fm.removeItem(at: destination) }
try fm.createDirectory(at: destination, withIntermediateDirectories: true)
try fm.copyItem(
at: source.appendingPathComponent("package.json"),
to: destination.appendingPathComponent("package.json"))
for command in built {
let file = "\(command.name).js"
try fm.copyItem(
at: source.appendingPathComponent(file),
to: destination.appendingPathComponent(file))
}
let assets = source.appendingPathComponent("assets")
if fm.fileExists(atPath: assets.path) {
try fm.copyItem(at: assets, to: destination.appendingPathComponent("assets"))
}
} catch {
throw InstallError.copyFailed(error.localizedDescription)
}
// Re-read from the install location so the returned value points at the copy.
let installedManifest = try ExtensionManifest.load(directory: destination)
return InstalledExtension(manifest: installedManifest, directory: destination)
}
/// Both paths it owns: nothing else collects the scratch dir, and the dialog promises it goes.
static func uninstall(_ installed: InstalledExtension) throws {
try? FileManager.default.removeItem(at: supportPath(for: installed.manifest.name))
try FileManager.default.removeItem(at: installed.directory)
}
/// Raycast keys installs by UUID, so the manifest is the only way to know what a directory holds.
nonisolated static func importableFromRaycast() -> [InstalledExtension] {
let fm = FileManager.default
let entries = raycastExtensionRoots().flatMap { root in
(try? fm.contentsOfDirectory(
at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])) ?? []
}
return
entries
.compactMap { directory -> InstalledExtension? in
guard let manifest = try? ExtensionManifest.load(directory: directory),
manifest.supportsMacOS,
// Skip half-installed or source-only directories.
manifest.commands.contains(where: {
fm.fileExists(atPath: directory.appendingPathComponent("\($0.name).js").path)
})
else { return nil }
return InstalledExtension(manifest: manifest, directory: directory)
}
// Both channels can hold the same extension; the earlier root wins, so it is offered once.
.reduce(into: [InstalledExtension]()) { unique, candidate in
guard !unique.contains(where: { $0.manifest.name == candidate.manifest.name }) else {
return
}
unique.append(candidate)
}
.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
}
}
@@ -0,0 +1,111 @@
import Foundation
/// Reclaims a stranded build workspace and the storage of an extension no longer installed.
/// It names the workspace too, so creating one and sweeping for one can't disagree.
enum ExtensionCleanup {
/// Injected, never read from `Bundle.main`: a harness would otherwise delete the real install.
struct Roots: Sendable {
var temp: URL
var support: URL
var data: URL
}
struct Report: Sendable, Equatable {
var items = 0
var bytes: Int64 = 0
var isEmpty: Bool { items == 0 }
}
/// A build's scratch directory. Unique per install, so two can run without colliding.
static func workspace(in temp: URL) -> URL {
temp.appendingPathComponent(workspacePrefix + UUID().uuidString, isDirectory: true)
}
nonisolated static func defaultRoots() -> Roots {
Roots(
temp: FileManager.default.temporaryDirectory,
support: ExtensionCatalog.supportRoot(),
data: ExtensionCatalog.storageDirectory())
}
/// What `clean` would remove, so a button can say so before it is pressed.
nonisolated static func reclaimable(installed: Set<String>, in roots: Roots) -> Report {
var report = Report()
for url in strays(installed: installed, in: roots) {
report.items += 1
report.bytes += size(of: url)
}
return report
}
/// A failed delete is skipped, not counted, so the total never claims space it didn't free.
@discardableResult
nonisolated static func clean(installed: Set<String>, in roots: Roots) -> Report {
var report = Report()
for url in strays(installed: installed, in: roots) {
let bytes = size(of: url)
guard (try? FileManager.default.removeItem(at: url)) != nil else { continue }
report.items += 1
report.bytes += bytes
}
return report
}
/// A `defer` clears a workspace on every exit an install takes; this collects what a crash left.
nonisolated static func sweepWorkspaces(in temp: URL) {
for url in staleWorkspaces(in: temp) {
try? FileManager.default.removeItem(at: url)
}
}
/// Sized the way Finder does, so a number here matches the one in Get Info.
nonisolated static func formatted(bytes: Int64) -> String {
ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
// MARK: - What counts as a stray
private static let workspacePrefix = "tinycast-install-"
/// Narrow by construction: three roots, and inside them only what names itself ours or nothing.
private nonisolated static func strays(installed: Set<String>, in roots: Roots) -> [URL] {
let safe = Set(installed.map(ExtensionCatalog.safeName))
let orphanedSupport = contents(of: roots.support).filter {
!safe.contains($0.lastPathComponent)
}
let orphanedData = contents(of: roots.data).filter {
$0.pathExtension == "json" && !safe.contains($0.deletingPathExtension().lastPathComponent)
}
return staleWorkspaces(in: roots.temp) + orphanedSupport + orphanedData
}
private nonisolated static func staleWorkspaces(in temp: URL) -> [URL] {
contents(of: temp).filter { $0.lastPathComponent.hasPrefix(workspacePrefix) }
}
private nonisolated static func contents(of directory: URL) -> [URL] {
(try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])) ?? []
}
/// An unreadable entry contributes nothing: the number is for a subtitle, not an invoice.
private nonisolated static func size(of url: URL) -> Int64 {
let keys: [URLResourceKey] = [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey]
func bytes(_ item: URL) -> Int64 {
let values = try? item.resourceValues(forKeys: Set(keys))
guard values?.isRegularFile == true else { return 0 }
return Int64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0)
}
if (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true {
return bytes(url)
}
guard
let walker = FileManager.default.enumerator(
at: url, includingPropertiesForKeys: keys, options: [])
else { return 0 }
return walker.reduce(into: Int64(0)) { total, item in
if let item = item as? URL { total += bytes(item) }
}
}
}
@@ -0,0 +1,209 @@
import Foundation
/// `fetch` for extensions, backed by `URLSession`. Bodies cross the bridge base64-encoded so binary
/// responses survive; JSON and text go through the same path.
///
/// `Sendable` because it holds only an immutable `URLSession`.
final class ExtensionFetcher: Sendable {
private let session: URLSession
init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 60
configuration.httpCookieStorage = nil
// Extensions do their own caching through the Cache API; a shared URL cache would surprise them.
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
session = URLSession(configuration: configuration)
}
enum FetchError: LocalizedError {
case badURL(String)
var errorDescription: String? {
switch self {
case .badURL(let url): return "Invalid URL: \(url)"
}
}
}
func request(_ spec: RenderValue?) async throws -> [String: Any] {
let fields = spec?.objectValue ?? [:]
let urlString = fields["url"]?.stringValue ?? ""
guard let url = URL(string: urlString), url.scheme != nil else {
throw FetchError.badURL(urlString)
}
var request = URLRequest(url: url)
request.httpMethod = fields["method"]?.stringValue ?? "GET"
for (name, value) in fields["headers"]?.objectValue ?? [:] {
guard let text = value.stringValue else { continue }
request.setValue(text, forHTTPHeaderField: name)
}
if let base64 = fields["bodyBase64"]?.stringValue, let body = Data(base64Encoded: base64) {
request.httpBody = body
}
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
var headers: [String: String] = [:]
for (key, value) in http?.allHeaderFields ?? [:] {
guard let name = key as? String, let text = value as? String else { continue }
headers[name.lowercased()] = text
}
let status = http?.statusCode ?? 200
return [
"status": status,
"statusText": HTTPURLResponse.localizedString(forStatusCode: status),
"headers": headers,
"url": response.url?.absoluteString ?? urlString,
"bodyBase64": data.base64EncodedString()
]
}
}
/// The async half of the `child_process` shim: `exec` / `execFile` without blocking the JS queue while
/// a slow command runs. The synchronous forms live in `ExtensionNodeShims`; both resolve executables
/// through `resolveExecutable` below.
enum ExtensionAsyncProcess {
enum ProcessError: LocalizedError {
case notFound(String)
case failedToStart(String, String)
var errorDescription: String? {
switch self {
case .notFound(let command):
return "ENOENT: command not found: '\(command)'"
case .failedToStart(let command, let reason):
return "Could not run '\(command)': \(reason)"
}
}
}
/// Resolve a bare command name against PATH the way `execFile` does. An app bundle inherits no
/// login shell, so `execFile("brew", …)` would otherwise fail for every Homebrew-based extension.
static func resolveExecutable(_ command: String) -> URL? {
let fileManager = FileManager.default
if command.contains("/") {
let expanded = (command as NSString).expandingTildeInPath
return fileManager.isExecutableFile(atPath: expanded)
? URL(fileURLWithPath: expanded) : nil
}
let search =
(ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
+ [
"/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin",
"/sbin"
]
for directory in search {
let candidate = (directory as NSString).appendingPathComponent(command)
if fileManager.isExecutableFile(atPath: candidate) {
return URL(fileURLWithPath: candidate)
}
}
return nil
}
static func run(_ spec: RenderValue?) async throws -> [String: Any] {
let fields = spec?.objectValue ?? [:]
let command = fields["command"]?.stringValue ?? ""
let useShell = fields["shell"]?.boolValue ?? false
let args = (fields["args"]?.arrayValue ?? []).compactMap(\.stringValue)
let cwd = fields["cwd"]?.stringValue
let environment = (fields["env"]?.objectValue).map { $0.compactMapValues(\.stringValue) }
let input = fields["input"]?.stringValue.flatMap { Data(base64Encoded: $0) }
let timeout = fields["timeout"]?.doubleValue
let detached = fields["detached"]?.boolValue ?? false
return try await withCheckedThrowingContinuation { continuation in
// `Process` termination is delivered on a private queue; run the whole thing off-main.
DispatchQueue.global(qos: .userInitiated).async {
do {
let result = try execute(
command: command, useShell: useShell, args: args, cwd: cwd,
environment: environment, input: input, timeout: timeout,
detached: detached)
continuation.resume(returning: result)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func execute(
command: String, useShell: Bool, args: [String], cwd: String?,
environment: [String: String]?, input: Data?, timeout: Double?, detached: Bool = false
) throws -> [String: Any] {
let task = Process()
if useShell {
task.executableURL = URL(fileURLWithPath: "/bin/sh")
task.arguments = ["-c", command]
} else {
guard let resolved = resolveExecutable(command) else {
throw ProcessError.notFound(command)
}
task.executableURL = resolved
task.arguments = args
}
if let cwd, !cwd.isEmpty {
task.currentDirectoryURL = URL(fileURLWithPath: (cwd as NSString).expandingTildeInPath)
}
task.environment = environment ?? ProcessInfo.processInfo.environment
let stdout = Pipe()
let stderr = Pipe()
task.standardOutput = stdout
task.standardError = stderr
if let input {
let stdin = Pipe()
task.standardInput = stdin
try? stdin.fileHandleForWriting.write(contentsOf: input)
try? stdin.fileHandleForWriting.close()
}
do {
try task.run()
} catch {
throw ProcessError.failedToStart(command, error.localizedDescription)
}
// A detached child is meant to outlive the call (`caffeinate -t 300`); answer as soon as it's
// running rather than pinning a worker thread until it exits.
if detached {
return ["stdout": "", "stderr": "", "status": 0, "signal": NSNull()]
}
let (outData, errData) = drain(task, stdout: stdout, stderr: stderr, timeout: timeout)
return [
"stdout": outData.base64EncodedString(),
"stderr": errData.base64EncodedString(),
"status": Int(task.terminationStatus),
"signal": task.terminationReason == .uncaughtSignal ? "SIGTERM" : NSNull()
]
}
/// Reads a started child to completion. A child that fills the 64 KB pipe buffer blocks before it
/// can exit, so the drain has to come first — which leaves the deadline nothing to be but a watchdog.
static func drain(
_ task: Process, stdout: Pipe, stderr: Pipe, timeout: Double?
) -> (Data, Data) {
var watchdog: DispatchSourceTimer?
if let timeout, timeout > 0 { watchdog = terminationWatchdog(task, after: timeout / 1000) }
let outData = stdout.fileHandleForReading.readDataToEndOfFile()
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
watchdog?.cancel()
return (outData, errData)
}
/// Signals the pid rather than the `Process`, which a `@Sendable` timer handler cannot capture.
private static func terminationWatchdog(
_ task: Process, after seconds: Double
) -> DispatchSourceTimer {
let pid = task.processIdentifier
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
timer.schedule(deadline: .now() + seconds)
timer.setEventHandler { kill(pid, SIGTERM) }
timer.resume()
return timer
}
}
@@ -0,0 +1,482 @@
import AppKit
import Foundation
/// A protocol, so the bridge has no hard dependency on the `ExtensionManager` that owns it.
@MainActor
protocol ExtensionHostContext: AnyObject {
/// The extension whose command is running — the namespace for storage, cache and preferences.
var activeExtensionName: String? { get }
var storage: ExtensionStorage { get }
/// The app a paste would land in — the palette's recorded `previousApp`.
var pasteTarget: NSRunningApplication? { get }
/// Every app bundle `getApplications()` reports, from the user's own search scopes.
var applicationURLs: [URL] { get }
func closeMainWindow(clearRootSearch: Bool)
/// Bring the palette back after a command hid it — what `raycast://` means to an extension.
func reopenPalette()
func popToRoot()
func clearSearchBar()
func openPreferences(scope: String)
func present(toast: ExtensionToast) -> Int
func update(toast id: Int, with toast: ExtensionToast)
func hide(toast id: Int)
func showHUD(_ text: String)
func confirmAlert(_ alert: ExtensionAlert) async -> Bool
func openWithPicker(path: String) async
func launch(command: String, extensionName: String?, arguments: [String: String]) throws
}
/// A toast as the palette shows it.
struct ExtensionToast: Sendable, Equatable, Identifiable {
enum Style: String, Sendable {
case success = "SUCCESS"
case failure = "FAILURE"
case animated = "ANIMATED"
init(raw: String?) {
self = Style(rawValue: raw ?? "SUCCESS") ?? .success
}
}
struct Action: Sendable, Equatable {
let title: String
/// Echoed back to `runToastAction` so JS can find the callback.
let token: String
}
var id: Int = 0
var style: Style = .success
var title: String = ""
var message: String?
var primaryAction: Action?
var secondaryAction: Action?
init(
id: Int = 0, style: Style = .success, title: String = "", message: String? = nil,
primaryAction: Action? = nil, secondaryAction: Action? = nil
) {
self.id = id
self.style = style
self.title = title
self.message = message
self.primaryAction = primaryAction
self.secondaryAction = secondaryAction
}
init(payload: [String: RenderValue]) {
style = Style(raw: payload["style"]?.stringValue)
title = payload["title"]?.stringValue ?? ""
message = payload["message"]?.stringValue
primaryAction = ExtensionToast.action(from: payload["primaryAction"])
secondaryAction = ExtensionToast.action(from: payload["secondaryAction"])
}
private static func action(from value: RenderValue?) -> Action? {
guard let fields = value?.objectValue, let token = fields["token"]?.stringValue else {
return nil
}
return Action(title: fields["title"]?.stringValue ?? "", token: token)
}
}
struct ExtensionAlert: Sendable {
var title: String
var message: String?
var primaryTitle: String
var dismissTitle: String
var isDestructive: Bool
init(payload: [String: RenderValue]) {
title = payload["title"]?.stringValue ?? "Are you sure?"
message = payload["message"]?.stringValue
let primary = payload["primaryAction"]?.objectValue
primaryTitle = primary?["title"]?.stringValue ?? "Confirm"
dismissTitle = payload["dismissAction"]?.objectValue?["title"]?.stringValue ?? "Cancel"
isDestructive = primary?["style"]?.stringValue == "destructive"
}
}
enum ExtensionHostError: LocalizedError {
case noActiveExtension
case unknown(String)
case unsupported(String)
var errorDescription: String? {
switch self {
case .noActiveExtension: return "No extension command is running."
case .unknown(let what): return "Unknown host call '\(what)'."
case .unsupported(let what): return "\(what) is not supported in Tinycast extensions."
}
}
}
@MainActor
final class ExtensionHostBridge: ExtensionHostAPI {
weak var context: ExtensionHostContext?
private let clipboardStore: ClipboardStore
private let fetcher = ExtensionFetcher()
init(clipboardStore: ClipboardStore) {
self.clipboardStore = clipboardStore
}
func perform(api: String, method: String, arguments: [RenderValue]) async throws -> String {
let value = try await dispatch(api: api, method: method, arguments: arguments)
return ExtensionRuntime.jsonString(from: value)
}
private func dispatch(api: String, method: String, arguments: [RenderValue]) async throws -> Any? {
switch api {
case "clipboard": return try clipboard(method: method, arguments: arguments)
case "storage": return try storage(method: method, arguments: arguments)
case "cache": return try cache(method: method, arguments: arguments)
case "window": return window(method: method, arguments: arguments)
case "feedback": return try await feedback(method: method, arguments: arguments)
case "system": return try await system(method: method, arguments: arguments)
case "fetch": return try await fetcher.request(arguments.first)
case "proc": return try await ExtensionAsyncProcess.run(arguments.first)
default: throw ExtensionHostError.unknown("\(api).\(method)")
}
}
private func requireContext() throws -> (ExtensionHostContext, String) {
guard let context, let name = context.activeExtensionName else {
throw ExtensionHostError.noActiveExtension
}
return (context, name)
}
// MARK: - Clipboard
private func clipboard(method: String, arguments: [RenderValue]) throws -> Any? {
switch method {
case "copy", "paste":
let content = arguments.first?.objectValue ?? [:]
// A file goes on the pasteboard as a file, so it pastes as the picture it is.
if let path = content["file"]?.stringValue, !path.isEmpty {
writeFileToPasteboard(path)
if method == "paste" { Paster.postCommandV() }
return nil
}
guard let text = clipboardText(from: content) else { return nil }
if method == "copy" {
Paster.copyString(text)
} else {
Paster.pasteString(text, previousApp: context?.pasteTarget)
}
return nil
case "clear":
NSPasteboard.general.clearContents()
return nil
case "readText":
return NSPasteboard.general.string(forType: .string) ?? ""
case "read":
var payload: [String: Any] = [:]
if let text = NSPasteboard.general.string(forType: .string) { payload["text"] = text }
if let url = NSPasteboard.general.string(forType: .URL) { payload["file"] = url }
return payload
default:
throw ExtensionHostError.unknown("clipboard.\(method)")
}
}
/// The file and its picture both: Finder takes the URL, a chat box takes the image data.
private func writeFileToPasteboard(_ path: String) {
let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
var items: [NSPasteboardWriting] = [url as NSURL]
if let image = NSImage(contentsOf: url) { items.append(image) }
pasteboard.writeObjects(items)
}
private func clipboardText(from content: [String: RenderValue]) -> String? {
if let text = content["text"]?.stringValue { return text }
if let html = content["html"]?.stringValue { return html }
return nil
}
// MARK: - LocalStorage
private func storage(method: String, arguments: [RenderValue]) throws -> Any? {
let (context, name) = try requireContext()
switch method {
case "get":
guard let key = arguments.first?.stringValue else { return nil }
return context.storage.localStorageValue(extension: name, key: key)?.jsonValue
case "set":
guard let key = arguments.first?.stringValue,
let value = arguments[safe: 1].flatMap(ExtensionStorage.StoredValue.init(renderValue:))
else { return nil }
context.storage.setLocalStorage(extension: name, key: key, value: value)
return nil
case "remove":
guard let key = arguments.first?.stringValue else { return nil }
context.storage.removeLocalStorage(extension: name, key: key)
return nil
case "clear":
context.storage.clearLocalStorage(extension: name)
return nil
case "all":
return context.storage.allLocalStorage(extension: name).mapValues(\.jsonValue)
default:
throw ExtensionHostError.unknown("storage.\(method)")
}
}
// MARK: - Cache
private func cache(method: String, arguments: [RenderValue]) throws -> Any? {
let (context, name) = try requireContext()
let namespace = arguments.first?.stringValue ?? "default"
switch method {
case "set":
// A nil key clears the namespace; a nil value removes one entry.
let key = arguments[safe: 1]?.stringValue
let value = arguments[safe: 2]?.stringValue
context.storage.setCache(extension: name, namespace: namespace, key: key, value: value)
return nil
case "clear":
context.storage.clearCache(extension: name, namespace: namespace)
return nil
default:
throw ExtensionHostError.unknown("cache.\(method)")
}
}
// MARK: - Window
private func window(method: String, arguments: [RenderValue]) -> Any? {
switch method {
case "close":
let options = arguments.first?.objectValue ?? [:]
context?.closeMainWindow(clearRootSearch: options["clearRootSearch"]?.boolValue ?? false)
case "popToRoot":
context?.popToRoot()
case "clearSearchBar":
context?.clearSearchBar()
case "openPreferences":
context?.openPreferences(scope: arguments.first?.stringValue ?? "extension")
default:
break
}
return nil
}
// MARK: - Feedback
private func feedback(method: String, arguments: [RenderValue]) async throws -> Any? {
guard let context else { throw ExtensionHostError.noActiveExtension }
switch method {
case "showToast":
guard let payload = arguments.first?.objectValue else { return nil }
return context.present(toast: ExtensionToast(payload: payload))
case "updateToast":
guard let id = arguments.first?.doubleValue.map(Int.init),
let payload = arguments[safe: 1]?.objectValue
else { return nil }
context.update(toast: id, with: ExtensionToast(payload: payload))
return nil
case "hideToast":
if let id = arguments.first?.doubleValue.map(Int.init) { context.hide(toast: id) }
return nil
case "showHUD":
context.showHUD(arguments.first?.stringValue ?? "")
return nil
case "confirmAlert":
guard let payload = arguments.first?.objectValue else { return false }
return await context.confirmAlert(ExtensionAlert(payload: payload))
default:
throw ExtensionHostError.unknown("feedback.\(method)")
}
}
// MARK: - System
private func system(method: String, arguments: [RenderValue]) async throws -> Any? {
switch method {
case "open":
guard let target = arguments.first?.stringValue else { return nil }
open(target: target, application: arguments[safe: 1]?.stringValue)
return nil
case "openWith":
await context?.openWithPicker(path: arguments.first?.stringValue ?? "")
return nil
case "showInFinder":
guard let path = arguments.first?.stringValue else { return nil }
AppLauncher.showInFinder(URL(fileURLWithPath: (path as NSString).expandingTildeInPath))
return nil
case "trash":
let paths = (arguments.first?.arrayValue ?? []).compactMap(\.stringValue)
for path in paths {
try? FileManager.default.trashItem(
at: URL(fileURLWithPath: (path as NSString).expandingTildeInPath),
resultingItemURL: nil)
}
return nil
case "applications":
return applications(forPath: arguments.first?.stringValue)
case "defaultApplication":
guard let path = arguments.first?.stringValue,
let url = NSWorkspace.shared.urlForApplication(
toOpen: URL(fileURLWithPath: (path as NSString).expandingTildeInPath))
else { throw ExtensionHostError.unsupported("getDefaultApplication") }
return describe(application: url)
case "frontmostApplication":
guard let app = context?.pasteTarget ?? NSWorkspace.shared.frontmostApplication,
let url = app.bundleURL
else { throw ExtensionHostError.unsupported("getFrontmostApplication") }
return describe(application: url)
case "selectedText":
return try selectedText()
case "selectedFinderItems":
return try finderSelection()
case "launchCommand":
let options = arguments.first?.objectValue ?? [:]
guard let name = options["name"]?.stringValue else {
throw ExtensionHostError.unsupported("launchCommand without a name")
}
var launchArguments: [String: String] = [:]
for (key, value) in options["arguments"]?.objectValue ?? [:] {
launchArguments[key] = value.stringValue
}
try context?.launch(
command: name, extensionName: options["extensionName"]?.stringValue,
arguments: launchArguments)
return nil
case "updateCommandMetadata":
// Subtitle metadata only shows on menu-bar commands, which Tinycast doesn't run.
return nil
default:
throw ExtensionHostError.unknown("system.\(method)")
}
}
private func open(target: String, application: String?) {
let url =
URL(string: target).flatMap { $0.scheme == nil ? nil : $0 }
?? URL(fileURLWithPath: (target as NSString).expandingTildeInPath)
// Extensions address Raycast by scheme; handing that to the workspace would launch Raycast.
if let scheme = url.scheme, scheme == "raycast" || scheme == "raycastinternal" {
openRaycastURL(url)
return
}
guard let appIdentifier = application else {
NSWorkspace.shared.open(url)
return
}
let appURL =
appIdentifier.hasPrefix("/")
? URL(fileURLWithPath: appIdentifier)
: NSWorkspace.shared.urlForApplication(withBundleIdentifier: appIdentifier)
guard let appURL else {
NSWorkspace.shared.open(url)
return
}
NSWorkspace.shared.open(
[url], withApplicationAt: appURL, configuration: NSWorkspace.OpenConfiguration(),
completionHandler: nil)
}
/// A command URL runs it when installed; every other Raycast URL just brings the palette back.
private func openRaycastURL(_ url: URL) {
let path = url.pathComponents.filter { $0 != "/" }
if url.host == "extensions", path.count >= 3,
(try? context?.launch(command: path[2], extensionName: path[1], arguments: [:])) != nil
{
return
}
context?.reopenPalette()
}
private func applications(forPath path: String?) -> [[String: Any]] {
let urls: [URL]
if let path, !path.isEmpty {
let target = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
urls = NSWorkspace.shared.urlsForApplications(toOpen: target)
} else {
urls = context?.applicationURLs ?? []
}
return urls.map(describe(application:))
}
private func describe(application url: URL) -> [String: Any] {
let bundle = Bundle(url: url)
let name =
(bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String)
?? url.deletingPathExtension().lastPathComponent
return [
"name": name, "path": url.path, "bundleId": bundle?.bundleIdentifier ?? NSNull(),
"localizedName": name
]
}
/// Reuses the Accessibility grant the paste path already needs.
private func selectedText() throws -> String {
guard Permissions.ensureAccessibility() else {
throw ExtensionHostError.unsupported("getSelectedText without the Accessibility permission")
}
let system = AXUIElementCreateSystemWide()
var focused: AnyObject?
guard
AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focused)
== .success,
let element = focused as! AXUIElement?
else { throw ExtensionHostError.unsupported("getSelectedText (no focused element)") }
var value: AnyObject?
guard
AXUIElementCopyAttributeValue(element, kAXSelectedTextAttribute as CFString, &value)
== .success, let text = value as? String
else { throw ExtensionHostError.unsupported("getSelectedText (no selection)") }
return text
}
/// Joined on a linefeed, which Finder forbids in a name; the comma AppleScript defaults to would
/// cut any path that contains one into two paths that exist nowhere.
private func finderSelection() throws -> [[String: String]] {
let script = """
set AppleScript's text item delimiters to linefeed
tell application "Finder" to set chosen to (get selection as alias list)
set paths to {}
repeat with one in chosen
set end of paths to POSIX path of one
end repeat
return paths as text
"""
guard let apple = NSAppleScript(source: script) else { return [] }
var error: NSDictionary?
let result = apple.executeAndReturnError(&error)
guard error == nil else { return [] }
return result.stringValue?
.split(separator: "\n")
.map { ["path": String($0)] } ?? []
}
}
@@ -0,0 +1,88 @@
import AppKit
/// An extension's artwork. See docs/features/extensions.md for why it draws smaller than an app icon.
enum ExtensionIconCache {
/// Below `IconCache.appIconExtent` on purpose; change it only against a rendered strip of icons.
static let extent: CGFloat = 0.76
/// `NSCache` is thread-safe but not `Sendable`, so assert the guarantee once here.
private final class Cache: NSCache<NSString, NSImage>, @unchecked Sendable {}
/// Its own budget, not the launcher's: Detail markdown caches images far larger than a row icon.
private static let cache: Cache = {
let cache = Cache()
cache.totalCostLimit = 16 * 1024 * 1024
return cache
}()
/// A freshly-decoded, thereafter-immutable `NSImage` is safe to move across the actor boundary.
private struct Decoded: @unchecked Sendable {
let image: NSImage?
}
// MARK: - Shipped with the extension
/// Cache-only, so a warm row paints on the same frame.
static func cached(atPath path: String) -> NSImage? {
IconCache.cachedArtwork(atPath: path, extent: extent)
}
/// Read from the file: `NSWorkspace` would answer a PNG with the generic document icon.
static func icon(atPath path: String) -> NSImage {
guard FileManager.default.fileExists(atPath: path) else {
return IconCache.symbolIcon(named: "puzzlepiece.extension")
}
return IconCache.artwork(atPath: path, extent: extent)
}
static func loadAsync(atPath path: String) async -> NSImage? {
guard FileManager.default.fileExists(atPath: path) else {
return IconCache.symbolIcon(named: "puzzlepiece.extension")
}
return await IconCache.loadArtworkAsync(atPath: path, extent: extent)
}
/// Never rasterized: fitting flattens a GIF to its first frame, and a playing tile needs them all.
static func loadOriginalAsync(atPath path: String) async -> NSImage? {
let key = originalKey(path)
if let cached = cache.object(forKey: key) { return cached }
let decoded = await Task.detached(priority: .userInitiated) {
Decoded(image: NSImage(contentsOfFile: path))
}.value
guard let image = decoded.image else { return nil }
cache.setObject(image, forKey: key, cost: Int(image.size.width * image.size.height * 4))
return image
}
// MARK: - Fetched by the extension
/// A failure caches nothing, so a transient error retries; `asIcon` is off for markdown images.
static func loadRemoteAsync(_ url: URL, asIcon: Bool = true) async -> NSImage? {
let key = remoteKey(url, asIcon: asIcon)
if let cached = cache.object(forKey: key) { return cached }
guard let (data, _) = try? await session.data(from: url) else { return nil }
let decoded = await Task.detached(priority: .userInitiated) {
Decoded(image: NSImage(data: data))
}.value
guard let source = decoded.image else { return nil }
guard asIcon else {
cache.setObject(source, forKey: key, cost: Int(source.size.width * source.size.height * 4))
return source
}
let (icon, cost) = IconCache.fitted(source, to: extent)
cache.setObject(icon, forKey: key, cost: cost)
return icon
}
/// Cacheless, never `URLSession.shared`: an extension names these URLs, so none reach a disk cache.
private static let session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
return URLSession(configuration: config)
}()
private static func originalKey(_ path: String) -> NSString { ("raw:" + path) as NSString }
private static func remoteKey(_ url: URL, asIcon: Bool) -> NSString {
((asIcon ? "remote:" : "full:") + url.absoluteString) as NSString
}
}
@@ -0,0 +1,225 @@
import Foundation
/// Turns a listing into an installed extension, in a workspace removed whichever way this ends.
struct ExtensionInstaller: Sendable {
/// An install can take minutes from source, and silence for that long reads as a hang.
enum Progress: Sendable, Equatable {
case downloading
case installingDependencies(manager: String)
case building
case installing
var message: String {
switch self {
case .downloading: return "Downloading…"
case .installingDependencies(let manager): return "Installing dependencies with \(manager)…"
case .building: return "Building…"
case .installing: return "Installing…"
}
}
}
/// Long enough for a cold install on a slow line, short enough that a wedged child can't hang.
private static let commandTimeout: TimeInterval = 300
let client: ExtensionStoreClient
let packageManager: ExtensionPackageManager
init(client: ExtensionStoreClient = ExtensionStoreClient(), packageManager: ExtensionPackageManager) {
self.client = client
self.packageManager = packageManager
}
/// Copies into place before the workspace goes, which is why it hands back the install, not a path.
@discardableResult
func install(
_ listing: ExtensionListing, onProgress: @Sendable @escaping (Progress) -> Void
) async throws -> InstalledExtension {
let workspace = ExtensionCleanup.workspace(in: FileManager.default.temporaryDirectory)
try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: workspace) }
let prepared: URL
switch listing.source {
case .prebuiltZip(let url):
onProgress(.downloading)
prepared = try await preparePrebuilt(from: url, in: workspace)
case .githubFolder(let owner, let repository, let path, let ref):
onProgress(.downloading)
let source = workspace.appendingPathComponent("source", isDirectory: true)
try await client.downloadFolder(
owner: owner, repository: repository, path: path, ref: ref, to: source)
prepared = try await build(
at: source, into: workspace.appendingPathComponent("build", isDirectory: true),
onProgress: onProgress)
}
onProgress(.installing)
return try ExtensionCatalog.install(from: prepared)
}
// MARK: - Prebuilt
private func preparePrebuilt(from url: URL, in workspace: URL) async throws -> URL {
let data = try await client.download(url)
let archive = workspace.appendingPathComponent("extension.zip")
try data.write(to: archive, options: .atomic)
let expanded = workspace.appendingPathComponent("expanded", isDirectory: true)
try FileManager.default.createDirectory(at: expanded, withIntermediateDirectories: true)
// `ditto` ships with macOS and handles the zips the store serves; Foundation has no unzip.
let result = try await run(
URL(fileURLWithPath: "/usr/bin/ditto"),
arguments: ["-x", "-k", archive.path, expanded.path], in: workspace)
guard result.status == 0 else {
throw ExtensionStoreError.downloadFailed(result.trimmedOutput)
}
return try locateManifestRoot(in: expanded)
}
/// The store's zip wraps the extension in a directory; a local build may not. Accept either.
private func locateManifestRoot(in directory: URL) throws -> URL {
let fileManager = FileManager.default
if fileManager.fileExists(atPath: directory.appendingPathComponent("package.json").path) {
return directory
}
let children =
(try? fileManager.contentsOfDirectory(
at: directory, includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles])) ?? []
for child in children
where fileManager.fileExists(atPath: child.appendingPathComponent("package.json").path) {
return child
}
throw ExtensionStoreError.notAnExtension
}
// MARK: - Source
/// Returns the directory to install from: `output` when `ray` produced it, else `source`.
private func build(
at source: URL, into output: URL, onProgress: @Sendable @escaping (Progress) -> Void
) async throws -> URL {
guard let resolved = packageManager.resolve() else {
throw ExtensionStoreError.noPackageManager
}
guard let node = ExtensionPackageManager.nodeURL() else {
throw ExtensionStoreError.noNode
}
onProgress(.installingDependencies(manager: resolved.manager.title))
let install = try await run(
resolved.url, arguments: resolved.manager.installArguments, in: source, node: node)
guard install.status == 0 else {
throw ExtensionStoreError.buildFailed(install.trimmedOutput)
}
onProgress(.building)
let ray = source.appendingPathComponent("node_modules/.bin/ray")
guard FileManager.default.isExecutableFile(atPath: ray.path) else {
// Not a Raycast build. Its own script is the only contract there is, and it emits in place.
let build = try await run(
resolved.url, arguments: resolved.manager.buildArguments, in: source, node: node)
guard build.status == 0 else {
throw ExtensionStoreError.buildFailed(build.trimmedOutput)
}
return try validated(source)
}
// `ray` directly, `-e dist`, `-o` never the source: dev installs into Raycast, and `-o` clears.
let build = try await run(
ray, arguments: ["build", "-e", "dist", "-o", output.path, "--non-interactive"],
in: source, node: node)
guard build.status == 0 else {
throw ExtensionStoreError.buildFailed(build.trimmedOutput)
}
return try validated(output)
}
private func validated(_ directory: URL) throws -> URL {
guard
FileManager.default.fileExists(
atPath: directory.appendingPathComponent("package.json").path)
else { throw ExtensionStoreError.notAnExtension }
return directory
}
// MARK: - Running a child process
private struct CommandResult {
let status: Int32
let output: String
/// The tail, which is where a package manager puts the actual error.
var trimmedOutput: String {
let lines = output.split(separator: "\n").suffix(6)
let text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? "no output" : text
}
}
/// Runs a tool with a PATH built for a GUI app, which inherits none of a login shell's.
private func run(
_ executable: URL, arguments: [String], in directory: URL, node: URL? = nil
) async throws -> CommandResult {
let process = Process()
process.executableURL = executable
process.arguments = arguments
process.currentDirectoryURL = directory
var environment = ProcessInfo.processInfo.environment
var searchPath = ExtensionPackageManager.searchPaths
// Node first: the package manager spawns `ray` off PATH, and a version manager hides Node.
if let node { searchPath.insert(node.deletingLastPathComponent().path, at: 0) }
environment["PATH"] = searchPath.joined(separator: ":")
// Keeps npm from writing progress bars into the output we surface on failure.
environment["CI"] = "1"
environment["NO_COLOR"] = "1"
process.environment = environment
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
return try await withCheckedThrowingContinuation { continuation in
// One resume, whichever of termination and timeout arrives first.
let state = ResumeGuard()
process.terminationHandler = { finished in
let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data()
guard state.claim() else { return }
continuation.resume(
returning: CommandResult(
status: finished.terminationStatus,
output: String(decoding: data, as: UTF8.self)))
}
do {
try process.run()
} catch {
guard state.claim() else { return }
continuation.resume(throwing: error)
return
}
Task {
try? await Task.sleep(for: .seconds(Self.commandTimeout))
guard process.isRunning else { return }
process.terminate()
guard state.claim() else { return }
continuation.resume(
returning: CommandResult(status: -1, output: "timed out after 5 minutes"))
}
}
}
}
/// Lets exactly one of two racing paths resume a continuation.
private final class ResumeGuard: @unchecked Sendable {
private let lock = NSLock()
private var claimed = false
func claim() -> Bool {
lock.lock()
defer { lock.unlock() }
if claimed { return false }
claimed = true
return true
}
}
@@ -0,0 +1,504 @@
import AppKit
import Foundation
/// What the palette is showing for the running command.
enum ExtensionSessionState: Equatable {
case idle
case launching
case rendered(RenderTree)
case failed(String)
/// A no-view command that ran to completion.
case finished
}
/// Owns the installed set, the runtime and the one running command. See docs/features/extensions.md.
@MainActor
@Observable
final class ExtensionManager: ExtensionRuntimeDelegate, ExtensionHostContext {
private(set) var installed: [InstalledExtension] = []
private(set) var state: ExtensionSessionState = .idle
/// The command whose session is live, if any.
private(set) var running: ExtensionCommandRef?
/// Toasts the running command asked for, newest last.
private(set) var toasts: [ExtensionToast] = []
/// Depth of the extension's own navigation stack; >1 means Escape should pop rather than close.
private(set) var navigationDepth = 1
/// Off means nothing scanned, published or held: the feature costs an unused stored property.
private(set) var isEnabled = false
/// Whether the commands reach the launcher at all; independent of `isEnabled`.
private(set) var showsInLauncher = true
let storage: ExtensionStorage
/// Extension-scoped state the launcher and Settings read through here, like `storage`.
let appearances = ExtensionAppearanceStore()
@ObservationIgnored private let runtime: ExtensionRuntime
@ObservationIgnored private let bridge: ExtensionHostBridge
@ObservationIgnored private weak var appIndex: AppIndex?
@ObservationIgnored private weak var coordinator: ExtensionCoordinator?
/// The entry ids an uninstall invalidated, so another feature can drop what it keyed to them.
@ObservationIgnored var onDidUninstall: (([String]) -> Void)?
@ObservationIgnored private var sessionID: String?
@ObservationIgnored private var nextToastID = 1
init(clipboardStore: ClipboardStore) {
storage = ExtensionStorage(directory: ExtensionCatalog.storageDirectory())
bridge = ExtensionHostBridge(clipboardStore: clipboardStore)
runtime = ExtensionRuntime(hostAPI: bridge)
bridge.context = self
}
/// Wires collaborators only; the coordinator applies the switches, deciding whether anything scans.
func start(appIndex: AppIndex, coordinator: ExtensionCoordinator) {
self.appIndex = appIndex
self.coordinator = coordinator
runtime.setDelegate(self)
// Not gated on `isEnabled`: a stranded workspace is ours whether or not the feature is on.
let temp = FileManager.default.temporaryDirectory
Task.detached(priority: .utility) { ExtensionCleanup.sweepWorkspaces(in: temp) }
}
// MARK: - The switches
/// Idempotent both ways, so applying settings on launch is the same call as flipping the switch.
func setEnabled(_ enabled: Bool) async {
guard enabled != isEnabled else { return }
isEnabled = enabled
guard enabled else {
await stop()
installed = []
appIndex?.setExtensionCommands([])
return
}
await refresh()
}
func setShowsInLauncher(_ shows: Bool) {
guard shows != showsInLauncher else { return }
showsInLauncher = shows
publishLauncherEntries()
}
// MARK: - Installed set
func refresh() async {
guard isEnabled else { return }
let found = await Task.detached(priority: .utility) { ExtensionCatalog.scan() }.value
guard found != installed else { return }
installed = found
publishLauncherEntries()
}
func extensionNamed(_ name: String) -> InstalledExtension? {
installed.first { $0.manifest.name == name }
}
/// Built from the installed set, not `AppIndex`: a shortcut can fire before a row ever exists.
func launcherEntry(forEntryID entryID: String) -> AppEntry? {
guard let reference = ExtensionCommandRef(entryID: entryID),
let owner = extensionNamed(reference.extensionName),
let command = owner.command(named: reference.commandName)
else { return nil }
return entry(for: command, in: owner)
}
/// Menu-bar commands are listed too: activating one explains itself, which beats hiding it.
private func publishLauncherEntries() {
guard isEnabled, showsInLauncher else {
appIndex?.setExtensionCommands([])
return
}
let entries =
installed
.flatMap { owner in owner.manifest.commands.map { entry(for: $0, in: owner) } }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
appIndex?.setExtensionCommands(entries)
}
/// One command as a row; a chosen appearance replaces the shipped icon for all of them.
private func entry(for command: ExtensionCommand, in owner: InstalledExtension) -> AppEntry {
let appearance = appearances.appearance(for: owner.manifest.name)
let reference = ExtensionCommandRef(
extensionName: owner.manifest.name, commandName: command.name)
return AppEntry(
id: reference.entryID,
name: command.title,
url: owner.directory,
bundleID: nil,
kind: .extensionCommand,
iconOverride: icon(for: command, in: owner, appearance: appearance),
labelOverride: owner.title)
}
/// Persist and re-publish, so rows change under the user rather than on the next scan.
func setAppearance(_ appearance: ExtensionAppearance?, for extensionName: String) {
appearances.set(appearance, for: extensionName)
publishLauncherEntries()
}
/// Bulk apply from a settings backup.
func replaceAppearances(_ overrides: [String: ExtensionAppearance]) {
appearances.replace(overrides)
publishLauncherEntries()
}
/// An appearance wins, else the shipped artwork: the launcher is handed the answer, not the why.
private func icon(
for command: ExtensionCommand, in owner: InstalledExtension,
appearance: ExtensionAppearance?
) -> EntryIcon {
if let appearance {
return .tintedSymbol(name: appearance.symbol, tint: appearance.tint.symbolTint)
}
guard let path = commandIconPath(command, in: owner) ?? owner.iconPath else {
return .symbol("puzzlepiece.extension")
}
return .artwork(path: path, extent: ExtensionIconCache.extent)
}
private func commandIconPath(_ command: ExtensionCommand, in owner: InstalledExtension) -> String? {
guard let icon = command.icon else { return nil }
let candidate = owner.directory.appendingPathComponent("assets").appendingPathComponent(icon)
return FileManager.default.fileExists(atPath: candidate.path) ? candidate.path : nil
}
// MARK: - Install / uninstall
func install(from source: URL) async throws {
_ = try ExtensionCatalog.install(from: source)
await refresh()
}
/// Scanned off-main: it reads a manifest per directory, and a full Raycast install is dozens.
func raycastImportCandidates() async -> [RaycastImportCandidate] {
let candidates = await Task.detached(priority: .userInitiated) {
ExtensionCatalog.importableFromRaycast()
}.value
let have = Set(installed.map(\.manifest.name))
return candidates.map {
RaycastImportCandidate(installed: $0, isInstalled: have.contains($0.manifest.name))
}
}
/// Progress is reported per step: building from source can take minutes.
func install(
listing: ExtensionListing, packageManager: ExtensionPackageManager,
onProgress: @Sendable @escaping (ExtensionInstaller.Progress) -> Void
) async throws {
let installer = ExtensionInstaller(packageManager: packageManager)
try await installer.install(listing, onProgress: onProgress)
await refresh()
}
/// Refreshes once at the end, and returns what failed so the pane can name it.
@discardableResult
func importAllFromRaycast(
_ candidates: [InstalledExtension], onProgress: (Int) -> Void = { _ in }
) async -> [String] {
var failed: [String] = []
for (index, candidate) in candidates.enumerated() {
do {
_ = try ExtensionCatalog.install(from: candidate.directory)
} catch {
failed.append(candidate.title)
}
onProgress(index + 1)
}
await refresh()
return failed
}
/// Takes everything keyed to it: files, storage, icon, and through `onDidUninstall` its shortcuts.
func uninstall(_ installedExtension: InstalledExtension) async {
if running?.extensionName == installedExtension.manifest.name { await stop() }
let entryIDs = installedExtension.manifest.commands.map {
ExtensionCommandRef(
extensionName: installedExtension.manifest.name, commandName: $0.name
).entryID
}
try? ExtensionCatalog.uninstall(installedExtension)
storage.removeAll(extension: installedExtension.manifest.name)
appearances.set(nil, for: installedExtension.manifest.name)
onDidUninstall?(entryIDs)
await refresh()
}
// MARK: - Running a command
enum LaunchError: LocalizedError {
case unknownCommand(String)
case unsupported(String)
case notBuilt(String)
case missingPreferences([ExtensionPreferenceSchema])
var errorDescription: String? {
switch self {
case .unknownCommand(let id): return "No installed extension provides '\(id)'."
case .unsupported(let reason): return reason
case .notBuilt(let name):
return "\(name) has no built bundle — reinstall the extension."
case .missingPreferences(let schemas):
let names = schemas.map(\.displayTitle).joined(separator: ", ")
return "This command needs its preferences set first: \(names)."
}
}
}
/// Resolve a launcher row to a command, or nil when the row isn't an extension command.
func resolve(_ entry: AppEntry) -> (InstalledExtension, ExtensionCommand)? {
guard let reference = ExtensionCommandRef(entryID: entry.id),
let owner = extensionNamed(reference.extensionName),
let command = owner.command(named: reference.commandName)
else { return nil }
return (owner, command)
}
func run(_ entry: AppEntry, arguments: [String: String] = [:]) async {
guard let (owner, command) = resolve(entry) else {
state = .failed(LaunchError.unknownCommand(entry.id).localizedDescription)
return
}
await run(owner, command: command, arguments: arguments)
}
func run(
_ owner: InstalledExtension, command: ExtensionCommand, arguments: [String: String] = [:]
) async {
await stop()
if let reason = command.mode.unsupportedReason {
state = .failed(reason)
running = ExtensionCommandRef(
extensionName: owner.manifest.name, commandName: command.name)
return
}
let schemas = owner.manifest.preferences + command.preferences
let missing = storage.missingRequiredPreferences(
extension: owner.manifest.name, schemas: schemas)
guard missing.isEmpty else {
running = ExtensionCommandRef(
extensionName: owner.manifest.name, commandName: command.name)
state = .failed(LaunchError.missingPreferences(missing).localizedDescription)
return
}
guard let bundle = owner.bundleURL(for: command) else {
running = ExtensionCommandRef(
extensionName: owner.manifest.name, commandName: command.name)
state = .failed(LaunchError.notBuilt(command.title).localizedDescription)
return
}
running = ExtensionCommandRef(extensionName: owner.manifest.name, commandName: command.name)
navigationDepth = 1
state = .launching
let supportPath = ExtensionCatalog.supportPath(for: owner.manifest.name)
try? FileManager.default.createDirectory(at: supportPath, withIntermediateDirectories: true)
do {
// No-op while a context is already up; after `stop()` this builds a fresh one.
try await runtime.boot(config: .current(supportDirectory: supportPath))
} catch {
state = .failed(error.localizedDescription)
return
}
// Reading the bundle is IO on a file that can be a few hundred KB; keep it off the main actor.
let code = await Task.detached(priority: .userInitiated) {
(try? String(contentsOf: bundle, encoding: .utf8)) ?? ""
}.value
guard !code.isEmpty else {
state = .failed(LaunchError.notBuilt(command.title).localizedDescription)
return
}
let session = UUID().uuidString
sessionID = session
let context = ExtensionLaunchContext(
extensionName: owner.manifest.name,
extensionTitle: owner.title,
commandName: command.name,
commandMode: command.mode,
assetsPath: owner.assetsPath,
supportPath: supportPath.path,
preferences: storage.resolvedPreferences(
extension: owner.manifest.name, schemas: schemas),
caches: storage.caches(extension: owner.manifest.name),
arguments: command.completeArguments(arguments),
fallbackText: nil)
await runtime.start(
session: session, code: code, file: bundle, mode: command.mode, context: context)
}
func stop() async {
guard let sessionID else {
resetSessionState()
return
}
self.sessionID = nil
await runtime.stop(session: sessionID)
// Then discard the context outright, so nothing an extension left behind reaches the next run.
runtime.shutdown()
storage.flush()
resetSessionState()
}
private func resetSessionState() {
state = .idle
running = nil
toasts = []
navigationDepth = 1
}
// MARK: - Events from the palette
func dispatch(handler: String, arguments: [Any] = []) {
guard let sessionID else { return }
let payload = ExtensionRuntime.jsonString(from: arguments)
Task { await runtime.dispatch(session: sessionID, handler: handler, payload: payload) }
}
/// Pops the extension's stack; false when there is nothing to pop and the palette should close.
func popNavigation() async -> Bool {
guard let sessionID, navigationDepth > 1 else { return false }
return await runtime.popNavigation(session: sessionID)
}
func runToastAction(token: String) {
Task { await runtime.runToastAction(token: token) }
}
// MARK: - ExtensionRuntimeDelegate
func runtime(_ runtime: ExtensionRuntime, session: String, didRender tree: RenderTree) {
guard session == sessionID else { return }
state = .rendered(tree)
navigationDepth = tree.depth
}
func runtime(_ runtime: ExtensionRuntime, session: String, didFail message: String) {
guard session == sessionID else { return }
state = .failed(message)
}
func runtime(_ runtime: ExtensionRuntime, session: String, navigationDepth depth: Int) {
guard session == sessionID else { return }
navigationDepth = depth
}
func runtime(_ runtime: ExtensionRuntime, session: String, didFinish: Void) {
guard session == sessionID else { return }
// A no-view command is done: the palette is already closing, so just release the session.
state = .finished
Task { await stop() }
}
func runtime(_ runtime: ExtensionRuntime, log level: String, message: String) {
#if DEBUG
print("[extension \(level)] \(message)")
#endif
}
// MARK: - ExtensionHostContext
var activeExtensionName: String? { running?.extensionName }
var pasteTarget: NSRunningApplication? { coordinator?.pasteTarget }
var applicationURLs: [URL] { coordinator?.applicationURLs ?? [] }
func closeMainWindow(clearRootSearch: Bool) {
coordinator?.closeMainWindow()
}
func reopenPalette() {
coordinator?.reopenPalette(hasRunningCommand: running != nil)
}
func popToRoot() {
coordinator?.popExtensionToRoot()
}
func clearSearchBar() {
coordinator?.clearSearchBar()
}
func openPreferences(scope: String) {
guard let running, let owner = extensionNamed(running.extensionName) else { return }
coordinator?.showExtensionSettings(for: owner)
}
func present(toast: ExtensionToast) -> Int {
var stamped = toast
stamped.id = nextToastID
nextToastID += 1
// A no-view command's toast has no palette to appear in; show it as a HUD instead of dropping it.
guard coordinator?.isPaletteVisible == true else {
coordinator?.showHUD(
[toast.title, toast.message].compactMap { $0 }.joined(separator: " — "))
return stamped.id
}
toasts.append(stamped)
// Non-animated toasts self-dismiss; an animated one stays until the command hides it.
if stamped.style != .animated { scheduleToastDismissal(id: stamped.id) }
return stamped.id
}
func update(toast id: Int, with toast: ExtensionToast) {
guard let index = toasts.firstIndex(where: { $0.id == id }) else { return }
var stamped = toast
stamped.id = id
toasts[index] = stamped
if stamped.style != .animated { scheduleToastDismissal(id: id) }
}
func hide(toast id: Int) {
toasts.removeAll { $0.id == id }
}
private func scheduleToastDismissal(id: Int) {
Task { [weak self] in
try? await Task.sleep(for: .seconds(3))
self?.hide(toast: id)
}
}
func showHUD(_ text: String) {
coordinator?.showHUD(text)
}
func confirmAlert(_ alert: ExtensionAlert) async -> Bool {
await coordinator?.confirmExtensionAlert(alert) ?? false
}
func openWithPicker(path: String) async {
let target = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
let candidates = NSWorkspace.shared.urlsForApplications(toOpen: target)
guard candidates.count > 1 else {
NSWorkspace.shared.open(target)
return
}
let panel = NSAlert()
panel.messageText = "Open With"
panel.informativeText = target.lastPathComponent
for candidate in candidates.prefix(4) {
panel.addButton(withTitle: candidate.deletingPathExtension().lastPathComponent)
}
panel.addButton(withTitle: "Cancel")
let response = panel.runModal().rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue
guard response >= 0, response < min(candidates.count, 4) else { return }
NSWorkspace.shared.open(
[target], withApplicationAt: candidates[Int(response)],
configuration: NSWorkspace.OpenConfiguration(), completionHandler: nil)
}
/// `launchCommand` from a running command: same extension unless it names another.
func launch(command name: String, extensionName: String?, arguments: [String: String]) throws {
let owningName = extensionName ?? running?.extensionName
guard let owningName, let owner = extensionNamed(owningName),
let command = owner.command(named: name)
else { throw LaunchError.unknownCommand(name) }
Task { await run(owner, command: command, arguments: arguments) }
}
}
@@ -0,0 +1,324 @@
import CryptoKit
import Foundation
/// Services the JS Node shims call *synchronously*. Safe to answer inline because none of this needs
/// the main actor: it all runs on `ExtensionRuntime`'s private JS queue, so a blocking answer can
/// never deadlock against the UI. Binary payloads cross as base64.
///
/// `@unchecked Sendable`: stateless apart from `FileManager.default`, which is thread-safe.
final class ExtensionNodeShims: @unchecked Sendable {
private let fileManager = FileManager.default
/// Returns the JSON envelope `{ok, value}` / `{ok:false, error, code}` the JS side unwraps.
func perform(api: String, method: String, argsJSON: String) -> String {
let arguments = ExtensionRuntime.jsonArray(from: argsJSON)
do {
let value = try dispatch(api: api, method: method, arguments: arguments)
return envelope(["ok": true, "value": value ?? NSNull()])
} catch let error as ShimError {
return envelope(["ok": false, "error": error.message, "code": error.code])
} catch {
return envelope(["ok": false, "error": error.localizedDescription, "code": "EUNKNOWN"])
}
}
private func envelope(_ payload: [String: Any]) -> String {
(try? JSONSerialization.data(withJSONObject: payload)).map {
String(decoding: $0, as: UTF8.self)
} ?? #"{"ok":false,"error":"could not encode host result","code":"EUNKNOWN"}"#
}
struct ShimError: Error {
let message: String
let code: String
static func noEntry(_ path: String, _ syscall: String) -> ShimError {
ShimError(message: "ENOENT: no such file or directory, \(syscall) '\(path)'", code: "ENOENT")
}
static func failed(_ message: String, _ code: String = "EIO") -> ShimError {
ShimError(message: message, code: code)
}
}
private func dispatch(api: String, method: String, arguments: [Any]) throws -> Any? {
switch api {
case "fs": return try filesystem(method: method, arguments: arguments)
case "proc": return try process(method: method, arguments: arguments)
case "crypto": return try crypto(method: method, arguments: arguments)
case "zlib": return try compression(method: method, arguments: arguments)
default: throw ShimError.failed("Unknown host module '\(api)'.", "ENOSYS")
}
}
// MARK: - fs
private func filesystem(method: String, arguments: [Any]) throws -> Any? {
func path(_ index: Int) throws -> String {
guard let value = arguments[safe: index] as? String, !value.isEmpty else {
throw ShimError.failed("fs.\(method) needs a path.", "EINVAL")
}
return (value as NSString).expandingTildeInPath
}
switch method {
case "readFile":
let target = try path(0)
guard let data = fileManager.contents(atPath: target) else {
throw ShimError.noEntry(target, "open")
}
return data.base64EncodedString()
case "writeFile":
let target = try path(0)
let data = Data(base64Encoded: arguments[safe: 1] as? String ?? "") ?? Data()
let append = arguments[safe: 2] as? Bool ?? false
if append, let handle = FileHandle(forWritingAtPath: target) {
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: data)
return nil
}
guard fileManager.createFile(atPath: target, contents: data) else {
throw ShimError.failed("EACCES: could not write '\(target)'", "EACCES")
}
return nil
case "exists":
return fileManager.fileExists(atPath: try path(0))
case "stat":
let target = try path(0)
let followLinks = !(arguments[safe: 1] as? Bool ?? false)
return try stat(path: target, followLinks: followLinks)
case "readdir":
let target = try path(0)
guard let names = try? fileManager.contentsOfDirectory(atPath: target) else {
throw ShimError.noEntry(target, "scandir")
}
return names.map { name -> [String: Any] in
let child = (target as NSString).appendingPathComponent(name)
var isDirectory: ObjCBool = false
let exists = fileManager.fileExists(atPath: child, isDirectory: &isDirectory)
let isLink =
(try? fileManager.destinationOfSymbolicLink(atPath: child)) != nil
return [
"name": name, "parentPath": target,
"_isFile": exists && !isDirectory.boolValue,
"_isDirectory": isDirectory.boolValue,
"_isSymbolicLink": isLink
]
}
case "mkdir":
let target = try path(0)
let recursive = arguments[safe: 1] as? Bool ?? false
try fileManager.createDirectory(
atPath: target, withIntermediateDirectories: recursive)
return recursive ? target : nil
case "remove":
let target = try path(0)
let force = arguments[safe: 2] as? Bool ?? false
if !fileManager.fileExists(atPath: target) {
if force { return nil }
throw ShimError.noEntry(target, "unlink")
}
try fileManager.removeItem(atPath: target)
return nil
case "rename":
let from = try path(0)
let to = try path(1)
if fileManager.fileExists(atPath: to) { try fileManager.removeItem(atPath: to) }
try fileManager.moveItem(atPath: from, toPath: to)
return nil
case "copyFile":
let from = try path(0)
let to = try path(1)
if fileManager.fileExists(atPath: to) { try fileManager.removeItem(atPath: to) }
try fileManager.copyItem(atPath: from, toPath: to)
return nil
case "realpath":
let target = try path(0)
guard fileManager.fileExists(atPath: target) else {
throw ShimError.noEntry(target, "realpath")
}
return URL(fileURLWithPath: target).resolvingSymlinksInPath().path
case "mkdtemp":
// Node's contract: the prefix already includes the parent directory.
let prefix = try path(0)
let target = prefix + String(UUID().uuidString.prefix(6))
try fileManager.createDirectory(atPath: target, withIntermediateDirectories: true)
return target
default:
throw ShimError.failed("fs.\(method) is not supported.", "ENOSYS")
}
}
private func stat(path: String, followLinks: Bool) throws -> [String: Any] {
let attributes =
followLinks
? try? fileManager.attributesOfItem(
atPath: URL(fileURLWithPath: path).resolvingSymlinksInPath().path)
: try? fileManager.attributesOfItem(atPath: path)
guard let attributes else { throw ShimError.noEntry(path, "stat") }
let type = attributes[.type] as? FileAttributeType
func milliseconds(_ key: FileAttributeKey) -> Double {
((attributes[key] as? Date)?.timeIntervalSince1970 ?? 0) * 1000
}
return [
"size": (attributes[.size] as? NSNumber)?.doubleValue ?? 0,
"mode": (attributes[.posixPermissions] as? NSNumber)?.intValue ?? 0,
"mtimeMs": milliseconds(.modificationDate),
"atimeMs": milliseconds(.modificationDate),
"ctimeMs": milliseconds(.creationDate),
"birthtimeMs": milliseconds(.creationDate),
"_isFile": type == .typeRegular,
"_isDirectory": type == .typeDirectory,
"_isSymbolicLink": type == .typeSymbolicLink
]
}
// MARK: - child_process
private func process(method: String, arguments: [Any]) throws -> Any? {
guard method == "run", let spec = arguments.first as? [String: Any] else {
throw ShimError.failed("child_process.\(method) is not supported.", "ENOSYS")
}
let command = spec["command"] as? String ?? ""
guard !command.isEmpty else { throw ShimError.failed("No command given.", "EINVAL") }
let useShell = spec["shell"] as? Bool ?? false
let task = Process()
if useShell {
task.executableURL = URL(fileURLWithPath: "/bin/sh")
task.arguments = ["-c", command]
} else {
guard let resolved = ExtensionAsyncProcess.resolveExecutable(command) else {
throw ShimError.noEntry(command, "spawn")
}
task.executableURL = resolved
task.arguments = (spec["args"] as? [String] ?? [])
}
if let cwd = spec["cwd"] as? String, !cwd.isEmpty {
task.currentDirectoryURL = URL(fileURLWithPath: (cwd as NSString).expandingTildeInPath)
}
if let overrides = spec["env"] as? [String: String] {
task.environment = overrides
} else {
task.environment = ProcessInfo.processInfo.environment
}
let stdout = Pipe()
let stderr = Pipe()
task.standardOutput = stdout
task.standardError = stderr
if let inputBase64 = spec["input"] as? String, let data = Data(base64Encoded: inputBase64) {
let stdin = Pipe()
task.standardInput = stdin
try? stdin.fileHandleForWriting.write(contentsOf: data)
try? stdin.fileHandleForWriting.close()
}
do {
try task.run()
} catch {
throw ShimError.failed("Could not run '\(command)': \(error.localizedDescription)", "ENOENT")
}
// This runs on the JS queue, so a child that never exits would freeze the whole runtime.
let (outData, errData) = ExtensionAsyncProcess.drain(
task, stdout: stdout, stderr: stderr,
timeout: (spec["timeout"] as? NSNumber)?.doubleValue)
return [
"stdout": outData.base64EncodedString(),
"stderr": errData.base64EncodedString(),
"status": Int(task.terminationStatus),
"signal": task.terminationReason == .uncaughtSignal ? "SIGTERM" : NSNull()
]
}
// MARK: - crypto
private func crypto(method: String, arguments: [Any]) throws -> Any? {
switch method {
case "uuid":
return UUID().uuidString.lowercased()
case "random":
let count = max(0, (arguments.first as? NSNumber)?.intValue ?? 0)
var bytes = [UInt8](repeating: 0, count: count)
for index in 0..<count { bytes[index] = UInt8.random(in: 0...255) }
return Data(bytes).base64EncodedString()
case "hash":
let algorithm = arguments[safe: 0] as? String ?? "sha256"
let data = Data(base64Encoded: arguments[safe: 1] as? String ?? "") ?? Data()
return try digest(algorithm: algorithm, data: data).base64EncodedString()
case "hmac":
let algorithm = arguments[safe: 0] as? String ?? "sha256"
let data = Data(base64Encoded: arguments[safe: 1] as? String ?? "") ?? Data()
let key = Data(base64Encoded: arguments[safe: 2] as? String ?? "") ?? Data()
return try authenticate(algorithm: algorithm, data: data, key: key).base64EncodedString()
default:
throw ShimError.failed("crypto.\(method) is not supported.", "ENOSYS")
}
}
private func digest(algorithm: String, data: Data) throws -> Data {
switch algorithm.lowercased() {
case "md5": return Data(Insecure.MD5.hash(data: data))
case "sha1": return Data(Insecure.SHA1.hash(data: data))
case "sha256": return Data(SHA256.hash(data: data))
case "sha384": return Data(SHA384.hash(data: data))
case "sha512": return Data(SHA512.hash(data: data))
default:
throw ShimError.failed("Unsupported hash algorithm '\(algorithm)'.", "ENOSYS")
}
}
private func authenticate(algorithm: String, data: Data, key: Data) throws -> Data {
let symmetric = SymmetricKey(data: key)
switch algorithm.lowercased() {
case "md5": return Data(HMAC<Insecure.MD5>.authenticationCode(for: data, using: symmetric))
case "sha1": return Data(HMAC<Insecure.SHA1>.authenticationCode(for: data, using: symmetric))
case "sha256": return Data(HMAC<SHA256>.authenticationCode(for: data, using: symmetric))
case "sha384": return Data(HMAC<SHA384>.authenticationCode(for: data, using: symmetric))
case "sha512": return Data(HMAC<SHA512>.authenticationCode(for: data, using: symmetric))
default:
throw ShimError.failed("Unsupported HMAC algorithm '\(algorithm)'.", "ENOSYS")
}
}
// MARK: - zlib
private func compression(method: String, arguments: [Any]) throws -> Any? {
let data = Data(base64Encoded: arguments.first as? String ?? "") ?? Data()
let result: Data
switch method {
case "gunzip": result = try Zlib.gunzip(data)
case "inflate": result = try Zlib.inflate(data)
case "inflateRaw": result = try Zlib.inflateRaw(data)
case "gzip": result = try Zlib.gzip(data)
case "deflate": result = try Zlib.deflate(data)
case "deflateRaw": result = try Zlib.deflateRaw(data)
default: throw ShimError.failed("zlib.\(method) is not supported.", "ENOSYS")
}
return result.base64EncodedString()
}
}
extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -0,0 +1,336 @@
import Foundation
import JavaScriptCore
/// The JS→Swift seam. Answers are JSON, so nothing non-`Sendable` crosses back to the JS queue.
@MainActor
protocol ExtensionHostAPI: AnyObject, Sendable {
func perform(api: String, method: String, arguments: [RenderValue]) async throws -> String
}
/// Where a running command's UI or failure lands. Every callback arrives on the main actor.
@MainActor
protocol ExtensionRuntimeDelegate: AnyObject {
func runtime(_ runtime: ExtensionRuntime, session: String, didRender tree: RenderTree)
func runtime(_ runtime: ExtensionRuntime, session: String, didFail message: String)
func runtime(_ runtime: ExtensionRuntime, session: String, navigationDepth: Int)
func runtime(_ runtime: ExtensionRuntime, session: String, didFinish: Void)
func runtime(_ runtime: ExtensionRuntime, log level: String, message: String)
}
/// The one `JSContext` a command runs in. `@unchecked Sendable` is narrow: every touch is on `queue`,
/// and only plain values cross. Why JavaScriptCore, and why one context, is in docs/features/extensions.md.
final class ExtensionRuntime: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.tinycast.extensions.js", qos: .userInitiated)
private var context: JSContext?
private var timers: [String: DispatchSourceTimer] = [:]
private let nodeShims = ExtensionNodeShims()
/// Set once at startup; read on the JS queue, so it is written before the runtime ever boots.
private nonisolated(unsafe) weak var delegate: ExtensionRuntimeDelegate?
private let hostAPI: ExtensionHostAPI
private let runtimeOverride: URL?
/// `runtimeURL` overrides the bundled runtime; only the harness passes it, having no app bundle.
init(hostAPI: ExtensionHostAPI, runtimeURL: URL? = nil) {
self.hostAPI = hostAPI
self.runtimeOverride = runtimeURL
}
@MainActor
func setDelegate(_ delegate: ExtensionRuntimeDelegate) {
self.delegate = delegate
}
// MARK: - Lifecycle
enum RuntimeError: LocalizedError {
case runtimeResourceMissing
case bootFailed(String)
var errorDescription: String? {
switch self {
case .runtimeResourceMissing:
return "RaycastRuntime.generated.js is missing from the app bundle."
case .bootFailed(let message):
return "The extension runtime failed to start: \(message)"
}
}
}
/// Idempotent, so any command can lazily ensure the engine is up.
func boot(config: ExtensionBootConfig) async throws {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
try self.bootOnQueue(config: config)
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func bootOnQueue(config: ExtensionBootConfig) throws {
guard context == nil else { return }
guard
let url = runtimeOverride
?? Bundle.main.url(forResource: "RaycastRuntime.generated", withExtension: "js"),
let source = try? String(contentsOf: url, encoding: .utf8)
else { throw RuntimeError.runtimeResourceMissing }
guard let context = JSContext() else { throw RuntimeError.bootFailed("no JSContext") }
var thrown: String?
context.exceptionHandler = { _, exception in
thrown = ExtensionRuntime.describe(exception)
}
installHost(in: context)
context.evaluateScript(source, withSourceURL: url)
if let thrown { throw RuntimeError.bootFailed(thrown) }
// Stored only once it is known good: a half-built one would make every later boot a no-op.
self.context = context
// From here on an exception is a bug in a command, not in the runtime: report it and keep going.
context.exceptionHandler = { [weak self] _, exception in
self?.report(level: "error", message: ExtensionRuntime.describe(exception))
}
let payload = config.jsonString()
_ = context.objectForKeyedSubscript("__tinycast")?
.invokeMethod("boot", withArguments: [payload])
}
/// Load and mount one command. `code` is its prebuilt CommonJS bundle.
func start(
session: String, code: String, file: URL, mode: ExtensionCommandMode,
context launchContext: ExtensionLaunchContext
) async {
let payload = launchContext.jsonString()
await onQueue { context in
let compiled = context.objectForKeyedSubscript("__tinycast")
_ = compiled?.invokeMethod(
"start",
withArguments: [
session, code, file.path, file.deletingLastPathComponent().path,
mode.runtimeName, payload
])
}
}
/// Pre-encoded: `[Any]` isn't Sendable, so only the JSON string crosses onto the queue.
func dispatch(session: String, handler: String, payload: String) async {
await onQueue { context in
_ = context.objectForKeyedSubscript("__tinycast")?
.invokeMethod("dispatch", withArguments: [session, handler, payload])
}
}
/// Escape / the back chevron inside a pushed screen. Returns true when a screen was actually popped.
func popNavigation(session: String) async -> Bool {
await withCheckedContinuation { continuation in
queue.async {
guard let context = self.context else { return continuation.resume(returning: false) }
let result = context.objectForKeyedSubscript("__tinycast")?
.invokeMethod("popNavigation", withArguments: [session])
continuation.resume(returning: result?.toString() == "1")
}
}
}
func runToastAction(token: String) async {
await onQueue { context in
_ = context.objectForKeyedSubscript("__tinycast")?
.invokeMethod("runToastAction", withArguments: [token])
}
}
func stop(session: String) async {
await onQueue { context in
_ = context.objectForKeyedSubscript("__tinycast")?
.invokeMethod("stop", withArguments: [session])
}
}
private func onQueue(_ body: @escaping @Sendable (JSContext) -> Void) async {
await withCheckedContinuation { continuation in
queue.async {
if let context = self.context { body(context) }
continuation.resume()
}
}
}
// MARK: - Host object
/// Installs `__tinycastHost` (the JS→Swift seam) and `__tinycastCompile`.
private func installHost(in context: JSContext) {
let host = JSValue(newObjectIn: context)
let log: @convention(block) (String, String) -> Void = { [weak self] level, message in
self?.report(level: level, message: message)
}
let render: @convention(block) (String, String) -> Void = { [weak self] session, json in
self?.deliverRender(session: session, json: json)
}
let failed: @convention(block) (String, String) -> Void = { [weak self] session, message in
guard let self else { return }
let delegate = self.delegate
Task { @MainActor in delegate?.runtime(self, session: session, didFail: message) }
}
let navigation: @convention(block) (String, String) -> Void = { [weak self] session, depth in
guard let self else { return }
let delegate = self.delegate
let value = Int(depth) ?? 1
Task { @MainActor in delegate?.runtime(self, session: session, navigationDepth: value) }
}
let finished: @convention(block) (String) -> Void = { [weak self] session in
guard let self else { return }
let delegate = self.delegate
Task { @MainActor in delegate?.runtime(self, session: session, didFinish: ()) }
}
let fieldCommand: @convention(block) (String, String) -> Void = { _, _ in
// Field focus requests have no native target yet; the palette focuses the first field.
}
let invoke: @convention(block) (String, String, String, String) -> Void = {
[weak self] callId, api, method, argsJSON in
self?.invokeAsync(callId: callId, api: api, method: method, argsJSON: argsJSON)
}
let invokeSync: @convention(block) (String, String, String) -> String = {
[weak self] api, method, argsJSON in
guard let self else { return #"{"ok":false,"error":"runtime gone"}"# }
return self.nodeShims.perform(api: api, method: method, argsJSON: argsJSON)
}
let startTimer: @convention(block) (String, Double, Bool) -> Void = {
[weak self] id, milliseconds, repeats in
self?.startTimer(id: id, milliseconds: milliseconds, repeats: repeats)
}
let clearTimer: @convention(block) (String) -> Void = { [weak self] id in
self?.clearTimer(id: id)
}
host?.setObject(log, forKeyedSubscript: "log" as NSString)
host?.setObject(render, forKeyedSubscript: "render" as NSString)
host?.setObject(failed, forKeyedSubscript: "failed" as NSString)
host?.setObject(navigation, forKeyedSubscript: "navigationDepthChanged" as NSString)
host?.setObject(finished, forKeyedSubscript: "finished" as NSString)
host?.setObject(fieldCommand, forKeyedSubscript: "fieldCommand" as NSString)
host?.setObject(invoke, forKeyedSubscript: "invoke" as NSString)
host?.setObject(invokeSync, forKeyedSubscript: "invokeSync" as NSString)
host?.setObject(startTimer, forKeyedSubscript: "startTimer" as NSString)
host?.setObject(clearTimer, forKeyedSubscript: "clearTimer" as NSString)
context.setObject(host, forKeyedSubscript: "__tinycastHost" as NSString)
// Global scope is the point: the extension body must not see the runtime's own locals.
let compile: @convention(block) (String, String) -> JSValue? = { [weak self] code, filename in
guard let context = self?.context else { return nil }
let wrapped = "(function (exports, require, module, __filename, __dirname) {\n\(code)\n})"
return context.evaluateScript(wrapped, withSourceURL: URL(fileURLWithPath: filename))
}
context.setObject(compile, forKeyedSubscript: "__tinycastCompile" as NSString)
}
// MARK: - Host call plumbing
private func invokeAsync(callId: String, api: String, method: String, argsJSON: String) {
// Decode to `RenderValue` here so only `Sendable` values reach the main actor.
let arguments = RenderValue.arguments(from: argsJSON)
let hostAPI = self.hostAPI
Task { @MainActor in
do {
let json = try await hostAPI.perform(
api: api, method: method, arguments: arguments)
await self.settle(callId: callId, ok: true, payload: json)
} catch {
await self.settle(
callId: callId, ok: false,
payload: (error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
}
}
private func settle(callId: String, ok: Bool, payload: String) async {
await onQueue { context in
_ = context.objectForKeyedSubscript("__tinycast")?
.invokeMethod("settle", withArguments: [callId, ok, payload])
}
}
private func deliverRender(session: String, json: String) {
guard let delegate else { return }
// Decode here: parsing a large list is the expensive part, and it belongs off the main actor.
guard let tree = RenderTree(json: json) else {
report(level: "error", message: "Could not decode the render tree for \(session).")
return
}
Task { @MainActor in delegate.runtime(self, session: session, didRender: tree) }
}
private func report(level: String, message: String) {
guard let delegate else { return }
Task { @MainActor in delegate.runtime(self, log: level, message: message) }
}
// MARK: - Timers
private func startTimer(id: String, milliseconds: Double, repeats: Bool) {
let timer = DispatchSource.makeTimerSource(queue: queue)
let interval = max(milliseconds, 0) / 1000
if repeats {
timer.schedule(deadline: .now() + interval, repeating: max(interval, 0.001))
} else {
timer.schedule(deadline: .now() + interval)
}
timer.setEventHandler { [weak self] in
guard let self else { return }
if !repeats { self.timers[id] = nil }
_ = self.context?.objectForKeyedSubscript("__tinycast")?
.invokeMethod("fireTimer", withArguments: [id])
}
timers[id]?.cancel()
timers[id] = timer
timer.resume()
}
private func clearTimer(id: String) {
timers[id]?.cancel()
timers[id] = nil
}
/// The whole context goes; the next `boot` builds a fresh one (~7 ms warm). Timers are global and
/// React's scheduler rides them, so reuse either cancels its commits or leaks an extension's.
func shutdown() {
queue.async {
for timer in self.timers.values { timer.cancel() }
self.timers.removeAll()
self.context = nil
}
}
// MARK: - JSON helpers
private static func describe(_ exception: JSValue?) -> String {
guard let exception else { return "unknown JavaScript error" }
let stack = exception.objectForKeyedSubscript("stack")?.toString()
let message = exception.toString() ?? "JavaScript error"
if let stack, !stack.isEmpty, stack != "undefined" { return "\(message)\n\(stack)" }
return message
}
static func jsonArray(from json: String) -> [Any] {
guard let data = json.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [Any]
else { return [] }
return array
}
/// Fragment-tolerant encoding: host results are frequently bare strings, numbers or nil.
static func jsonString(from value: Any?) -> String {
guard let value, !(value is NSNull) else { return "" }
guard
let data = try? JSONSerialization.data(
withJSONObject: value, options: [.fragmentsAllowed])
else { return "" }
return String(decoding: data, as: UTF8.self)
}
}
@@ -0,0 +1,200 @@
import Foundation
/// Per-extension `LocalStorage`, `Cache` and preference values, persisted as one JSON file per
/// extension under the app-support directory.
///
/// A file rather than SQLite on purpose: an extension's store is small, read whole at launch (the
/// `Cache` API is synchronous, so its contents must be handed over up front) and written rarely.
@MainActor
final class ExtensionStorage {
private struct Store: Codable {
var localStorage: [String: StoredValue] = [:]
var caches: [String: [String: String]] = [:]
var preferences: [String: StoredValue] = [:]
}
/// `LocalStorage` accepts strings, numbers and booleans and must return them with their type.
enum StoredValue: Codable, Sendable, Equatable {
case string(String)
case number(Double)
case bool(Bool)
var jsonValue: Any {
switch self {
case .string(let value): return value
case .number(let value): return value
case .bool(let value): return value
}
}
init?(renderValue: RenderValue) {
switch renderValue {
case .string(let value): self = .string(value)
case .number(let value): self = .number(value)
case .bool(let value): self = .bool(value)
default: return nil
}
}
init(preference: ExtensionPreferenceValue) {
switch preference {
case .string(let value): self = .string(value)
case .number(let value): self = .number(value)
case .bool(let value): self = .bool(value)
}
}
var preferenceValue: ExtensionPreferenceValue {
switch self {
case .string(let value): return .string(value)
case .number(let value): return .number(value)
case .bool(let value): return .bool(value)
}
}
}
private let directory: URL
private var stores: [String: Store] = [:]
/// Extensions whose store changed and hasn't been written yet — writes are coalesced so a busy
/// `Cache` doesn't hit the disk per key.
private var dirty: Set<String> = []
private var flushTask: Task<Void, Never>?
init(directory: URL) {
self.directory = directory
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
// MARK: - LocalStorage
func localStorageValue(extension name: String, key: String) -> StoredValue? {
store(for: name).localStorage[key]
}
func allLocalStorage(extension name: String) -> [String: StoredValue] {
store(for: name).localStorage
}
func setLocalStorage(extension name: String, key: String, value: StoredValue) {
mutate(name) { $0.localStorage[key] = value }
}
func removeLocalStorage(extension name: String, key: String) {
mutate(name) { $0.localStorage.removeValue(forKey: key) }
}
func clearLocalStorage(extension name: String) {
mutate(name) { $0.localStorage.removeAll() }
}
// MARK: - Cache
func caches(extension name: String) -> [String: [String: String]] {
store(for: name).caches
}
/// `nil` removes the key; a `nil` key clears the namespace.
func setCache(extension name: String, namespace: String, key: String?, value: String?) {
mutate(name) { store in
guard let key else {
store.caches[namespace] = [:]
return
}
var bucket = store.caches[namespace] ?? [:]
if let value { bucket[key] = value } else { bucket.removeValue(forKey: key) }
store.caches[namespace] = bucket
}
}
func clearCache(extension name: String, namespace: String) {
mutate(name) { $0.caches[namespace] = [:] }
}
// MARK: - Preferences
func preference(extension name: String, key: String) -> ExtensionPreferenceValue? {
store(for: name).preferences[key]?.preferenceValue
}
func setPreference(extension name: String, key: String, value: ExtensionPreferenceValue?) {
mutate(name) { store in
if let value {
store.preferences[key] = StoredValue(preference: value)
} else {
store.preferences.removeValue(forKey: key)
}
}
}
/// Manifest defaults overlaid with whatever the user has set — what a command sees from
/// `getPreferenceValues()`.
func resolvedPreferences(
extension name: String, schemas: [ExtensionPreferenceSchema]
) -> [String: ExtensionPreferenceValue] {
var resolved: [String: ExtensionPreferenceValue] = [:]
for schema in schemas {
resolved[schema.name] = preference(extension: name, key: schema.name) ?? schema.effectiveDefault
}
return resolved
}
/// True when every required preference has a non-empty value — a command with an unset required
/// preference must not run, exactly as in Raycast.
func missingRequiredPreferences(
extension name: String, schemas: [ExtensionPreferenceSchema]
) -> [ExtensionPreferenceSchema] {
schemas.filter { schema in
guard schema.required else { return false }
let value = preference(extension: name, key: schema.name) ?? schema.effectiveDefault
if case .string(let text) = value { return text.isEmpty }
return false
}
}
func removeAll(extension name: String) {
stores.removeValue(forKey: name)
try? FileManager.default.removeItem(at: fileURL(for: name))
}
// MARK: - Persistence
private func store(for name: String) -> Store {
if let existing = stores[name] { return existing }
let loaded =
(try? Data(contentsOf: fileURL(for: name)))
.flatMap { try? JSONDecoder().decode(Store.self, from: $0) } ?? Store()
stores[name] = loaded
return loaded
}
private func mutate(_ name: String, _ body: (inout Store) -> Void) {
var current = store(for: name)
body(&current)
stores[name] = current
dirty.insert(name)
scheduleFlush()
}
private func scheduleFlush() {
guard flushTask == nil else { return }
flushTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(250))
self?.flush()
}
}
func flush() {
flushTask?.cancel()
flushTask = nil
let pending = dirty
dirty.removeAll()
for name in pending {
guard let store = stores[name], let data = try? JSONEncoder().encode(store) else { continue }
try? data.write(to: fileURL(for: name), options: .atomic)
}
}
private func fileURL(for name: String) -> URL {
directory.appendingPathComponent("\(ExtensionCatalog.safeName(name)).json")
}
}
@@ -0,0 +1,218 @@
import Foundation
/// Searches every enabled registry and merges the results, the store's winning because it is prebuilt.
struct ExtensionStoreClient: Sendable {
/// Kept small: a GitHub registry reads one manifest per candidate.
private static let githubCandidateLimit = 12
/// Cacheless, never `URLSession.shared`, so a registry search leaves no second copy on disk.
private static let defaultSession: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
return URLSession(configuration: config)
}()
private let session: URLSession
init(session: URLSession = ExtensionStoreClient.defaultSession) {
self.session = session
}
// MARK: - Search
/// The error rather than a throw, so one failing registry can't take the others' results.
struct RegistryResult: Sendable {
let registry: ExtensionRegistry
let listings: [ExtensionListing]
let failure: String?
}
func search(_ query: String, in registries: [ExtensionRegistry]) async -> [RegistryResult] {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return [] }
return await withTaskGroup(of: RegistryResult.self) { group in
for registry in registries where registry.isEnabled {
group.addTask { await search(trimmed, in: registry) }
}
var results: [RegistryResult] = []
for await result in group { results.append(result) }
// Registry order, not completion order, so the list doesn't reshuffle between searches.
return registries.compactMap { registry in
results.first { $0.registry.id == registry.id }
}
}
}
private func search(_ query: String, in registry: ExtensionRegistry) async -> RegistryResult {
do {
let listings =
switch registry.kind {
case .raycastStore: try await searchStore(query, registry: registry)
case .github: try await searchGitHub(query, registry: registry)
}
return RegistryResult(registry: registry, listings: listings, failure: nil)
} catch {
return RegistryResult(
registry: registry, listings: [], failure: error.localizedDescription)
}
}
private func searchStore(
_ query: String, registry: ExtensionRegistry
) async throws
-> [ExtensionListing]
{
guard let url = ExtensionStoreResponse.searchURL(query: query, page: 1) else {
throw ExtensionStoreError.malformedResponse
}
let data = try await get(url)
return try ExtensionStoreResponse.parseStore(data, registry: registry)
}
/// A registry has no search, so the listing is ranked here and only the best few are read.
private func searchGitHub(
_ query: String, registry: ExtensionRegistry
) async throws
-> [ExtensionListing]
{
let folders = try await folderNames(in: registry)
let ranked =
folders
.compactMap { folder -> (String, Int)? in
guard let score = FuzzyMatch.score(query: query, candidate: folder) else {
return nil
}
return (folder, score)
}
.sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0 < $1.0 }
.prefix(Self.githubCandidateLimit)
.map(\.0)
return await withTaskGroup(of: ExtensionListing?.self) { group in
for folder in ranked {
group.addTask { await manifestListing(folder: folder, registry: registry) }
}
var listings: [ExtensionListing] = []
for await listing in group {
if let listing { listings.append(listing) }
}
// Back into rank order: the group finishes in whatever order the requests do.
return ranked.compactMap { folder in
listings.first { listing in
if case .githubFolder(_, _, let path, _) = listing.source {
return path.hasSuffix("/" + folder)
}
return false
}
}
}
}
private func manifestListing(
folder: String, registry: ExtensionRegistry
) async
-> ExtensionListing?
{
let raw =
"https://raw.githubusercontent.com/\(registry.owner)/\(registry.repository)"
+ "/\(registry.ref)/\(registry.path)/\(folder)/package.json"
guard let url = URL(string: raw), let data = try? await get(url) else { return nil }
return ExtensionStoreResponse.parseManifestSummary(
data, folder: folder, registry: registry)
}
/// Trees, not contents: contents caps a directory at 1000 silently, and this repo is three times that.
private func folderNames(in registry: ExtensionRegistry) async throws -> [String] {
if let cached = await FolderCache.shared.names(for: registry.id) { return cached }
var sha = registry.ref
for segment in registry.path.split(separator: "/").map(String.init) {
guard
let url = ExtensionStoreResponse.treeURL(
owner: registry.owner, repository: registry.repository, sha: sha)
else { throw ExtensionStoreError.malformedResponse }
let tree = try ExtensionStoreResponse.parseTree(try await get(url))
guard let next = tree.directorySHA(named: segment) else {
throw ExtensionStoreError.registryRejected(
"\(registry.owner)/\(registry.repository) has no \(registry.path) directory "
+ "on \(registry.ref).")
}
sha = next
}
guard
let url = ExtensionStoreResponse.treeURL(
owner: registry.owner, repository: registry.repository, sha: sha)
else { throw ExtensionStoreError.malformedResponse }
let tree = try ExtensionStoreResponse.parseTree(try await get(url))
let names = tree.directoryNames
await FolderCache.shared.store(names, for: registry.id)
return names
}
// MARK: - Fetching
/// One folder, walked breadth-first: a registry repository is gigabytes, and cloning it is absurd.
func downloadFolder(
owner: String, repository: String, path: String, ref: String, to destination: URL
) async throws {
let fileManager = FileManager.default
try fileManager.createDirectory(at: destination, withIntermediateDirectories: true)
guard
let url = ExtensionStoreResponse.contentsURL(
owner: owner, repository: repository, path: path, ref: ref)
else { throw ExtensionStoreError.malformedResponse }
let entries = try ExtensionStoreResponse.parseContents(try await get(url))
for entry in entries {
// Never needed to build, and the heaviest thing in some extension folders.
if entry.isDirectory, entry.name == "node_modules" || entry.name == "metadata" {
continue
}
let target = destination.appendingPathComponent(entry.name)
if entry.isDirectory {
try await downloadFolder(
owner: owner, repository: repository, path: entry.path, ref: ref, to: target)
} else {
guard let raw = entry.downloadURL, let fileURL = URL(string: raw) else { continue }
let data = try await get(fileURL)
try data.write(to: target, options: .atomic)
}
}
}
func download(_ url: URL) async throws -> Data {
try await get(url)
}
private func get(_ url: URL) async throws -> Data {
var request = URLRequest(url: url)
// GitHub serves the old media type without it, and rejects a request with no user agent.
request.setValue("Tinycast", forHTTPHeaderField: "User-Agent")
if url.host?.hasSuffix("api.github.com") == true {
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { return data }
guard (200..<300).contains(http.statusCode) else {
// GitHub explains a rate limit in the body; surfacing that beats a bare "403".
struct Message: Decodable { let message: String }
if let error = try? JSONDecoder().decode(Message.self, from: data) {
throw ExtensionStoreError.registryRejected(error.message)
}
throw ExtensionStoreError.downloadFailed("HTTP \(http.statusCode)")
}
return data
}
}
/// Listings for the session: re-fetching per keystroke spends GitHub's anonymous limit in a few searches.
private actor FolderCache {
static let shared = FolderCache()
private var cached: [UUID: [String]] = [:]
func names(for registry: UUID) -> [String]? { cached[registry] }
func store(_ names: [String], for registry: UUID) { cached[registry] = names }
}
@@ -0,0 +1,172 @@
import AppKit
/// One group of symbols; `id` is the CoreGlyphs key, but for the two synthetic ones.
struct SymbolCategory: Identifiable, Hashable, Sendable {
let id: String
let title: String
static let suggested = SymbolCategory(id: "tinycast.suggested", title: "Suggested")
static let all = SymbolCategory(id: "tinycast.all", title: "All Symbols")
static let bundled = SymbolCategory(id: "tinycast.bundled", title: "Tinycast")
}
/// Read from `CoreGlyphs.bundle` at runtime, so the list matches the OS. A system resource, not API:
/// every read is optional and the curated list stands in if its layout ever changes.
struct SymbolCatalog: Sendable {
let symbols: [String]
let categories: [SymbolCategory]
private let byCategory: [String: [String]]
private let searchTerms: [String: [String]]
/// Marks we ship ourselves: the system has no bluetooth symbol at all, restricted or otherwise.
static let bundledGlyphs = ["bluetooth", "BrandGitHub", "BrandDiscord", "BrandX"]
static func isBundled(_ symbol: String) -> Bool { bundledGlyphs.contains(symbol) }
/// Search terms for the app's own marks, since they carry none of CoreGlyphs' metadata.
private nonisolated static let bundledTerms: [String: [String]] = [
"bluetooth": ["bluetooth", "wireless", "pair", "device"],
"BrandGitHub": ["github", "git", "repository", "code", "brand"],
"BrandDiscord": ["discord", "chat", "community", "brand"],
"BrandX": ["x", "twitter", "social", "brand"]
]
/// What the picker opens on: scrolling eight thousand icons is not a way to choose one.
static let suggested =
bundledGlyphs + [
// Status & power
"bolt.fill", "cup.and.saucer.fill", "moon.fill", "sun.max.fill", "power", "battery.100",
"eye.fill", "bell.fill", "sparkles", "wand.and.stars",
// Time
"calendar", "clock.fill", "timer", "hourglass", "alarm.fill",
// Text & documents
"doc.text.fill", "text.alignleft", "checklist", "list.bullet", "note.text",
"folder.fill", "tray.full.fill", "archivebox.fill", "book.fill", "bookmark.fill",
// Communication
"envelope.fill", "message.fill", "paperplane.fill", "phone.fill", "video.fill",
"person.2.fill", "bubble.left.and.bubble.right.fill",
// Media
"music.note", "speaker.wave.2.fill", "headphones", "photo.fill", "camera.fill",
"play.fill", "pause.fill", "paintbrush.fill", "theatermasks.fill",
// Developer
"terminal.fill", "chevron.left.forwardslash.chevron.right", "hammer.fill",
"wrench.and.screwdriver.fill", "ant.fill", "cpu", "memorychip", "externaldrive.fill",
"server.rack", "shippingbox.fill",
// System & network
"gearshape.fill", "slider.horizontal.3", "network", "globe", "link", "wifi",
"display", "keyboard", "cursorarrow.rays", "square.grid.2x2.fill",
// Security & money
"lock.fill", "key.fill", "shield.fill", "creditcard.fill", "cart.fill", "banknote.fill",
// Data
"chart.bar.fill", "chart.pie.fill", "function", "number", "brain",
// Places & things
"star.fill", "heart.fill", "flag.fill", "tag.fill", "map.fill", "location.fill",
"airplane", "car.fill", "leaf.fill", "flame.fill", "drop.fill", "snowflake",
"cloud.fill", "gift.fill", "trash.fill", "arrow.triangle.2.circlepath"
]
/// The curated set alone: the fallback, and what the picker shows until the real load lands.
static let fallback = SymbolCatalog(
symbols: suggested.filter {
isBundled($0) || NSImage(systemSymbolName: $0, accessibilityDescription: nil) != nil
},
categories: [.suggested, .bundled],
byCategory: [:],
searchTerms: [:])
/// Reads and filters the system catalog. Off the main actor: it parses ~700 KB of plists.
nonisolated static func load() -> SymbolCatalog {
let base = URL(
fileURLWithPath:
"/System/Library/CoreServices/CoreGlyphs.bundle/Contents/Resources")
func plist<T>(_ name: String, as type: T.Type) -> T? {
guard let data = try? Data(contentsOf: base.appendingPathComponent(name)),
let value = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil)
else { return nil }
return value as? T
}
guard let order = plist("symbol_order.plist", as: [String].self), !order.isEmpty else {
return fallback
}
// Apple reserves ~600 for its own products; labelling an extension with one misuses the mark.
let restricted = Set(
(plist("symbol_restrictions.strings", as: [String: String].self) ?? [:]).keys)
let categoriesBySymbol = plist("symbol_categories.plist", as: [String: [String]].self) ?? [:]
let search = plist("symbol_search.plist", as: [String: [String]].self) ?? [:]
let systemSymbols = order.filter { !restricted.contains($0) && !isLocaleVariant($0) }
guard !systemSymbols.isEmpty else { return fallback }
// The app's own marks lead, so what the system lacks is the first thing offered.
let symbols = bundledGlyphs + systemSymbols
var byCategory: [String: [String]] = [:]
for symbol in symbols {
for category in categoriesBySymbol[symbol] ?? [] where categoryTitles[category] != nil {
byCategory[category, default: []].append(symbol)
}
}
// Apple's own order, skipping buckets that describe a rendering mode rather than a subject.
let ordered = (plist("categories.plist", as: [[String: String]].self) ?? [])
.compactMap { $0["key"] }
.filter { byCategory[$0]?.isEmpty == false }
.compactMap { key in categoryTitles[key].map { SymbolCategory(id: key, title: $0) } }
return SymbolCatalog(
symbols: symbols,
categories: [.suggested, .bundled, .all] + ordered,
byCategory: byCategory,
searchTerms: search.merging(bundledTerms) { system, _ in system })
}
func symbols(in category: SymbolCategory) -> [String] {
switch category.id {
case SymbolCategory.suggested.id: return Self.suggested
case SymbolCategory.bundled.id: return Self.bundledGlyphs
case SymbolCategory.all.id: return symbols
default: return byCategory[category.id] ?? []
}
}
/// Every word must hit the name or a search term, so "coffee" finds `cup.and.saucer`.
func search(_ query: String, in category: SymbolCategory) -> [String] {
let words = query.lowercased().split(whereSeparator: { $0 == " " || $0 == "." })
guard !words.isEmpty else { return symbols(in: category) }
// A search is a search: it looks through everything unless the user narrowed to a category.
let pool = category.id == SymbolCategory.suggested.id ? symbols : symbols(in: category)
return pool.filter { symbol in
let haystack = symbol.replacingOccurrences(of: ".", with: " ")
return words.allSatisfy { word in
haystack.contains(word)
|| (searchTerms[symbol] ?? []).contains { $0.lowercased().contains(word) }
}
}
}
/// Locale renderings of symbols that already exist: a thousand near-duplicates, all noise here.
private nonisolated static func isLocaleVariant(_ symbol: String) -> Bool {
let suffixes: Set<String> = [
"ar", "he", "hi", "ja", "ko", "th", "zh", "my", "km", "mn", "ne", "si", "ta", "te",
"kn", "ml", "gu", "pa", "or", "bn", "ur", "am", "el", "ru", "sr", "rtl", "ltr"
]
return symbol.split(separator: ".").contains { suffixes.contains(String($0)) }
}
/// Readable names for the CoreGlyphs category keys; anything absent here is a rendering-mode bucket
/// (all / whatsnew / variable / multicolor / draw) and isn't browsable as a subject.
private nonisolated static let categoryTitles: [String: String] = [
"communication": "Communication", "weather": "Weather", "maps": "Maps",
"objectsandtools": "Objects & Tools", "devices": "Devices",
"cameraandphotos": "Camera & Photos", "gaming": "Gaming",
"connectivity": "Connectivity", "transportation": "Transportation",
"automotive": "Automotive", "accessibility": "Accessibility",
"privacyandsecurity": "Privacy & Security", "human": "People", "home": "Home",
"fitness": "Fitness", "nature": "Nature", "editing": "Editing",
"textformatting": "Text Formatting", "media": "Media", "keyboard": "Keyboard",
"commerce": "Commerce", "time": "Time", "health": "Health", "shapes": "Shapes",
"arrows": "Arrows", "indices": "Indices", "math": "Math"
]
}
@@ -0,0 +1,172 @@
import SwiftUI
/// Swatches and a searchable symbol grid. Changes apply at once: the launcher is the real preview.
struct ExtensionAppearancePicker: View {
let current: ExtensionAppearance
/// Whether the extension is currently re-skinned, which is the only time resetting means anything.
let isCustom: Bool
let onPick: (ExtensionAppearance) -> Void
let onReset: () -> Void
/// The curated set first: ~700 KB of plists would stall the popover's first frame.
@State private var catalog = SymbolCatalog.fallback
@State private var category = SymbolCategory.suggested
@State private var query = ""
/// One column system from the grid, so every row shares its edges rather than finding its own.
private enum Metrics {
static let tile: CGFloat = 30
static let columns = 10
static let gap: CGFloat = 8
static let inset: CGFloat = Theme.Spacing.xl
static let swatchesPerRow = 9
static let swatch: CGFloat = 20
/// Whole rows on show, plus half a tile of the next as a deliberate scroll hint.
static let visibleRows = 6
static var contentWidth: CGFloat {
CGFloat(columns) * tile + CGFloat(columns - 1) * gap
}
static var popoverWidth: CGFloat { contentWidth + inset * 2 }
static var gridHeight: CGFloat {
CGFloat(visibleRows) * (tile + gap) - gap + tile / 2
}
/// Spread across the same width, so the first and last swatch sit on the grid's edges.
static var swatchGap: CGFloat {
(contentWidth - CGFloat(swatchesPerRow) * swatch) / CGFloat(swatchesPerRow - 1)
}
}
private let swatches = Array(
repeating: GridItem(.fixed(Metrics.swatch), spacing: Metrics.swatchGap),
count: Metrics.swatchesPerRow)
private let icons = Array(
repeating: GridItem(.fixed(Metrics.tile), spacing: Metrics.gap), count: Metrics.columns)
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.lg) {
LazyVGrid(columns: swatches, alignment: .leading, spacing: Metrics.gap) {
ForEach(ExtensionTint.allCases) { tint in
Button {
onPick(ExtensionAppearance(symbol: current.symbol, tint: tint))
} label: {
Circle()
.fill(tint.color.gradient)
.frame(width: Metrics.swatch, height: Metrics.swatch)
.overlay(
Circle().strokeBorder(
.white.opacity(tint == current.tint ? 0.9 : 0), lineWidth: 2)
)
}
.buttonStyle(.plain)
.help(tint.title)
}
}
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("", text: $query, prompt: Text("Search symbols…"))
.textFieldStyle(.plain)
.labelsHidden()
.pointerStyle(.horizontalText)
Picker("", selection: $category) {
ForEach(catalog.categories) { item in
Text(item.title).tag(item)
}
}
.labelsHidden()
.fixedSize()
}
let results = catalog.search(query, in: category)
if results.isEmpty {
Text("No symbols match \u{201C}\(query)\u{201D}.")
.font(.callout)
.foregroundStyle(.secondary)
// Whole rows: a half one clipped by the footer reads as a rendering bug.
.frame(width: Metrics.contentWidth, height: Metrics.gridHeight)
} else {
ScrollView {
// Leading: a fixed-column `LazyVGrid` centres itself in spare width otherwise.
LazyVGrid(columns: icons, alignment: .leading, spacing: Metrics.gap) {
ForEach(results, id: \.self) { symbol in
Button {
onPick(ExtensionAppearance(symbol: symbol, tint: current.tint))
} label: {
SymbolTile(symbol: symbol, tint: current.tint, side: Metrics.tile)
.opacity(symbol == current.symbol ? 1 : 0.55)
.overlay(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.strokeBorder(
.white.opacity(symbol == current.symbol ? 0.9 : 0),
lineWidth: 2)
)
}
.buttonStyle(.plain)
.help(symbol)
}
}
.frame(width: Metrics.contentWidth, alignment: .leading)
.hideNativeScrollers()
}
.overflowFade()
.thinScrollbar()
// The column's width, never the popover's, or the grid overhangs the shared inset.
.frame(width: Metrics.contentWidth, height: Metrics.gridHeight)
}
HStack {
Text(footnote(results.count))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Use Original Icon", action: onReset)
.disabled(!isCustom)
.help("Go back to the icon the extension ships.")
}
}
.padding(Metrics.inset)
.frame(width: Metrics.popoverWidth)
.task {
// Only the fallback has a single category; once the real catalog is in, don't re-read it.
guard catalog.categories.count == 1 else { return }
catalog = await Task.detached(priority: .userInitiated) { SymbolCatalog.load() }.value
}
}
private func footnote(_ count: Int) -> String {
let noun = count == 1 ? "symbol" : "symbols"
return query.isEmpty ? "\(count) \(noun) in \(category.title)" : "\(count) \(noun) matching"
}
}
/// The launcher's icon tile, drawn in SwiftUI so the picker previews exactly what `IconCache` renders.
struct SymbolTile: View {
let symbol: String
let tint: ExtensionTint
let side: CGFloat
/// Marks the system has no symbol for; they draw as templates, so they tint like a symbol.
@ViewBuilder
private var glyph: some View {
if SymbolCatalog.isBundled(symbol) {
Image(symbol)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: side * 0.5, height: side * 0.5)
.foregroundStyle(.white)
} else {
Image(systemName: symbol)
.font(.system(size: side * 0.46, weight: .medium))
.foregroundStyle(.white)
}
}
var body: some View {
RoundedRectangle(cornerRadius: side * 0.23, style: .continuous)
.fill(tint.color)
.frame(width: side, height: side)
.overlay(glyph)
}
}
@@ -0,0 +1,255 @@
import SwiftUI
/// Where searching looks, and what builds what it finds.
///
/// A sheet reached from search — from the Install row's "Registries…" and from inside the search
/// sheet itself — rather than a row of its own in the pane. A registry is not a fourth way to install
/// something; it is the setting that decides what searching can find, and it belongs where that
/// question is asked.
struct ExtensionRegistriesSheet: View {
let onClose: () -> Void
@Environment(AppCore.self) private var core
@State private var addingRegistry = false
private var settings: AppSettings { core.settings }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Registries").font(.title2.weight(.bold))
Text("Where Tinycast looks when you search for an extension to install.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, Theme.Spacing.xxl)
.padding(.top, Theme.Spacing.xxl)
Form {
// Two sections, because the two kinds are not variants of one thing: the store is a
// fixed, prebuilt source you can only switch on or off, and a GitHub registry is
// something you add, that serves source, and that has to be built to run.
Section {
ForEach(storeRegistries) { registry in
registryRow(registry)
}
} header: {
Text("Raycast Store")
} footer: {
Text(
"Prebuilt extensions, through the endpoint the store's own site searches. "
+ "Not an official API, so a GitHub registry is the fallback if it changes."
)
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
if gitHubRegistries.isEmpty {
Text("None yet.")
.foregroundStyle(.secondary)
} else {
ForEach(gitHubRegistries) { registry in
registryRow(registry)
}
}
Button("Add Registry…") { addingRegistry = true }
// This section's business and nothing else's: only a GitHub registry serves
// source, and only source has to be built.
if !gitHubRegistries.isEmpty {
buildingRow
}
} header: {
Text("GitHub Registries")
} footer: {
Text(
"A repository with one folder per extension, laid out like "
+ "raycast/extensions. These serve source, so installing one builds it "
+ "here — dependencies first, with the package manager above. Add a "
+ "registry only if you trust who publishes it."
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
HStack {
Spacer()
Button("Done", action: onClose)
.keyboardShortcut(.defaultAction)
}
.padding(Theme.Spacing.xxl)
}
.frame(width: Theme.Size.editorSheetWidth, height: 560)
.sheet(isPresented: $addingRegistry) {
RegistryEditorSheet(
onAdd: { registry in
settings.extensionRegistries.append(registry)
addingRegistry = false
}, onCancel: { addingRegistry = false })
}
}
private var storeRegistries: [ExtensionRegistry] {
settings.extensionRegistries.filter { $0.kind == .raycastStore }
}
private var gitHubRegistries: [ExtensionRegistry] {
settings.extensionRegistries.filter { $0.kind == .github }
}
private func registryRow(_ registry: ExtensionRegistry) -> some View {
SettingsRow(title: registry.name, subtitle: registry.subtitle) {
registryIcon(registry)
} trailing: {
Toggle("", isOn: binding(for: registry))
.labelsHidden()
.help(registry.isEnabled ? "Searched" : "Not searched")
if !registry.isBuiltIn {
Button {
settings.extensionRegistries.removeAll { $0.id == registry.id }
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
.help("Remove Registry")
.accessibilityLabel("Remove \(registry.name)")
}
}
}
private var buildingRow: some View {
@Bindable var settings = core.settings
return SettingsRow(title: "Package manager", subtitle: packageManagerDetail) {
Image(systemName: "shippingbox")
.foregroundStyle(.secondary)
} trailing: {
Picker("", selection: $settings.extensionPackageManager) {
ForEach(ExtensionPackageManager.allCases) { manager in
Text(manager.title).tag(manager)
}
}
.labelsHidden()
.fixedSize()
}
}
private var packageManagerDetail: String {
let chosen = settings.extensionPackageManager
guard let resolved = chosen.resolve() else {
return chosen == .automatic
? "None found on this Mac. Install pnpm, npm, Yarn or Bun to use a source registry."
: "\(chosen.title) isn't installed on this Mac."
}
return chosen == .automatic
? "Found \(resolved.manager.title) at \(resolved.url.path)."
: "Found at \(resolved.url.path)."
}
@ViewBuilder
private func registryIcon(_ registry: ExtensionRegistry) -> some View {
switch registry.kind {
case .raycastStore:
Image(systemName: "bag")
.foregroundStyle(.secondary)
.frame(width: Theme.Size.settingsRowIcon)
case .github:
// The same mark the About window uses, as a template so it reads as an icon.
Image("BrandGitHub")
.renderingMode(.template)
.resizable()
.scaledToFit()
.foregroundStyle(.secondary)
.frame(width: Theme.Size.settingsRowIcon, height: Theme.Size.settingsRowIcon)
}
}
private func binding(for registry: ExtensionRegistry) -> Binding<Bool> {
Binding(
get: { registry.isEnabled },
set: { isOn in
guard
let index = settings.extensionRegistries.firstIndex(where: {
$0.id == registry.id
})
else { return }
settings.extensionRegistries[index].isEnabled = isOn
})
}
}
/// Adds a GitHub registry from a URL, which is what someone has when they want one.
struct RegistryEditorSheet: View {
let onAdd: (ExtensionRegistry) -> Void
let onCancel: () -> Void
@State private var url = ""
@State private var name = ""
private var parsed: ExtensionRegistry? { ExtensionRegistry.parse(url, name: name) }
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xl) {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Add Registry").font(.title2.weight(.bold))
Text(
"A GitHub repository holding one folder per extension, laid out like "
+ "raycast/extensions."
)
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Repository").font(.callout.weight(.medium))
TextField("", text: $url, prompt: Text("owner/repo, or a link to the folder"))
.textFieldStyle(.roundedBorder)
.pointerStyle(.horizontalText)
}
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Name").font(.callout.weight(.medium))
TextField("", text: $name, prompt: Text(parsed?.name ?? "Optional"))
.textFieldStyle(.roundedBorder)
.pointerStyle(.horizontalText)
}
if let parsed {
Text(
"Will search \(parsed.owner)/\(parsed.repository)/\(parsed.path) at \(parsed.ref)."
)
.font(.caption)
.foregroundStyle(.secondary)
} else if !url.trimmingCharacters(in: .whitespaces).isEmpty {
Text("That doesn't look like a GitHub repository.")
.font(.caption)
.foregroundStyle(.orange)
}
Text(
"Extensions from a repository are source: installing one runs your package manager "
+ "and the extension's own build script on this Mac."
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Cancel", action: onCancel)
.keyboardShortcut(.cancelAction)
Button("Add") {
guard let parsed else { return }
onAdd(parsed)
}
.keyboardShortcut(.defaultAction)
.disabled(parsed == nil)
}
}
.padding(Theme.Spacing.xxl)
.frame(width: Theme.Size.editorSheetWidth)
}
}
@@ -0,0 +1,320 @@
import SwiftUI
/// Search every configured registry and install from any of them, through one list.
///
/// Where a result comes from changes only two things a person can see: the badge on its row, and
/// whether installing it has to build first. Everything else is deliberately identical.
struct ExtensionStoreSheet: View {
let onClose: () -> Void
@Environment(AppCore.self) private var core
@State private var query = ""
@State private var results: [ExtensionListing] = []
@State private var notices: [String] = []
@State private var searching = false
@State private var searched = false
@State private var installing: [String: ExtensionInstaller.Progress] = [:]
@State private var failures: [String: String] = [:]
@State private var installed: Set<String> = []
@State private var searchTask: Task<Void, Never>?
@State private var editingRegistries = false
private var registries: [ExtensionRegistry] { core.settings.extensionRegistries }
/// Which registries this will actually cover, by name — the sheet can't reach the pane's registry
/// settings, so at least it says what they are.
private var searchingSummary: String {
let on = registries.filter(\.isEnabled)
guard !on.isEmpty else {
return "No registries are enabled. Turn one on under Install → Where to search."
}
let names = on.map(\.name).joined(separator: ", ")
return "Searching \(names). Store extensions install as they are; a repository is built first."
}
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.lg) {
header
// The same borderless field the panes use, rather than a bordered capsule of its own.
SettingsFilterField(prompt: "Search extensions…", query: $query)
content
// The list scrolls right up to the footer without it, cutting the last row mid-sentence.
Divider()
footer
}
.padding(Theme.Spacing.xxl)
.frame(width: 620, height: 560)
.onChange(of: query) { _, value in scheduleSearch(value) }
// Re-run against whatever the registries now are, so the results match the header again.
.onChange(of: core.settings.extensionRegistries) { _, _ in scheduleSearch(query) }
.sheet(isPresented: $editingRegistries) {
ExtensionRegistriesSheet(onClose: { editingRegistries = false })
}
.onDisappear { searchTask?.cancel() }
}
private var header: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
// Named for the row that opens it, and it names the registries rather than counting them.
HStack(alignment: .firstTextBaseline) {
Text("Search Extensions").font(.title2.weight(.bold))
Spacer()
// Changing what is searched belongs in the flow, not back out in the pane.
Button("Registries…") { editingRegistries = true }
}
Text(searchingSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var content: some View {
if registries.filter(\.isEnabled).isEmpty {
// Otherwise this is a search field that can only ever find nothing.
noRegistriesState
} else if query.trimmingCharacters(in: .whitespaces).isEmpty {
emptyState
} else if searching && results.isEmpty {
VStack(spacing: Theme.Spacing.md) {
ProgressView()
Text("Searching…").font(.callout).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if results.isEmpty && searched {
placeholder("Nothing matches “\(query)”.")
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(results) { listing in
StoreRow(
listing: listing,
state: state(for: listing),
onInstall: { install(listing) })
Divider().opacity(0.4)
}
}
.hideNativeScrollers()
}
.overflowFade()
.thinScrollbar()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Not a bare line of text in a tall empty sheet: says what to do and what it will search.
private var emptyState: some View {
VStack(spacing: Theme.Spacing.md) {
Image(systemName: "magnifyingglass")
.font(.system(size: 28, weight: .light))
.foregroundStyle(.tertiary)
Text("Search for an extension")
.font(.headline)
Text(
"By name, or by what it does — \u{201C}colour\u{201D}, \u{201C}github\u{201D}, \u{201C}window\u{201D}."
)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Nothing is searchable, so the only useful thing here is the way to fix that.
private var noRegistriesState: some View {
VStack(spacing: Theme.Spacing.md) {
Image(systemName: "tray")
.font(.system(size: 28, weight: .light))
.foregroundStyle(.tertiary)
Text("No registries enabled")
.font(.headline)
Text("Turn one on and this will have somewhere to look.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Registries…") { editingRegistries = true }
.padding(.top, Theme.Spacing.xs)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func placeholder(_ text: String) -> some View {
Text(text)
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var footer: some View {
HStack(alignment: .firstTextBaseline) {
// A registry that failed is worth saying so about — the results are quietly incomplete.
if !notices.isEmpty {
Label(notices.joined(separator: " · "), systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
}
Spacer()
Button("Done", action: onClose)
.keyboardShortcut(.defaultAction)
}
}
// MARK: - State
private func state(for listing: ExtensionListing) -> StoreRow.State {
if installed.contains(listing.name) { return .installed }
if let progress = installing[listing.id] { return .installing(progress.message) }
if let failure = failures[listing.id] { return .failed(failure) }
if core.extensions.installed.contains(where: { $0.manifest.name == listing.name }) {
return .alreadyInstalled
}
return .idle
}
// MARK: - Searching
/// Debounced, because every keystroke is a request to someone else's API — and, for a GitHub
/// registry, a handful of manifest reads against an anonymous rate limit of sixty an hour.
private func scheduleSearch(_ value: String) {
searchTask?.cancel()
let trimmed = value.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else {
results = []
notices = []
searched = false
return
}
searchTask = Task {
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
await search(trimmed)
}
}
private func search(_ trimmed: String) async {
searching = true
defer {
searching = false
searched = true
}
let found = await ExtensionStoreClient().search(trimmed, in: registries)
guard !Task.isCancelled else { return }
// The store's copy wins a tie: it is prebuilt, so installing it needs no toolchain.
var seen = Set<String>()
var merged: [ExtensionListing] = []
for result in found {
for listing in result.listings where seen.insert(listing.name).inserted {
merged.append(listing)
}
}
results = merged
notices = found.compactMap { result in
result.failure.map { "\(result.registry.name): \($0)" }
}
}
// MARK: - Installing
private func install(_ listing: ExtensionListing) {
failures[listing.id] = nil
installing[listing.id] = .downloading
Task {
do {
try await core.extensions.install(
listing: listing,
packageManager: core.settings.extensionPackageManager,
onProgress: { progress in
Task { @MainActor in installing[listing.id] = progress }
})
installed.insert(listing.name)
} catch {
failures[listing.id] = error.localizedDescription
}
installing[listing.id] = nil
}
}
}
/// One search result: what it is, where it came from, and the button that installs it.
private struct StoreRow: View {
enum State: Equatable {
case idle
case installing(String)
case installed
case alreadyInstalled
case failed(String)
}
let listing: ExtensionListing
let state: State
let onInstall: () -> Void
var body: some View {
HStack(alignment: .top, spacing: Theme.Spacing.lg) {
ExtensionIconView(
resolved: listing.iconURL.map { ExtensionImage.Resolved(source: .remote($0)) },
size: 32)
VStack(alignment: .leading, spacing: Theme.Spacing.xxs) {
HStack(spacing: Theme.Spacing.sm) {
Text(listing.title).font(.body.weight(.medium))
if listing.needsBuild {
Text("builds on install")
.font(.caption2)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Theme.Colors.controlSurface, in: .capsule)
.foregroundStyle(.secondary)
.help(
"This registry serves source. Installing runs your package manager "
+ "and the extension's build script.")
}
}
if !listing.summary.isEmpty {
Text(listing.summary)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
}
Text(listing.subtitle)
.font(.caption2)
.foregroundStyle(.tertiary)
if case .failed(let message) = state {
Text(message)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: Theme.Spacing.md)
action
}
.padding(.vertical, Theme.Spacing.md)
}
@ViewBuilder
private var action: some View {
switch state {
case .idle:
Button("Install", action: onInstall)
case .installing(let message):
HStack(spacing: Theme.Spacing.sm) {
ProgressView().controlSize(.small)
Text(message).font(.caption).foregroundStyle(.secondary)
}
.fixedSize()
case .installed:
Label("Installed", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
.labelStyle(.titleAndIcon)
case .alreadyInstalled:
Button("Reinstall", action: onInstall)
.help("Already installed. Reinstalling replaces it with the registry's copy.")
case .failed:
Button("Retry", action: onInstall)
}
}
}
@@ -0,0 +1,844 @@
import SwiftUI
/// Settings › Extensions: the master switch, then a row per extension that expands in place.
struct ExtensionsSettingsView: View {
@Environment(AppCore.self) private var core
@State private var expanded: String?
@State private var filter = ""
@State private var importCandidates: ImportCandidates?
@State private var browsingStore = false
@State private var editingRegistries = false
@State private var error: String?
/// Extensions Raycast has built that aren't here yet, refreshed whenever the pane appears.
@State private var pending: [RaycastImportCandidate] = []
/// What a bulk import is doing, so a thirty-item batch reports rather than going quiet.
@State private var importProgress: (done: Int, total: Int)?
@State private var importSummary: String?
/// What a cleanup would reclaim, rescanned whenever the installed set changes.
@State private var reclaimable = ExtensionCleanup.Report()
var body: some View {
@Bindable var settings = core.settings
return Form {
FeatureSwitchSection(
header: "Extensions",
enableTitle: "Enable extensions",
enableSubtitle:
"Run Raycast extensions natively. A running command holds a JavaScript engine "
+ "in memory until you leave it.",
launcherSubtitle: "List every extension's commands in launcher search.",
// Enabling is also consent to run third-party code, so it uses the confirming setter.
isEnabled: Binding(
get: { settings.extensionsEnabled },
set: { core.extensionCoordinator.setExtensionsEnabled($0) }),
showsInLauncher: $settings.extensionsShowInLauncher)
Group {
compatibility
install
library
}
.settingsEnabled(settings.extensionsEnabled)
// Outside the enabled group: leftovers are on disk whether or not extensions are on.
storage
}
.formStyle(.grouped)
.releasesFocusOnOutsideClick()
// Escape and Return are the keyboard way out of the same field.
.onExitCommand { NSApp.keyWindow?.makeFirstResponder(nil) }
.onSubmit { NSApp.keyWindow?.makeFirstResponder(nil) }
.onChange(of: settings.extensionsShowInLauncher) {
core.extensionCoordinator.applyExtensionsLauncherPresence()
}
// By item, not a flag: `isPresented` builds the sheet from a snapshot taken before the write.
.sheet(item: $importCandidates) { candidates in
ExtensionImportSheet(
candidates: candidates.entries,
onImport: { chosen in
importCandidates = nil
Task { await importAll(chosen) }
},
onCancel: { importCandidates = nil })
}
.sheet(isPresented: $browsingStore) {
ExtensionStoreSheet(onClose: { browsingStore = false })
}
.sheet(isPresented: $editingRegistries) {
ExtensionRegistriesSheet(onClose: { editingRegistries = false })
}
.onReceive(NotificationCenter.default.publisher(for: .tinycastSelectExtension)) { note in
if let name = note.object as? String { expanded = name }
}
.onChange(of: core.extensions.installed.count) { Task { await measureReclaimable() } }
.task {
await core.extensions.refresh()
await measureReclaimable()
await findPending()
}
}
// MARK: - Compatibility
private var compatibility: some View {
Section {
LabeledContent {
EmptyView()
} label: {
Label("What works", systemImage: "checkmark.circle")
Text(
"List, detail, form and grid commands, and ones that just run. Preferences, "
+ "arguments, storage, the clipboard, toasts and HUDs.")
}
LabeledContent {
EmptyView()
} label: {
Label("What doesn't, yet", systemImage: "xmark.circle")
Text(
"Raycast's OAuth sign-in, menu-bar commands, and Raycast's own AI, browser and "
+ "window-management services.")
}
} header: {
Text("Compatibility")
} footer: {
Text("An extension that needs something missing says so when you run it.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - The library
/// `LauncherItemsSection`'s shape, so a long list reads as a list rather than a run of settings.
private var library: some View {
Section {
if core.extensions.installed.isEmpty {
Text("Nothing installed yet — search for one under Install, below.")
.foregroundStyle(.secondary)
} else {
if core.extensions.installed.count > 3 {
SettingsFilterField(prompt: "Filter extensions…", query: $filter)
}
if matching.isEmpty {
Text("No extension matches \u{201C}\(filter)\u{201D}.")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
} else {
// One row holding a lazy stack: a `Form` realizes every row it is handed.
LazyVStack(spacing: 0) {
ForEach(matching) { installed in
if installed.id != matching.first?.id { Divider() }
ExtensionDisclosure(
installed: installed,
isExpanded: expanded == installed.manifest.name,
onToggle: {
expanded =
expanded == installed.manifest.name
? nil : installed.manifest.name
},
onUninstall: {
core.extensionCoordinator.confirmUninstall(installed)
})
}
}
.padding(.vertical, -Self.rowPadding)
}
}
} header: {
Text(
core.extensions.installed.isEmpty
? "Installed" : "Installed (\(core.extensions.installed.count))")
} footer: {
if let error {
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
}
/// A grouped `Form` row's own vertical padding, which the stack above has to give back.
private static let rowPadding: CGFloat = 15
private var matching: [InstalledExtension] {
guard !filter.isEmpty else { return core.extensions.installed }
return core.extensions.installed.filter { entry in
entry.title.localizedCaseInsensitiveContains(filter)
|| entry.manifest.commands.contains {
$0.title.localizedCaseInsensitiveContains(filter)
}
}
}
/// Three rows rather than a menu: a search, a copy and a folder behave differently enough to say so.
private var install: some View {
Section {
SettingsRow(title: "Search extensions", subtitle: searchSubtitle) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
} trailing: {
// Beside search, because this is the setting that decides what search can find.
Button("Registries…") { editingRegistries = true }
Button("Search…") { browsingStore = true }
}
// A state of this row, not a card above the pane: it is the same job as the button beside it.
SettingsRow(title: "Import from Raycast", subtitle: importSubtitle) {
Image(systemName: "arrow.down.doc")
.foregroundStyle(pending.isEmpty ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint))
} trailing: {
if importProgress != nil {
ProgressView().controlSize(.small)
} else {
Button("Import…", action: openImport)
.disabled(!raycastAvailable)
if !pending.isEmpty {
Button("Import All") { Task { await importAll(pending.map(\.installed)) } }
}
}
}
SettingsRow(
title: "Add from folder",
subtitle: "A folder holding package.json and the built command files."
) {
Image(systemName: "folder")
.foregroundStyle(.secondary)
} trailing: {
Button("Choose…", action: addFolder)
}
} header: {
Text("Install")
} footer: {
if let error {
// Under the buttons that caused it: it used to sit beneath the list, far above.
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
}
/// An install cleans up after itself, so in normal use this row has nothing to offer.
private var storage: some View {
Section {
SettingsRow(title: "Leftover files", subtitle: reclaimableSubtitle) {
Image(systemName: "internaldrive")
.foregroundStyle(reclaimable.isEmpty ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint))
} trailing: {
Button("Clean Up…") {
Task {
await core.extensionCoordinator.confirmCleanup(reclaimable)
await measureReclaimable()
}
}
.disabled(reclaimable.isEmpty)
}
} header: {
Text("Storage")
}
}
private var reclaimableSubtitle: String {
guard !reclaimable.isEmpty else { return "Nothing to clean up." }
let items = reclaimable.items == 1 ? "1 item" : "\(reclaimable.items) items"
return "Reclaims \(ExtensionCleanup.formatted(bytes: reclaimable.bytes)) from \(items)."
}
/// Off-main: measuring walks a `node_modules`, which is tens of thousands of files.
private func measureReclaimable() async {
let installed = Set(core.extensions.installed.map(\.manifest.name))
let roots = ExtensionCleanup.defaultRoots()
reclaimable = await Task.detached(priority: .utility) {
ExtensionCleanup.reclaimable(installed: installed, in: roots)
}.value
}
/// Names what searching will cover, so the row says what the Registries button is for.
private var searchSubtitle: String {
let on = core.settings.extensionRegistries.filter(\.isEnabled)
guard !on.isEmpty else { return "No registries enabled — searching would find nothing." }
return "Searching \(on.map(\.name).joined(separator: ", "))."
}
private var importSubtitle: String {
if let importProgress {
return "Importing \(importProgress.done) of \(importProgress.total)…"
}
if let importSummary { return importSummary }
guard raycastAvailable else {
return "No Raycast install found in ~/.config — checked raycast and raycast-x."
}
guard !pending.isEmpty else {
return "Copy what Raycast has already built. No Node or package manager needed."
}
let names = pending.map(\.installed.title)
.sorted { $0.sortKey.localizedCaseInsensitiveCompare($1.sortKey) == .orderedAscending }
.prefix(3)
.joined(separator: ", ")
let more = pending.count > 3 ? " and \(pending.count - 3) more" : ""
return "\(pending.count) not here yet — \(names)\(more)."
}
private var raycastAvailable: Bool {
ExtensionCatalog.raycastExtensionsDirectory() != nil
}
// MARK: - Adding
private func openImport() {
Task {
importCandidates = ImportCandidates(
entries: await core.extensions.raycastImportCandidates())
}
}
private func addFolder() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
panel.prompt = "Add"
guard panel.runModal() == .OK else { return }
Task {
error = nil
for url in panel.urls {
do {
try await core.extensions.install(from: url)
} catch {
self.error = error.localizedDescription
}
}
}
}
private func importAll(_ chosen: [InstalledExtension]) async {
error = nil
importSummary = nil
importProgress = (0, chosen.count)
let failed = await core.extensions.importAllFromRaycast(chosen) { done in
importProgress = (done, chosen.count)
}
importProgress = nil
await findPending()
let imported = chosen.count - failed.count
if failed.isEmpty {
importSummary = "Imported \(imported) extension\(imported == 1 ? "" : "s")."
} else {
importSummary = "Imported \(imported); \(failed.count) failed."
error = "Couldn't import \(failed.joined(separator: ", "))."
}
}
private func findPending() async {
guard core.settings.extensionsEnabled, raycastAvailable else {
pending = []
return
}
pending = await core.extensions.raycastImportCandidates().filter { !$0.isInstalled }
}
}
/// A summary row, and while open its settings on an inset card — separators and fill, never glass.
private struct ExtensionDisclosure: View {
let installed: InstalledExtension
let isExpanded: Bool
let onToggle: () -> Void
let onUninstall: () -> Void
/// A grouped `Form` row's own vertical padding, restored around the summary.
private static let rowPadding: CGFloat = 15
var body: some View {
VStack(alignment: .leading, spacing: 0) {
summary
.padding(.vertical, Self.rowPadding)
if isExpanded {
settings
.padding(.bottom, Theme.Spacing.lg)
}
}
}
private var summary: some View {
SettingsRow(title: installed.title, subtitle: subtitle) {
ExtensionIconView(
resolved: installed.iconPath.map { ExtensionImage.Resolved(source: .file($0)) },
size: Theme.Size.rowIcon)
} trailing: {
Image(systemName: "chevron.down")
.rotationEffect(.degrees(isExpanded ? 180 : 0))
.foregroundStyle(.secondary)
.accessibilityHidden(true)
}
// The whole row toggles: a `DisclosureGroup` would only respond to its chevron.
.contentShape(.rect)
.onTapGesture(perform: onToggle)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(
isExpanded ? "Hide \(installed.title) settings" : "Configure \(installed.title)")
}
/// One `Grid` for every run: separate grids size their columns apart, stranding controls mid-row.
private var settings: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.lg) {
Grid(
alignment: .leading, horizontalSpacing: Theme.Spacing.lg,
verticalSpacing: Theme.Spacing.md
) {
// No heading: these two are one idea, and first so a 19-command extension can't bury them.
ExtensionLauncherRow(installed: installed)
ExtensionIconRow(installed: installed)
if !installed.manifest.preferences.isEmpty {
rule
heading("Preferences")
ForEach(
Array(installed.manifest.preferences.enumerated()), id: \.element.name
) { index, schema in
if index > 0 { rule }
ExtensionPreferenceRow(
extensionName: installed.manifest.name, schema: schema)
}
}
rule
heading(installed.manifest.commands.count == 1 ? "Command" : "Commands")
ForEach(Array(installed.manifest.commands.enumerated()), id: \.element.id) {
index, command in
if index > 0 { rule }
CommandRows(installed: installed, command: command)
}
}
HStack {
Spacer()
Button("Uninstall…", role: .destructive, action: onUninstall)
}
}
// Indented under the row's icon, so the settings read as belonging to the row above them.
.padding(.leading, Theme.Size.rowIcon + Theme.Spacing.lg)
.padding(.bottom, Theme.Spacing.lg)
.frame(maxWidth: .infinity, alignment: .leading)
}
/// A step below the pane's section headers, by size and colour: nothing here sets a heading in caps.
private func heading(_ title: String) -> some View {
GridRow {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(.tertiary)
.gridCellColumns(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, Theme.Spacing.xs)
}
}
/// The hairline every other multi-row group in the app puts between its rows.
private var rule: some View {
GridRow {
Divider()
.gridCellColumns(2)
}
}
private var subtitle: String {
let count = installed.manifest.commands.count
let commands = "\(count) command\(count == 1 ? "" : "s")"
let author = installed.manifest.author
return author.isEmpty ? commands : "\(commands) · \(author)"
}
}
/// One card row: the label left, the control right, columns aligned by the enclosing `Grid`.
private struct SettingsCardRow<Control: View>: View {
/// Wide enough for a path field, and the trailing edge every control in the column shares.
static var controlWidth: CGFloat { 200 }
let title: String
var detail: String?
/// Leading inset for a row that belongs to the row above it, rather than to the run.
var indent: CGFloat = 0
/// A short fact about the row, beside its name rather than in the control column.
var badge: String?
@ViewBuilder var control: Control
var body: some View {
GridRow(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: Theme.Spacing.xxs) {
HStack(spacing: Theme.Spacing.sm) {
Text(title)
if let badge {
Text(badge)
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, Theme.Spacing.xs)
.padding(.vertical, 1)
.background(Theme.Colors.controlSurface, in: .capsule)
}
}
if let detail, !detail.isEmpty {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.leading, indent)
.frame(maxWidth: .infinity, alignment: .leading)
.gridColumnAlignment(.leading)
// One width for every control: left alone a toggle, a pop-up and a field end on three edges.
control
.frame(width: SettingsCardRow.controlWidth, alignment: .trailing)
.gridColumnAlignment(.trailing)
}
}
}
/// One command: its shortcut, then any preferences it declares of its own.
private struct CommandRows: View {
let installed: InstalledExtension
let command: ExtensionCommand
/// A fact about the command, so it sits by the name as a badge rather than a warning colour.
private var badge: String? { command.mode.isSupported ? nil : "Menu Bar" }
var body: some View {
SettingsCardRow(title: command.title, detail: command.description, badge: badge) {
if command.mode.isSupported {
// Per command, not per extension: a shortcut has to land on one thing to run.
ShortcutRecorder(
action: .extensionCommand(
entryID: ExtensionCommandRef(
extensionName: installed.manifest.name, commandName: command.name
).entryID),
isQuiet: true)
}
}
// Indented under its command: at the same inset the association rests on reading order alone.
ForEach(command.preferences, id: \.name) { schema in
ExtensionPreferenceRow(
extensionName: installed.manifest.name, schema: schema, indent: Theme.Spacing.lg)
}
}
}
/// Hides one extension's commands: an import can add hundreds, and the global switch is too blunt.
private struct ExtensionLauncherRow: View {
let installed: InstalledExtension
@Environment(AppCore.self) private var core
private var entryIDs: [String] {
installed.manifest.commands.map {
ExtensionCommandRef(extensionName: installed.manifest.name, commandName: $0.name)
.entryID
}
}
private var isVisible: Bool {
entryIDs.contains { !core.visibility.hiddenItemKeys.contains($0) }
}
var body: some View {
SettingsCardRow(
title: "Show in launcher",
detail: isVisible
? "Its commands appear in launcher search."
: "Hidden from launcher search; shortcuts still work."
) {
// A closure, not `set: setVisible`: an actor-isolated method as a setter crashes IRGen.
Toggle("", isOn: Binding(get: { isVisible }, set: { setVisible($0) }))
.labelsHidden()
}
}
private func setVisible(_ visible: Bool) {
for entryID in entryIDs {
core.visibility.setItemVisible(
visible,
for: AppEntry(
id: entryID, name: "", url: installed.directory, bundleID: nil,
kind: .extensionCommand))
}
}
}
/// The launcher icon, and the picker that replaces it.
private struct ExtensionIconRow: View {
let installed: InstalledExtension
@Environment(AppCore.self) private var core
@State private var picking = false
/// From the store, not the manager: picking publishes there, so preview and popover both observe it.
private var appearance: ExtensionAppearance? {
core.extensions.appearances.appearance(for: installed.manifest.name)
}
var body: some View {
SettingsCardRow(
title: "Launcher icon",
detail: appearance == nil
? "The icon this extension ships." : "Replaced with a Tinycast icon."
) {
HStack(spacing: Theme.Spacing.md) {
preview
Button("Change…") { picking = true }
.popover(isPresented: $picking, arrowEdge: .bottom) {
ExtensionAppearancePicker(
current: appearance ?? .fallback,
isCustom: appearance != nil,
onPick: { core.extensions.setAppearance($0, for: installed.manifest.name) },
onReset: {
core.extensions.setAppearance(nil, for: installed.manifest.name)
})
}
}
}
}
/// Exactly what the launcher row will draw — the shipped image, or the chosen tile.
@ViewBuilder
private var preview: some View {
if let appearance {
SymbolTile(symbol: appearance.symbol, tint: appearance.tint, side: Theme.Size.rowIcon)
} else {
ExtensionIconView(
resolved: installed.iconPath.map { ExtensionImage.Resolved(source: .file($0)) },
size: Theme.Size.rowIcon)
}
}
}
/// One preference control, stored so a command reads it through `getPreferenceValues()`.
private struct ExtensionPreferenceRow: View {
let extensionName: String
let schema: ExtensionPreferenceSchema
var indent: CGFloat = 0
@Environment(AppCore.self) private var core
@State private var text: String = ""
@State private var flag: Bool = false
private var storage: ExtensionStorage { core.extensions.storage }
var body: some View {
SettingsCardRow(title: schema.displayTitle, detail: detail, indent: indent) {
control
}
.onAppear(perform: load)
}
private var detail: String? {
let description = schema.description ?? ""
guard schema.required else { return description }
return description.isEmpty ? "Required." : description + " Required."
}
@ViewBuilder
private var control: some View {
switch schema.kind {
case .checkbox:
Toggle(schema.label ?? "", isOn: $flag)
.labelsHidden()
.onChange(of: flag) { _, value in
storage.setPreference(
extension: extensionName, key: schema.name, value: .bool(value))
}
case .dropdown:
Picker("", selection: $text) {
ForEach(schema.options, id: \.value) { option in
Text(option.title).tag(option.value)
}
}
.labelsHidden()
.onChange(of: text) { _, value in save(value) }
case .password:
SecureField("", text: $text, prompt: schema.placeholder.map(Text.init))
.textFieldStyle(.roundedBorder)
.labelsHidden()
.pointerStyle(.horizontalText)
.onChange(of: text) { _, value in save(value) }
case .file, .directory, .appPicker:
HStack(spacing: Theme.Spacing.sm) {
Text(text.isEmpty ? "Not set" : (text as NSString).lastPathComponent)
.foregroundStyle(text.isEmpty ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.middle)
Button("Choose…", action: choosePath)
}
case .textfield:
TextField("", text: $text, prompt: schema.placeholder.map(Text.init))
.textFieldStyle(.roundedBorder)
.labelsHidden()
.pointerStyle(.horizontalText)
.onChange(of: text) { _, value in save(value) }
}
}
private func load() {
let value =
storage.preference(extension: extensionName, key: schema.name)
?? schema.effectiveDefault
text = value.stringValue
flag = value.boolValue
}
private func save(_ value: String) {
storage.setPreference(extension: extensionName, key: schema.name, value: .string(value))
}
private func choosePath() {
let panel = NSOpenPanel()
panel.canChooseFiles = schema.kind != .directory
panel.canChooseDirectories = schema.kind == .directory
if schema.kind == .appPicker {
panel.directoryURL = URL(fileURLWithPath: "/Applications")
panel.allowedContentTypes = [.application]
}
guard panel.runModal() == .OK, let url = panel.url else { return }
text = url.path
save(url.path)
}
}
/// One candidate from a local Raycast install, and whether we already have it.
struct RaycastImportCandidate: Identifiable {
let installed: InstalledExtension
let isInstalled: Bool
var id: String { installed.id }
}
/// One scan of the local Raycast install, carried as the import sheet's presentation item.
private struct ImportCandidates: Identifiable {
let id = UUID()
let entries: [RaycastImportCandidate]
}
/// What a local Raycast has built. Anything not here starts selected, so the common case is one press.
private struct ExtensionImportSheet: View {
let candidates: [RaycastImportCandidate]
let onImport: ([InstalledExtension]) -> Void
let onCancel: () -> Void
@State private var chosen: Set<String> = []
@State private var seeded = false
@State private var filter = ""
private var fresh: [RaycastImportCandidate] { candidates.filter { !$0.isInstalled } }
/// Thirty-odd rows is past the point where scanning beats filtering — the same field the pane
/// puts above its own list.
private var matching: [RaycastImportCandidate] {
guard !filter.isEmpty else { return candidates }
return candidates.filter {
$0.installed.title.localizedCaseInsensitiveContains(filter)
}
}
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xl) {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Import from Raycast").font(.title2.weight(.bold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if candidates.count > 6 {
SettingsFilterField(prompt: "Filter…", query: $filter)
}
ScrollView {
VStack(spacing: 0) {
ForEach(matching) { candidate in
// The checkbox sits outside the Toggle's label: an AppKit checkbox aligns to
// its label's first baseline, which reads off-centre next to a two-line row.
HStack(spacing: Theme.Spacing.md) {
Toggle("", isOn: binding(for: candidate))
.labelsHidden()
ExtensionIconView(
resolved: candidate.installed.iconPath.map {
ExtensionImage.Resolved(source: .file($0))
}, size: Theme.Size.rowIcon)
VStack(alignment: .leading, spacing: 0) {
Text(candidate.installed.title)
Text(detail(for: candidate))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, Theme.Spacing.xs)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
.onTapGesture { binding(for: candidate).wrappedValue.toggle() }
}
}
.hideNativeScrollers()
}
.overflowFade()
.thinScrollbar()
.frame(minHeight: 220)
HStack {
// Reads against what is actually selected, so it is never a button that does nothing.
Button(allChosen ? "Deselect All" : "Select All") {
chosen = allChosen ? [] : Set(candidates.map(\.installed.manifest.name))
}
.disabled(candidates.isEmpty)
Spacer()
Button("Cancel", action: onCancel)
.keyboardShortcut(.cancelAction)
Button("Import \(chosen.isEmpty ? "" : "(\(chosen.count))")") {
onImport(
candidates.map(\.installed).filter { chosen.contains($0.manifest.name) })
}
.keyboardShortcut(.defaultAction)
.disabled(chosen.isEmpty)
}
}
.padding(Theme.Spacing.xxl)
.frame(width: Theme.Size.editorSheetWidth)
.onAppear {
// Once: re-seeding on every render would fight the user's own deselection.
guard !seeded else { return }
seeded = true
chosen = Set(fresh.map(\.installed.manifest.name))
}
}
private var allChosen: Bool { chosen.count == candidates.count }
private var subtitle: String {
guard !candidates.isEmpty else {
return "No built extensions found in ~/.config/raycast/extensions."
}
guard !fresh.isEmpty else {
return "Everything Raycast has built is already here. Import one again to update it."
}
let count = fresh.count == 1 ? "one" : "\(fresh.count)"
return "The \(count) you don't have yet \(fresh.count == 1 ? "is" : "are") already ticked. "
+ "Ticking one you have updates it."
}
private func detail(for candidate: RaycastImportCandidate) -> String {
let count = candidate.installed.manifest.commands.count
let commands = "\(count) command\(count == 1 ? "" : "s")"
return candidate.isInstalled ? "\(commands) · installed — tick to update" : commands
}
private func binding(for candidate: RaycastImportCandidate) -> Binding<Bool> {
Binding(
get: { chosen.contains(candidate.installed.manifest.name) },
set: { isOn in
if isOn {
chosen.insert(candidate.installed.manifest.name)
} else {
chosen.remove(candidate.installed.manifest.name)
}
})
}
}
extension String {
/// Sorts on the first letter that is one: a name like "(Basic) Bookmarks" otherwise leads every
/// list on the strength of its bracket.
fileprivate var sortKey: String {
String(drop { !$0.isLetter && !$0.isNumber })
}
}
@@ -0,0 +1,104 @@
import SwiftUI
/// The inline argument fields Raycast shows beside the search field when the selected command declares
/// `arguments` — the command's icon, then one compact field per argument, filled before ↵ runs it.
///
/// Focus is deliberately separate from the search field's: the palette's whole focus model hangs off
/// one always-attached `TextField` (see docs/palette.md), so these get their own `FocusState` and Tab
/// hands focus back and forth rather than replacing it.
struct CommandArgumentsRow: View {
let arguments: [ExtensionCommandArgument]
/// The selected command's glyph, drawn as a leading chip so the fields read as belonging to it.
let icon: EntryIcon?
/// Binding factory keyed by argument name — the values live in the palette view's state.
let value: (String) -> Binding<String>
@FocusState.Binding var focused: String?
/// ↵ from inside a field runs the command, like ↵ in the search field.
let onSubmit: () -> Void
var body: some View {
HStack(spacing: Theme.Spacing.xs) {
if let icon {
EntryIconView(source: icon).frame(width: Self.height, height: Self.height)
}
ForEach(arguments, id: \.name) { argument in
ArgumentField(
argument: argument,
text: value(argument.name),
isFocused: focused == argument.name,
onSubmit: onSubmit
)
.focused($focused, equals: argument.name)
}
}
}
static let height: CGFloat = 26
/// What the whole strip occupies, so the header can shrink the search field to exactly the room
/// left over — the fields sit right after the typed text, as they do in Raycast.
static func totalWidth(for arguments: [ExtensionCommandArgument], hasIcon: Bool) -> CGFloat {
let fields = arguments.reduce(0) { $0 + fieldWidth(for: $1) }
let gaps = CGFloat(arguments.count + (hasIcon ? 0 : -1)) * Theme.Spacing.xs
return fields + gaps + (hasIcon ? height : 0)
}
static func fieldWidth(for argument: ExtensionCommandArgument) -> CGFloat {
min(max(CGFloat(argument.placeholder.count) * 7 + 20, 62), 150)
}
/// The order Tab walks: search field (nil) → each argument → back to the search field.
static func next(after current: String?, in arguments: [ExtensionCommandArgument]) -> String? {
guard let current, let index = arguments.firstIndex(where: { $0.name == current }) else {
return arguments.first?.name
}
let following = arguments.index(after: index)
return following < arguments.endIndex ? arguments[following].name : nil
}
}
private struct ArgumentField: View {
let argument: ExtensionCommandArgument
@Binding var text: String
let isFocused: Bool
let onSubmit: () -> Void
@State private var hovered = false
var body: some View {
TextField(
"", text: $text,
prompt: Text(argument.placeholder).foregroundStyle(Theme.Colors.textTertiary)
)
.textFieldStyle(.plain)
.font(Theme.Typography.rowTrailing)
.tint(.white)
.onSubmit(onSubmit)
.multilineTextAlignment(.center)
// Sized to the placeholder so a three-argument command (Hours / Minutes / Seconds) still fits.
.frame(width: CommandArgumentsRow.fieldWidth(for: argument))
.padding(.horizontal, Theme.Spacing.sm)
.frame(height: CommandArgumentsRow.height)
.background(
RoundedRectangle(cornerRadius: Theme.Radius.row, style: .continuous).fill(fill)
)
.overlay(
RoundedRectangle(cornerRadius: Theme.Radius.row, style: .continuous)
.strokeBorder(stroke, lineWidth: 1)
)
.onHover { hovered = $0 }
.help(argument.required ? "\(argument.placeholder) — required" : argument.placeholder)
}
private var fill: Color {
if isFocused { return Theme.Colors.selection }
if hovered { return Theme.Colors.rowHover }
return Color.white.opacity(0.045)
}
/// Focus reads as a brighter edge; an unfilled required argument stays amber until it's answered.
private var stroke: Color {
if isFocused { return Color.white.opacity(0.28) }
if argument.required && text.isEmpty { return Color.orange.opacity(0.45) }
return Color.white.opacity(0.07)
}
}
@@ -0,0 +1,130 @@
import SwiftUI
/// File-scoped so a row and the cap that counts rows read one number, and can't drift into half a row.
private enum Metrics {
static let width: CGFloat = 300
/// The glyph slot plus its breathing room — the tallest thing a row contains.
static let rowHeight: CGFloat = Theme.Size.menuIcon + Theme.Spacing.md * 2
static let rowSpacing: CGFloat = 1
/// Six rows and half of the seventh, so a long panel reads as scrollable rather than clipped.
static let visibleRows: CGFloat = 6.5
static var maxHeight: CGFloat { visibleRows * (rowHeight + rowSpacing) }
}
/// The ⌘K panel of a running command. Not `PopoverMenu`: an extension's panel is long, so it scrolls.
struct ExtensionActionsPanel: View {
var header: String?
let items: [PopoverMenuItem]
@Binding var selection: Int
let onActivate: (Int) -> Void
/// A hovered row is already visible, so scrolling to it would drag the list from under the cursor.
@State private var hoverSelection: Int?
var body: some View {
VStack(alignment: .leading, spacing: 1) {
if let header {
Text(header)
.font(Theme.Typography.sectionHeader)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, Theme.Spacing.lg)
.padding(.top, Theme.Spacing.xs)
.padding(.bottom, Theme.Spacing.xs / 2)
}
// The header stays put while rows move under it, so what the panel belongs to stays read.
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
// Index-as-id is stable: a panel's rows never reorder while it is open.
ForEach(items.indices, id: \.self) { index in
ExtensionActionRow(
item: items[index],
selected: index == selection,
onHover: {
hoverSelection = index
selection = index
},
onActivate: { onActivate(index) }
)
.id(index)
}
}
}
.frame(maxHeight: Metrics.maxHeight)
// Without this a panel shorter than the cap rubber-bands against nothing.
.scrollBounceBehavior(.basedOnSize)
// None, like a real menu: `thinScrollbar` wants floating bars, the native one cuts glass.
.scrollIndicators(.hidden)
.onChange(of: selection) {
let movedByPointer = hoverSelection == selection
hoverSelection = nil
guard !movedByPointer else { return }
// No anchor: reveal the row, never re-centre the list around it.
proxy.scrollTo(selection)
}
}
}
.padding(Theme.Spacing.sm)
.frame(width: Metrics.width)
// Glass carries its own elevation, so a drop shadow on top reads heavy.
.glassEffect(
.regular, in: RoundedRectangle(cornerRadius: Theme.Radius.menuPanel, style: .continuous)
)
}
}
/// Its own row, not the palette's: that one is file-private, and this one may grow its own trimmings.
private struct ExtensionActionRow: View {
let item: PopoverMenuItem
let selected: Bool
/// Fired on enter, so the owner can move selection and share one highlight.
let onHover: () -> Void
let onActivate: () -> Void
var body: some View {
Button(action: onActivate) {
HStack(spacing: Theme.Spacing.sm) {
icon
Text(item.title)
.font(Theme.Typography.menuRow)
.foregroundStyle(item.isDestructive ? Color.red : Color.primary)
.lineLimit(1)
Spacer(minLength: Theme.Spacing.sm)
if let shortcut = item.shortcut {
HStack(spacing: Theme.Spacing.xxs) {
ForEach(Array(shortcut.enumerated()), id: \.offset) { _, glyph in
KeyCapChip(text: String(glyph), style: .outline)
}
}
}
}
.padding(.horizontal, Theme.Spacing.md)
// Fixed, not padded: the cap above counts rows, so a row has to be one known height.
.frame(maxWidth: .infinity, minHeight: Metrics.rowHeight, alignment: .leading)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: Theme.Radius.menuRow, style: .continuous)
.fill(selected ? Theme.Colors.menuHover : Color.clear)
)
}
.buttonStyle(.plain)
.onHover { if $0 { onHover() } }
}
@ViewBuilder
private var icon: some View {
switch item.icon {
case .symbol(let name):
Image(systemName: name)
.font(Theme.Typography.menuIcon)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(item.isDestructive ? Color.red : Color.secondary)
.frame(width: Theme.Size.menuIcon, height: Theme.Size.menuIcon)
case .file(let path):
ExtensionIconView(
resolved: ExtensionImage.Resolved(source: .file(path)), size: Theme.Size.menuIcon)
}
}
}
@@ -0,0 +1,38 @@
import AppKit
import SwiftUI
/// Plays a multi-frame `NSImage`. `NSImageView` owns the frame timer; SwiftUI has no equivalent.
struct AnimatedImageView: NSViewRepresentable {
let image: NSImage
func makeNSView(context: Context) -> NSImageView {
let view = NSImageView()
view.imageScaling = .scaleProportionallyUpOrDown
view.animates = true
// Neither axis may drive intrinsic size: a large GIF would otherwise widen the row it sits in.
view.setContentHuggingPriority(.defaultLow, for: .horizontal)
view.setContentHuggingPriority(.defaultLow, for: .vertical)
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
view.image = image
return view
}
func updateNSView(_ view: NSImageView, context: Context) {
guard view.image !== image else { return }
view.image = image
view.animates = true
}
}
extension NSImage {
/// True when a representation carries more than one frame — the only reason to pay for a timer.
var isAnimated: Bool {
representations.contains(where: { rep in
guard let bitmap = rep as? NSBitmapImageRep,
let frames = bitmap.value(forProperty: .frameCount) as? NSNumber
else { return false }
return frames.intValue > 1
})
}
}
@@ -0,0 +1,30 @@
import SwiftUI
/// The inline argument strip, as a `PaletteHeaderAccessory` the palette renders without reading.
@MainActor
enum ExtensionArgumentsAccessory {
/// Nil when the row declares no arguments, which is every row but an extension command's.
static func make(
entry: AppEntry?,
coordinator: ExtensionCoordinator,
values: @escaping (String) -> Binding<String>,
focus: FocusState<String?>.Binding,
onSubmit: @escaping () -> Void
) -> PaletteHeaderAccessory? {
guard let entry, let arguments = coordinator.commandArguments(for: entry),
!arguments.isEmpty
else { return nil }
let icon = entry.iconSource
return PaletteHeaderAccessory(
width: CommandArgumentsRow.totalWidth(for: arguments, hasIcon: true),
fieldNames: arguments.map(\.name),
firstIncompleteField: arguments.first {
$0.required && values($0.name).wrappedValue.isEmpty
}?.name,
view: AnyView(
CommandArgumentsRow(
arguments: arguments, icon: icon, value: values, focused: focus,
onSubmit: onSubmit)))
}
}

Some files were not shown because too many files have changed in this diff Show More