mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
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:
@@ -50,6 +50,9 @@ Carthage/Build/
|
||||
# Tuist (if ever added)
|
||||
Tuist/.build/
|
||||
|
||||
# Extension runtime toolchain (Scripts/raycast-runtime)
|
||||
node_modules/
|
||||
|
||||
# Temporary files
|
||||
*.swp
|
||||
*.tmp
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)" : ""}`);
|
||||
@@ -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();
|
||||
@@ -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(", "));
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1272
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",
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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)}`;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t)
|
||||
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
Reference in New Issue
Block a user