chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
node_modules/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 yetone
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,207 @@
|
||||
<div align="center">
|
||||
|
||||
# native-feel.skill
|
||||
|
||||
> *"Cross-platform development AND near-native performance — refuse the trade-off."*
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/yetone/native-feel-skill)
|
||||
|
||||
<br>
|
||||
|
||||
**An Agent Skill for designing cross-platform desktop apps that feel native** — distilled from Raycast's 2.0 technical deep-dive and grounded in reverse-engineering of the shipping `Raycast Beta.app` binary.
|
||||
|
||||
Two goals usually pull against each other: convenient cross-platform development, and near-native performance. This skill captures the structural choices — eight architectural tenets, a four-layer architecture, a WebKit/WebView2 survival guide, a 75-item ship audit — that let an app have both.
|
||||
|
||||
<br>
|
||||
|
||||
**Install** — pick one:
|
||||
|
||||
</div>
|
||||
|
||||
**A. With [`skills`](https://github.com/vercel-labs/skills) (any compatible agent):**
|
||||
|
||||
```bash
|
||||
npx skills add yetone/native-feel-skill -g
|
||||
```
|
||||
|
||||
The `-g` flag installs globally (user-level) so every project picks it up.
|
||||
|
||||
**B. Or paste this prompt to your AI agent:**
|
||||
|
||||
```
|
||||
Install the native-feel skill for me:
|
||||
|
||||
1. Clone https://github.com/yetone/native-feel-skill into your
|
||||
user-level skills directory as `native-feel-cross-platform-desktop/`
|
||||
(whatever path your agent reads skills from on this machine — e.g.
|
||||
`~/.claude/skills/` for Claude Code).
|
||||
2. Verify that SKILL.md plus the references/ and checklists/ subdirs
|
||||
are present.
|
||||
3. Confirm the install path when done.
|
||||
```
|
||||
|
||||
<div align="center">
|
||||
|
||||
[Use cases](#use-cases) · [What this is](#what-this-is) · [Philosophy](#philosophy) · [Layout](#layout) · [Sources](#sources)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Use cases
|
||||
|
||||
Once installed, the skill activates automatically whenever the conversation touches cross-platform desktop architecture, WebView quirks, or making a UI feel native. Two common ways to use it:
|
||||
|
||||
### Case 1 — Refactor an existing app to feel more native
|
||||
|
||||
You have an Electron / Tauri / web-wrapped app, and a designer (or your own eye) tells you it feels web-y. You want to know where to start and how far you can take it without a full rewrite.
|
||||
|
||||
```
|
||||
You ❯ My Electron app feels web-y on macOS — the designer says it
|
||||
doesn't pass for a real Mac app. Where do I start?
|
||||
|
||||
Agent ❯ Quickest path is the 75-item native-feel audit
|
||||
(checklists/ship-readiness.md). Most Electron apps fail
|
||||
the same 6–8 items:
|
||||
- cursor:pointer on rows (item 21)
|
||||
- web-style modal overlays instead of native sheets (19)
|
||||
- hardcoded brand accent instead of system accent (33)
|
||||
- page fade-transitions (40)
|
||||
- opaque window background instead of platform material (31)
|
||||
- WebKit context menu still firing (23)
|
||||
Each is a 5-to-30-minute fix; together they move you most
|
||||
of the way to "passes the 30-second skeptical user test."
|
||||
|
||||
Bigger question first: how far do you need to go? If "good
|
||||
enough" means passing that 30-second test, Electron + this
|
||||
audit gets you 80% there without rewriting. If you want
|
||||
"indistinguishable from a Mac app" — Liquid Glass materials,
|
||||
native popovers, sub-200ms launch — Electron has a ceiling
|
||||
you'll hit. Want me to run the decision tree first?
|
||||
```
|
||||
|
||||
### Case 2 — Build a cross-platform native-feel app from scratch
|
||||
|
||||
Greenfield project, must run on macOS + Windows, and the native feel is the *point* — a launcher, a productivity tool, an AI workspace your users live in all day.
|
||||
|
||||
```
|
||||
You ❯ Starting a new desktop app — macOS + Windows, native feel
|
||||
is the whole pitch. What architecture should I use?
|
||||
|
||||
Agent ❯ Before architecture, the decision tree
|
||||
(checklists/decision-tree.md) rules this stack out for
|
||||
several common shapes — quick check first:
|
||||
- Cold-start budget? (<100ms → build native instead)
|
||||
- Memory floor? (<150MB → build native instead)
|
||||
- Extension ecosystem? (yes → need the Node layer)
|
||||
- Runway? (<3mo → start on Electron)
|
||||
|
||||
If you pass, the four-layer architecture is:
|
||||
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Native shell Swift/AppKit + C#/WPF │
|
||||
│ ↕ one IPC schema, codegen for all sides │
|
||||
│ System WebView WKWebView + WebView2 │
|
||||
│ running React + TypeScript, shared 1:1 │
|
||||
│ ↕ │
|
||||
│ Node backend single long-lived process │
|
||||
│ ↕ │
|
||||
│ Rust core UniFFI-bridged, sharable │
|
||||
│ with iOS and server │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
The expensive-to-get-wrong decisions, in order:
|
||||
1. The IPC contract (one schema, codegen). Get this wrong
|
||||
and you rebuild it later under duress.
|
||||
2. WebView survival flags (occlusion off, alpha-prewarm,
|
||||
_doAfterNextPresentationUpdate, font prewarming).
|
||||
3. Memory hygiene (lazy-load extensions, tear down
|
||||
secondary windows aggressively).
|
||||
|
||||
Want to start with the seam — where to draw the cross-
|
||||
platform line — or with the IPC contract, since getting
|
||||
that schema wrong means rebuilding every bridge later?
|
||||
```
|
||||
|
||||
### Other things the skill is good for
|
||||
|
||||
- **"Why is my WKWebView flickering when I hide and re-show it?"** → walks you through `references/03-webview-survival.md` (most likely A.1 throttling or A.2 startup flicker).
|
||||
- **"How should typed IPC work across Rust, Swift, and TypeScript?"** → the UniFFI-based pattern in `references/04-ipc-contract.md`, with the exact `Coordinator`/`EventHandler` shape Raycast Beta ships.
|
||||
- **"My app is at 450 MB resident, is that bad?"** → the six common Activity-Monitor mistakes and what to actually measure, in `references/05-memory-truths.md`.
|
||||
- **"Is my designer's spec ‘native enough’?"** → the 70+ item conventions audit in `references/06-native-conventions.md`.
|
||||
|
||||
---
|
||||
|
||||
> *"We're not a web app with some native hooks sprinkled on top. We're a native app that uses web for its UI."* — Raycast
|
||||
|
||||
## What this is
|
||||
|
||||
A reference for architects, tech leads, and engineers who must build a desktop app that:
|
||||
|
||||
- runs on **macOS + Windows** (optionally Linux) from a single UI codebase,
|
||||
- launches in under 500 ms and stays under 500 MB resident,
|
||||
- is **indistinguishable from a native app** to its users (no `cursor: pointer` tell-tales, no white-flash on launch, no WebKit context menu, no smooth-scroll JS),
|
||||
- supports a **plugin/extension ecosystem** in TypeScript,
|
||||
- can share performance-critical code with iOS and a server backend.
|
||||
|
||||
This is the four-layer architecture: **native shell → system WebView (WKWebView/WebView2) → Node backend → Rust core**, wired together with a single typed IPC schema that generates clients for every runtime.
|
||||
|
||||
## What this is not
|
||||
|
||||
- Not for single-OS apps (just build native).
|
||||
- Not for Electron-style "good enough" apps (the polish budget here is 5–10× higher).
|
||||
- Not for apps with strict <150 MB or <100 ms cold-start budgets (the floor is real).
|
||||
- Not for games, document editors, or media players.
|
||||
|
||||
Run [`checklists/decision-tree.md`](checklists/decision-tree.md) to find out if this architecture is even right for your project. It rules itself out for several common cases — saying so directly is more useful than over-fitting advice.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
native-feel-skill/
|
||||
├── SKILL.md # entry point for the agent
|
||||
├── references/
|
||||
│ ├── 01-philosophy.md # 8 tenets that drive every decision
|
||||
│ ├── 02-architecture.md # the four-layer architecture
|
||||
│ ├── 03-webview-survival.md # WebKit/WebView2 quirks + fixes (the goldmine)
|
||||
│ ├── 04-ipc-contract.md # typed IPC across Rust/Swift/C#/TS
|
||||
│ ├── 05-memory-truths.md # why Activity Monitor lies
|
||||
│ ├── 06-native-conventions.md # 70+ items the native-feel audit checks
|
||||
│ └── 07-evidence-raycast.md # what a reverse-eng. of Raycast Beta shows
|
||||
└── checklists/
|
||||
├── decision-tree.md # should you use this architecture?
|
||||
└── ship-readiness.md # 75-item launch audit
|
||||
```
|
||||
|
||||
## Philosophy
|
||||
|
||||
The central tension this architecture resolves: **how can a desktop app deliver convenient cross-platform development AND near-native performance, when those goals usually pull against each other?** Eight tenets name the structural moves:
|
||||
|
||||
1. **Place the seam at the rendering surface** — share above the WebView, diverge below it; this is the only altitude where both DX and native feel survive.
|
||||
2. **One schema, many languages** — pay the polyglot tax once at the declaration, never at the call site.
|
||||
3. **Adopt the platform; don't compete with it** — the OS draws blur, scrolling, materials, and dark mode better than you can.
|
||||
4. **Performance is a property of perception** — what the user feels, not what Activity Monitor reports.
|
||||
5. **The short iteration loop is the product** — 200 ms hot reload vs 30 s native rebuild is a 150× compounding advantage.
|
||||
6. **Cross boundaries intentionally** — IPC has a cost; design every crossing as async, batched, schema-typed.
|
||||
7. **Identity is muscle memory** — the hotkey, the rank order, the verbs are the app; everything else is implementation.
|
||||
8. **Separate baseline from margin** — the WebView+Node floor is rented; only your dirty pages are yours to optimize.
|
||||
|
||||
Read [`references/01-philosophy.md`](references/01-philosophy.md) first. Everything else is consequence.
|
||||
|
||||
## About Agent Skills
|
||||
|
||||
Agent Skills are the emerging standard for packaging domain knowledge that any compatible agent (Claude Code, the Claude Agent SDK, or other Agent-Skill-aware runtimes) can discover and load. Once installed via the prompt at the top of this README, the skill activates automatically when the agent's conversation touches cross-platform desktop architecture, WebView quirks, or Raycast-style apps — the trigger conditions are declared in `SKILL.md`'s frontmatter.
|
||||
|
||||
## Sources
|
||||
|
||||
- Raycast's public technical post: [A Technical Deep Dive into the New Raycast](https://www.raycast.com/blog/a-technical-deep-dive-into-the-new-raycast)
|
||||
- Reverse engineering of `Raycast Beta.app` v0.60.0 (macOS 26+ build, Xcode 17, arm64) — see [`references/07-evidence-raycast.md`](references/07-evidence-raycast.md) for what was found and how.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [`LICENSE`](LICENSE).
|
||||
|
||||
## Credits
|
||||
|
||||
Authored as an Agent Skill. The architecture this skill describes is Raycast's; the philosophy is the author's synthesis; the evidence is from the shipping app.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`yetone/native-feel-skill`
|
||||
- 原始仓库:https://github.com/yetone/native-feel-skill
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: native-feel-cross-platform-desktop
|
||||
description: Use when the user is designing, prototyping, or rewriting a desktop app that must run on multiple OSes (macOS + Windows, optionally Linux) AND feel indistinguishable from a native app to its users — fast launch, native windowing, native input handling, native materials. Trigger words include "cross-platform desktop", "Electron alternative", "Tauri vs native", "WebView wrapper", "near-native performance", "Raycast architecture", "WebKit/WebView2 quirks", "WKWebView", "system tray app", "global hotkey app", "launcher app". Do NOT trigger this skill for pure web apps, pure mobile apps, or for greenfield projects that have no native-feel requirement.
|
||||
---
|
||||
|
||||
# Native-Feel Cross-Platform Desktop
|
||||
|
||||
You are advising on the architecture of a cross-platform desktop app that must *feel native*. This skill captures the philosophy, architecture, and concrete pitfalls — distilled from Raycast's public technical deep-dive on their 2.0 rewrite and verified by reverse-engineering the shipping `Raycast Beta.app` binary on macOS.
|
||||
|
||||
## How to use this skill
|
||||
|
||||
1. **Start with the philosophy in `references/01-philosophy.md`.** It frames the central tension this architecture resolves — *how to get cross-platform DX and near-native performance at the same time* — and gives you eight tenets that name the structural moves. Every concrete decision later flows from one of those tenets. If the user is making a decision that contradicts a tenet, surface the tenet by number and short name and explain the trade-off.
|
||||
2. **Match the user's question to a reference file.** Don't dump the whole skill — load only what's needed:
|
||||
- Architecture / "which layers should I have?" → `references/02-architecture.md`
|
||||
- "Why does my WebView flicker / stutter / freeze when hidden?" → `references/03-webview-survival.md` (the highest-density file — every item is a real bug with a real fix)
|
||||
- "How do I type my IPC across Rust/Swift/C#/TS?" → `references/04-ipc-contract.md`
|
||||
- "Why does Activity Monitor say 400 MB?" → `references/05-memory-truths.md`
|
||||
- "How do I make it not feel like a webpage?" → `references/06-native-conventions.md`
|
||||
- "What does Raycast actually ship?" (concrete evidence) → `references/07-evidence-raycast.md`
|
||||
3. **Before recommending an architecture, run the decision tree in `checklists/decision-tree.md`.** It rules this stack OUT for several common project shapes — say so directly.
|
||||
4. **Before the user claims their app "feels native", run `checklists/ship-readiness.md`.** It's a 30-item audit; most apps fail 5–10 items on first pass.
|
||||
|
||||
## The one-paragraph version
|
||||
|
||||
A native-feel cross-platform desktop app is **not a web app with native hooks** and **not Electron with a custom theme**. It is a **native shell** (Swift/AppKit on macOS, C#/WPF on Windows) that owns the window, the hotkeys, the menu bar, the materials, and the lifecycle — and embeds the system WebView (WKWebView or WebView2) purely as a *rendering surface* for a shared React/TypeScript UI. Business logic lives in a long-lived Node process bundled with the app. Performance-critical subsystems (file indexing, calculation, crypto) live in Rust, shared across platforms and exposed through UniFFI-generated typed bindings. Four runtimes communicate through a single declared interface that generates typed clients for each side. The whole thing fits in ~400 MB resident memory, of which ~150 MB is the inescapable WebView+Node baseline. You pay that baseline so that one React codebase serves both OSes; you earn it back through hot-reload iteration speed and a shared extension API that already runs thousands of community plugins on both platforms.
|
||||
|
||||
## Core anti-patterns to call out immediately
|
||||
|
||||
When you see the user doing any of these, stop and ask:
|
||||
|
||||
- **"Let's just use Electron and theme it"** → Electron abstracts away the system WebView, window class, and material APIs you need for native feel. You cannot get Liquid Glass / acrylic / true vibrancy through Electron's abstraction without forking it. Recommend `references/02-architecture.md` instead.
|
||||
- **"Let's use Tauri — it's like Electron but lighter"** → Tauri ships its own WebView wrapper and abstracts platform APIs. Same control-loss problem as Electron, plus less mature. Acceptable for utilities; not for apps where every window animation has to match the OS.
|
||||
- **"Let's render UI in Swift/C# and share business logic"** → You will maintain two UI codebases forever. Every feature ships twice. Designers maintain two specs. Recommend WebView-as-renderer instead.
|
||||
- **"WebKit is throttling us; let's spin our own polling loop"** → No. The throttling is solvable with two specific `WKWebView` configuration flags. See `references/03-webview-survival.md` § "Hidden window throttling".
|
||||
- **"Memory is bad — we're at 400 MB"** → Probably wrong measurement. Activity Monitor double-counts shared frameworks and treats compressed pages as resident. See `references/05-memory-truths.md` before optimizing anything.
|
||||
- **"Let's hand-write the IPC types in each language"** → They will drift within a sprint. Use UniFFI (for Rust ↔ Swift/Kotlin/C#) or hand-roll a single IDL that generates clients. See `references/04-ipc-contract.md`.
|
||||
- **"Adding `cursor: pointer` to make it feel responsive"** → That's exactly what makes it feel *web*. Native UIs do not change the cursor on hoverable rows. See `references/06-native-conventions.md`.
|
||||
|
||||
## Output style
|
||||
|
||||
When advising:
|
||||
- Quote the specific tenet from `references/01-philosophy.md` that applies (e.g., *"T3 — adopt the platform; don't compete with it: the OS draws blur better than you can"*).
|
||||
- Cite the file and section, not the whole skill.
|
||||
- For each recommendation, name what the user is **giving up** in exchange. There are no free wins in this architecture — the whole skill is about deliberate trade-offs.
|
||||
- If you're unsure whether the user's project should even use this architecture, run them through `checklists/decision-tree.md` before giving advice. It is okay to conclude "this skill doesn't apply — build a normal Electron app."
|
||||
@@ -0,0 +1,135 @@
|
||||
# Decision Tree: Should You Build This Architecture?
|
||||
|
||||
Run the user's project through this tree *before* recommending the four-layer architecture. It excludes the architecture for many common project shapes, and saying so directly is more useful than over-fitting advice.
|
||||
|
||||
---
|
||||
|
||||
## Question 1: How many OS targets?
|
||||
|
||||
- **Just one (macOS only, or Windows only):**
|
||||
→ **Don't use this architecture.** Build native (Swift/AppKit, or C#/WinUI/WPF). The cross-platform tax is not worth it. The whole point of this stack is to share a UI codebase across OSes; with one OS, you have nothing to share.
|
||||
|
||||
- **Two (macOS + Windows):**
|
||||
→ Proceed.
|
||||
|
||||
- **Three (+ Linux):**
|
||||
→ Proceed, but warn: this skill is grounded in macOS+Windows evidence. Linux + WebKitGTK works but has its own quirks not documented here. WebView2 doesn't exist on Linux; you'll likely use WebKitGTK or fall back to bundling. Budget for it.
|
||||
|
||||
- **Mobile too (iOS / Android):**
|
||||
→ Proceed for desktop, *and* extract the Rust core to share with mobile (this is exactly the Layer-4-sharing benefit). Don't try to share the React UI with mobile via React Native; that's a different stack with different trade-offs.
|
||||
|
||||
---
|
||||
|
||||
## Question 2: Is "native feel" actually a hard requirement?
|
||||
|
||||
- **Yes, the app must be indistinguishable from native** (e.g., a launcher, a system utility, a productivity tool the user lives in all day):
|
||||
→ Proceed.
|
||||
|
||||
- **"It should be nice but Electron is fine":**
|
||||
→ **Don't use this architecture.** Use Electron. The polish budget here is 5–10× higher than Electron, and you only earn that back if native-feel is a competitive differentiator. If "nice" is sufficient, the cheapest path is Electron + a good designer.
|
||||
|
||||
- **No, it's an internal tool, no end-user polish needed:**
|
||||
→ **Don't use this architecture.** Web app in a browser tab. Or Electron. Or a Mac app for the one OS your team uses.
|
||||
|
||||
---
|
||||
|
||||
## Question 3: Do you have a plugin/extension ecosystem?
|
||||
|
||||
- **Yes, third parties will build extensions:**
|
||||
→ JS/TS plugins are the only practical choice (1000× more authors than native plugins). You need Layer 3 (Node backend). Proceed.
|
||||
|
||||
- **No, but the app has very rich business logic / network code:**
|
||||
→ Layer 3 (Node) is still likely correct, because the alternative is duplicating business logic in Swift and C#. Proceed.
|
||||
|
||||
- **No, and the app is mostly UI + a small amount of native work:**
|
||||
→ Consider skipping Layer 3. A native shell + WebView + small Rust core may be enough. Lighter, simpler.
|
||||
|
||||
---
|
||||
|
||||
## Question 4: How tight is your launch budget?
|
||||
|
||||
- **Cold start must be < 100 ms:**
|
||||
→ **Don't use this architecture.** The WebView + Node boot floor is ~150–300 ms even with prewarming. Build native.
|
||||
|
||||
- **Cold start < 500 ms acceptable, warm start < 50 ms required:**
|
||||
→ Proceed. Prewarm a hidden launcher window at app start. Warm activation is essentially free.
|
||||
|
||||
- **Cold start tolerance is generous (< 2s):**
|
||||
→ Proceed comfortably.
|
||||
|
||||
---
|
||||
|
||||
## Question 5: What's your memory budget?
|
||||
|
||||
- **Under 150 MB resident:**
|
||||
→ **Don't use this architecture.** The floor is ~150 MB on Windows for an empty WebView + Node. Build native.
|
||||
|
||||
- **300–800 MB acceptable:**
|
||||
→ Proceed.
|
||||
|
||||
- **1 GB+ fine (e.g., a heavy AI app):**
|
||||
→ Proceed, but consider memory hygiene from day one — see `references/05-memory-truths.md`. Going over 1 GB sustained will cost you reviews from users who don't understand memory accounting.
|
||||
|
||||
---
|
||||
|
||||
## Question 6: How experienced is your team?
|
||||
|
||||
- **Strong in one of Swift OR C#, plus React/TS:**
|
||||
→ Proceed. You'll need to hire/learn the other native side, but the centroid of work is in TS where the team is fluent. This is a near-ideal fit.
|
||||
|
||||
- **Strong in React/TS, no native experience:**
|
||||
→ Proceed with caution. The two native shells (Layers 1 + 1 again) are real work. Budget 2–3 months per shell for an experienced TS engineer to get competent. Or hire one native specialist per OS.
|
||||
|
||||
- **Strong in native (Swift or C#), no React experience:**
|
||||
→ Proceed with caution. React/TS is easier to ramp on than the reverse, but design-system maturity in React takes longer than people expect. Budget 3 months for the UI baseline before shipping.
|
||||
|
||||
- **Two-person team, no Rust experience:**
|
||||
→ Skip Layer 4 initially. Start with shell + WebView + Node. Add Rust only when a specific need arises (file indexer, crypto, etc.). UniFFI is great but ramping on Rust + UniFFI + cross-toolchain builds *while shipping* is a lot.
|
||||
|
||||
---
|
||||
|
||||
## Question 7: How long is the runway?
|
||||
|
||||
- **Short (need to ship in < 3 months):**
|
||||
→ **Don't use this architecture.** Use Electron or Tauri. You can rewrite to native-feel later if the product takes off. (Raycast itself started as a pure Mac native app — they rewrote *after* they had product-market fit.)
|
||||
|
||||
- **Medium (6–12 months):**
|
||||
→ Proceed if you have native experience on the team. Skip Layer 4 to start.
|
||||
|
||||
- **Long (12+ months to v1):**
|
||||
→ Proceed comfortably. All four layers.
|
||||
|
||||
---
|
||||
|
||||
## Decision matrix
|
||||
|
||||
After all seven questions, score yourself:
|
||||
|
||||
| Score | Recommendation |
|
||||
|---|---|
|
||||
| All "Proceed" | Four-layer architecture. Read `references/02-architecture.md`. |
|
||||
| One or two "Skip Layer X" | Three-layer architecture (omit the skipped layer). Read `references/02-architecture.md` and note the per-layer rationale to confirm what you're losing. |
|
||||
| Any "Don't use this architecture" | Use the alternative named in that question (native / Electron / different stack). This skill doesn't apply. |
|
||||
| "Proceed with caution" anywhere | Proceed but front-load risk: prototype the riskiest layer first (e.g., the native shell + WebView wiring), not the easiest. |
|
||||
|
||||
---
|
||||
|
||||
## Common false positives (apps that *seem* like a fit but aren't)
|
||||
|
||||
- **A "fast" web app** — i.e., a SaaS dashboard the team wants to "make feel native." Almost always: just ship it as a web app. Adding a native shell for "feel" rarely pays back the engineering cost.
|
||||
- **A small utility** — e.g., a clipboard manager. Probably wants pure native; the cross-platform value isn't there at this size.
|
||||
- **A game or graphics-heavy app** — use a game engine or native graphics framework, not a WebView.
|
||||
- **A document editor** — depends. Notion/Obsidian-class? Maybe. Microsoft Word-class? Native.
|
||||
- **A media player** — native, because media frameworks (AVFoundation, Media Foundation) are platform-specific anyway.
|
||||
|
||||
---
|
||||
|
||||
## When this architecture is *clearly* right
|
||||
|
||||
- A productivity launcher (Raycast, Alfred-style).
|
||||
- A note-taking app with rich extensions (Obsidian-class).
|
||||
- A team communication app with deep OS integration (Slack-class, *if* willing to invest more than Slack did).
|
||||
- A developer tool that needs to embed editors, terminals, and rich panels (Linear desktop, Warp-ish).
|
||||
- An AI app where the UI needs to render rich markdown/code and the backend needs to manage long-lived AI sessions across windows.
|
||||
|
||||
If the user's project resembles one of these and they passed Q1–Q7, recommend with confidence.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Ship-Readiness Audit
|
||||
|
||||
Before claiming the app "feels native," walk through this list. Most apps fail 5–10 items on first pass. Each failure costs cheap-to-fix engineering, but compounds.
|
||||
|
||||
Score: green ✓ if observably correct, red ✗ if not, gray ◯ if N/A.
|
||||
|
||||
---
|
||||
|
||||
## A. Cold launch (10 items)
|
||||
|
||||
1. ◯ App launches from hotkey to visible window in < 200 ms (warm) / < 600 ms (cold).
|
||||
2. ◯ No white/black flash before content appears. (`_doAfterNextPresentationUpdate` on Mac, `DefaultBackgroundColor` + `NavigationCompleted` on Win.)
|
||||
3. ◯ Window appears at the correct screen and position (last position remembered, multi-monitor aware).
|
||||
4. ◯ Initial focus is in the most likely input control. User can type immediately.
|
||||
5. ◯ No "loading…" placeholder visible to the user during normal launch.
|
||||
6. ◯ Dock/Taskbar icon shows correct app icon, not a generic Electron-style icon.
|
||||
7. ◯ Menu bar / system tray icon is monochrome (Mac convention) or follows system theme.
|
||||
8. ◯ ⌘-Space / global hotkey works on first launch without re-registration.
|
||||
9. ◯ App registers with Login Items (Mac) / Startup (Win) on user opt-in, not silently.
|
||||
10. ◯ Quitting the app actually quits — no hidden zombie processes.
|
||||
|
||||
## B. Window & focus (10 items)
|
||||
|
||||
11. ◯ ⌘W (Mac) / Ctrl-W (Win) closes the window.
|
||||
12. ◯ ⌘M (Mac) / Win-Down (Win) minimizes.
|
||||
13. ◯ Green button (Mac) zooms to content size, doesn't fullscreen unless Option held.
|
||||
14. ◯ Clicking outside the launcher window dismisses it (if that's the design).
|
||||
15. ◯ Window remembers size and position across launches.
|
||||
16. ◯ Multi-monitor: window opens on the active screen, not always screen 0.
|
||||
17. ◯ Fullscreen mode (if applicable) uses native fullscreen, not a maximized window.
|
||||
18. ◯ Settings opens in a separate native window (⌘, on Mac, Ctrl-, on Win).
|
||||
19. ◯ No modal "dialogs" implemented as DOM overlays. Use NSAlert / MessageBox or native sheets.
|
||||
20. ◯ When the app loses focus, the launcher hides (or follows the configured behavior — but predictably).
|
||||
|
||||
## C. Input & cursor (10 items)
|
||||
|
||||
21. ◯ No `cursor: pointer` on rows, buttons, or tabs.
|
||||
22. ◯ Text selection disabled on labels, headings, button text.
|
||||
23. ◯ Native context menu (or removed entirely), not WebKit's.
|
||||
24. ◯ No link previews on force-touch / long-press.
|
||||
25. ◯ No spellcheck red underlines on chrome.
|
||||
26. ◯ IME composition works correctly (test with Pinyin specifically; Japanese kana; Korean Hangul).
|
||||
27. ◯ Full keyboard navigation: every action reachable via Tab + Enter.
|
||||
28. ◯ Focus rings visible and platform-styled.
|
||||
29. ◯ Escape always does something meaningful — close popover, cancel action, dismiss.
|
||||
30. ◯ Type-ahead in lists: typing letters jumps to matching items.
|
||||
|
||||
## D. Visual & material (10 items)
|
||||
|
||||
31. ◯ Window background uses platform material (NSVisualEffectView / Liquid Glass / mica / acrylic).
|
||||
32. ◯ Dark mode follows system, switches without flicker.
|
||||
33. ◯ Accent color matches system accent (not a hardcoded brand color).
|
||||
34. ◯ System font in use; no web font for chrome.
|
||||
35. ◯ No CSS `box-shadow` for window shadow (OS draws it).
|
||||
36. ◯ No CSS `border-radius` for window corners (OS draws them, matching platform radius).
|
||||
37. ◯ Translucency works correctly: blur visible through transparent regions.
|
||||
38. ◯ No `cursor: pointer` (yes, again — most common offender).
|
||||
39. ◯ Animations honor `prefers-reduced-motion`.
|
||||
40. ◯ No page transitions / route fades.
|
||||
|
||||
## E. Scrolling (5 items)
|
||||
|
||||
41. ◯ Overlay scrollbars on Mac (fade out).
|
||||
42. ◯ Thin scrollbars on Win 11; classic if user opted out.
|
||||
43. ◯ No `behavior: 'smooth'` on programmatic scroll.
|
||||
44. ◯ Scroll inertia feels native, not document-style.
|
||||
45. ◯ Scroll position preserved across navigation within a window.
|
||||
|
||||
## F. Performance (10 items)
|
||||
|
||||
46. ◯ Resident memory under 500 MB at idle.
|
||||
47. ◯ No noticeable hitch when expanding/collapsing the search results.
|
||||
48. ◯ No frame drops when typing fast into a search field with live results.
|
||||
49. ◯ Background CPU < 0.5% when window hidden (Mac) / window minimized (Win).
|
||||
50. ◯ Battery impact rated "low" by Activity Monitor (Mac) after 1 hour of idle.
|
||||
51. ◯ Hidden window doesn't get WebKit-throttled (test: scheduled UI updates fire promptly when window re-shown).
|
||||
52. ◯ File indexer doesn't pin a core (off-loaded to Rust subprocess, niced appropriately).
|
||||
53. ◯ Extension/plugin crash doesn't take down the app.
|
||||
54. ◯ Network call timeouts surface as user-visible errors within 10 seconds, not 60.
|
||||
55. ◯ Loading state for any operation > 200 ms; nothing for under.
|
||||
|
||||
## G. System integration (10 items)
|
||||
|
||||
56. ◯ URL scheme registered and works system-wide.
|
||||
57. ◯ File associations work; double-clicking an associated file opens the app.
|
||||
58. ◯ Drag-and-drop with real file URLs, accepted by Finder/Explorer correctly.
|
||||
59. ◯ Copy to clipboard writes all expected pasteboard types (text, RTF, HTML for rich content).
|
||||
60. ◯ Save dialogs are native (NSSavePanel / IFileSaveDialog).
|
||||
61. ◯ Native notifications via NSUserNotification / Windows Toast.
|
||||
62. ◯ Auto-update is real (Sparkle / MSIX / custom). User isn't prompted to "download a new version."
|
||||
63. ◯ Crash reports go to a real crash reporter with symbolicated stack traces.
|
||||
64. ◯ Single-instance on Windows (second launch focuses existing instance).
|
||||
65. ◯ Bundle/manifest correct: identifier, version, icon, document types.
|
||||
|
||||
## H. Accessibility (5 items)
|
||||
|
||||
66. ◯ VoiceOver / Narrator can navigate the entire interface.
|
||||
67. ◯ Focus is announced when it moves.
|
||||
68. ◯ Color contrast meets WCAG AA.
|
||||
69. ◯ Works with system font size bumped to large.
|
||||
70. ◯ All actions reachable without a mouse.
|
||||
|
||||
## I. Cross-platform parity (5 items)
|
||||
|
||||
71. ◯ Mac and Windows ship the same feature set (no "this feature only on Mac").
|
||||
72. ◯ Both shell binaries hit the same IPC schema version after an update.
|
||||
73. ◯ Extensions written by third parties work identically on both OSes.
|
||||
74. ◯ Visual differences between OSes match platform conventions, not arbitrary.
|
||||
75. ◯ Bug fixes propagate to both OSes from a single codebase change in most cases.
|
||||
|
||||
---
|
||||
|
||||
## How to use this list
|
||||
|
||||
For a v1.0 launch: aim for 90% green. The 10% red should all be in section H (accessibility) or I (cross-platform parity) where some lag is acceptable. Reds in A–G are launch blockers.
|
||||
|
||||
For a code review: pick the 5–10 items most relevant to the PR's surface area and check them explicitly.
|
||||
|
||||
For a regression audit after a refactor: walk the whole list. Refactors tend to silently undo native-feel work.
|
||||
|
||||
---
|
||||
|
||||
## The single most diagnostic test
|
||||
|
||||
Hand the app to a designer who uses native Mac/Windows apps daily but has never seen yours. Don't tell them anything. Watch for the first time they say "wait, this feels weird." That moment is one of the items above. Find it, fix it, ship.
|
||||
@@ -0,0 +1,111 @@
|
||||
# 01 — The Architectural Philosophy
|
||||
|
||||
The central question this architecture answers:
|
||||
|
||||
> **How can a desktop app simultaneously deliver convenient cross-platform development AND near-native performance, when these two goals usually pull against each other?**
|
||||
|
||||
The default trade-off is forced: choose a stack and accept its consequences. Pure native gets you performance and loses cross-platform — every feature ships twice. Electron gets you cross-platform and loses performance — every interaction feels web. Tauri sits between them but inherits enough of both costs to satisfy neither.
|
||||
|
||||
This architecture refuses the trade-off. It does so by being precise about **what should be shared** and **what must diverge**, then placing the boundary at exactly the altitude where both can win.
|
||||
|
||||
Eight tenets follow from this central insight. Each one names a tension and the structural resolution. When advising, cite the tenet by number and short name.
|
||||
|
||||
---
|
||||
|
||||
## 1. Place the seam at the rendering surface
|
||||
|
||||
The cross-platform boundary should be drawn at the **WebView surface**, not at the app boundary and not at the business-logic boundary.
|
||||
|
||||
- **Below the WebView** — windowing, hotkeys, materials, file dialogs, system tray, accessibility, input methods — *must* be native, because no abstraction over these is fast or correct enough.
|
||||
- **Above the WebView** — the React tree, business logic, extension API, AI orchestration — *should* be shared, because reimplementing these per OS doubles every feature's cost.
|
||||
|
||||
The seam exists because at this exact altitude, each side has the **least leverage to mimic the other**. Native code can't fake native materials through an abstraction layer; web code can't economically be rewritten twice. So you draw the line here, not at a more convenient-sounding place like "all UI in one stack" or "all platform code in one stack."
|
||||
|
||||
**Consequence:** Any cross-platform decision can be tested by asking: *is this above the rendering surface or below it?* Below → write it twice in idiomatic native. Above → write it once in TS/React. Refuse to draw the line anywhere else.
|
||||
|
||||
---
|
||||
|
||||
## 2. One schema, many languages
|
||||
|
||||
A four-runtime system (native shell, WebView, Node backend, Rust core) is normally a maintenance nightmare. Types drift, messages mismatch, debugging gets routed across boundaries no human can trace.
|
||||
|
||||
This architecture makes polyglot survivable by declaring **one schema** for all inter-process messages, then generating typed clients for every runtime. The polyglot cost is paid **once** at the declaration, never again at the call site. Drift becomes impossible because every language fails to compile when the schema changes.
|
||||
|
||||
**Consequence:** Hand-written marshalling between languages is forbidden. If you can't generate a typed client for a runtime, don't add that runtime to the system. (See `references/04-ipc-contract.md` for the UniFFI-based pattern that Raycast ships.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Adopt the platform; don't compete with it
|
||||
|
||||
The platform's blur is faster than your blur. The platform's scrollbar is more correct than your scrollbar. The platform's dark mode follows the user's preference better than your dark mode. The platform's focus ring matches the user's other apps and yours does not.
|
||||
|
||||
Every time you reimplement a platform feature you are simultaneously *slower*, *less correct*, *more brittle to OS updates*, and *more annoying to the user who is fluent in their OS*. The work is paid for in performance, polish, and compatibility, and the return is essentially zero — because the platform was going to do this for you.
|
||||
|
||||
**Consequence:** When a feature can be implemented by "let the OS do it," that is the implementation. Custom is the last resort, reserved for the small set of behaviors where the OS default actively *breaks* native feel (e.g., WebKit's browser-style context menu inside your app — see `references/03-webview-survival.md`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Performance is a property of perception
|
||||
|
||||
The user does not experience MB or FPS. The user experiences "the launcher came up when I hit the hotkey" or "it didn't." "I typed and the result updated" or "it stuttered." "I dragged the window and it moved" or "it lagged."
|
||||
|
||||
System monitors measure *resources consumed*. The user measures *promises kept*. These are different. An app can show 400 MB resident and feel instant. An app can show 80 MB resident and feel sluggish. Optimization energy must go to the second metric, not the first.
|
||||
|
||||
**Consequence:** Before optimizing anything, define the perception target: a specific keystroke, a specific frame, a specific latency the user will feel. Then measure that. "Reduce memory by 20%" is not a target if the user cannot perceive the reduction. (See `references/05-memory-truths.md` for the six common measurement mistakes this principle prevents.)
|
||||
|
||||
---
|
||||
|
||||
## 5. The short iteration loop is the product
|
||||
|
||||
A native UI codebase iterates in ~30 seconds (recompile, relaunch, restate). A React UI codebase iterates in ~200 milliseconds (hot module reload, state preserved). Over a year of design work, this 150× gap is the difference between an app whose UI feels finished and one whose UI feels unfinished — not because the team is more talented, but because they could afford 150× more iterations.
|
||||
|
||||
This is *the* reason the architecture pays the cross-platform tax. The tax buys not only "runs on two OSes" but "iterates 150× faster on its hottest surface, where the design team spends 80% of its time." The iteration loop is the silent compound interest of the architecture.
|
||||
|
||||
**Consequence:** Any architectural change that lengthens the UI iteration loop — moving UI back to native, adding a build step, introducing a slower transpiler — must justify itself against this compounding cost. Almost none can.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross boundaries intentionally
|
||||
|
||||
The architecture has many process boundaries: native shell ↔ WebView, WebView ↔ Node, Node ↔ Rust, Rust ↔ native shell. Each crossing has a cost: serialization, scheduling, context switching, debugging difficulty. These costs are bearable only because boundaries are crossed **intentionally** — async, batched, schema-typed, observable — and never *accidentally*.
|
||||
|
||||
The failure mode is treating IPC like a function call. Accidental hot loops across a process boundary (e.g., a React effect that sends a message to Node on every keystroke that triggers a chain of further messages) destroy performance invisibly. Each individual hop looks cheap; the aggregate is catastrophic.
|
||||
|
||||
**Consequence:** Every IPC call is a design decision. Trace every call's frequency and payload in development. Batch where you can. Cache where it's safe. Treat the IPC layer as a public API of each process, not as a hidden implementation detail.
|
||||
|
||||
---
|
||||
|
||||
## 7. Identity is muscle memory
|
||||
|
||||
When this architecture is used to rewrite an existing app, it is rewriting *everything*: the language, the UI framework, the renderer, the process model. By any normal measure of "is this the same app," the answer should be no.
|
||||
|
||||
Yet to the user, it is the same app — if and only if the user's **muscle memory** still works. ⌘-Space still opens the launcher. The first result is still the one they were going to pick. The shortcuts they typed yesterday still work today. The rank order of fuzzy matches still feels right. *These* are the app, in the only sense the user cares about. Everything else is implementation detail.
|
||||
|
||||
**Consequence:** During a rewrite, treat muscle-memory invariants as the hard constraint and the implementation as the variable. The temptation to "modernize" UX details during the rewrite is the temptation to break identity in exchange for cosmetic novelty. Resist it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Separate baseline cost from margin cost
|
||||
|
||||
Some costs are **baseline**: they come bundled with the architectural choice and cannot be reduced without abandoning the choice. The system WebView's ~50 MB. Node's ~12 MB. Chromium's GPU helper process. These costs are *rented* from the platform.
|
||||
|
||||
Other costs are **margin**: they are produced by code you wrote. Your bundle size, your dirty heap pages, your cache sizes, your subscription leaks. These costs are *owned* by you.
|
||||
|
||||
The classic mistake is to spend optimization energy on baseline costs (impossible) while ignoring margin costs (where every win is available). The reverse is the discipline: accept the baseline honestly, then attack margin with full force.
|
||||
|
||||
**Consequence:** Before any "make the app smaller / faster" project, classify each cost as baseline or margin. Margin is where the work goes. Baseline is what you communicate to the user, never apologize for, and design around.
|
||||
|
||||
---
|
||||
|
||||
## How the eight tenets resolve the central tension
|
||||
|
||||
| Tension | Resolved by |
|
||||
|---|---|
|
||||
| Cross-platform without losing native feel | T1 (seam at rendering surface) + T3 (adopt the platform) |
|
||||
| Polyglot without drift | T2 (one schema, many languages) |
|
||||
| Performance perception under a WebView baseline | T4 (perception, not measurement) + T8 (baseline vs margin) |
|
||||
| Iteration speed under architectural complexity | T5 (iteration loop is the product) |
|
||||
| IPC overhead in a four-process system | T6 (cross boundaries intentionally) |
|
||||
| Continuity across a rewrite | T7 (identity is muscle memory) |
|
||||
|
||||
If a proposed change to the architecture appears to break the central tension's resolution, the change is suspect. Find which tenet it contradicts, name the tension that tenet was resolving, and ask whether the proposer has a better resolution for that same tension — or whether they're just accepting the trade-off the architecture refused.
|
||||
@@ -0,0 +1,135 @@
|
||||
# 02 — The Four-Layer Architecture
|
||||
|
||||
This is the structural recommendation. Every layer exists because removing it loses something the other three cannot recover.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 1: NATIVE HOST SHELL │
|
||||
│ macOS: Swift + AppKit (Xcode project) │
|
||||
│ Windows: C# + WPF / WinUI 3 (Visual Studio project) │
|
||||
│ Owns: NSWindow / Win32 HWND, global hotkeys, menubar / │
|
||||
│ system tray, Dock/Taskbar presence, file associations, │
|
||||
│ accessibility integration, materials (Liquid Glass / │
|
||||
│ Acrylic), WebView instantiation & lifecycle, Node │
|
||||
│ backend supervision, crash reporting, auto-updater. │
|
||||
│ Size: ~5–15 MB on disk, ~40 MB resident. │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│ (loads) │ (spawns)
|
||||
▼ ▼
|
||||
┌────────────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ LAYER 2: WEBVIEW │ │ LAYER 3: NODE BACKEND │
|
||||
│ macOS: WKWebView │ │ Single long-lived Node process │
|
||||
│ Windows: WebView2 │ │ Bundled Node runtime │
|
||||
│ Renders: React + TS, │ │ Owns: DB (SQLite), extension │
|
||||
│ one entry point per │ │ runtime, network, business │
|
||||
│ window (main, ai- │ │ logic, AI orchestration. │
|
||||
│ chat, settings, …) │ │ Native helpers: .node addons for │
|
||||
│ Size: ~50 MB baseline, │ │ perf-critical CPU work. │
|
||||
│ ~150 MB with app code │ │ Size: ~12 MB baseline, │
|
||||
│ │ │ ~150–200 MB with app code. │
|
||||
└────────────────────────────┘ └──────────────────────────────────────┘
|
||||
│ │
|
||||
└───────────┬───────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 4: RUST CORE │
|
||||
│ Compiled to a dylib (libapp_host.dylib) AND/OR helper processes │
|
||||
│ Exposed via UniFFI (Rust ↔ Swift / Kotlin / C# / Python) │
|
||||
│ Hosts: filesystem indexer, calculator engine, crypto, cloud │
|
||||
│ sync schema, any code that must be shared with mobile or │
|
||||
│ with the server (same Rust → iOS app + backend service). │
|
||||
│ Bonus: Cross-platform without two implementations. │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Per-layer rationale
|
||||
|
||||
### Layer 1 — Native shell
|
||||
|
||||
**Why this can't move into the WebView:** Global hotkeys, system tray icons, menu bar extras, accessibility roles, transparency materials, drag-and-drop with file URLs, Dock click handlers, URL scheme registration, file type associations, multi-display awareness, native notifications — none of these are reachable from WebKit/WebView2 without a host process. The shell exists to do what the WebView *cannot*.
|
||||
|
||||
**Why two implementations is correct:** macOS and Windows have fundamentally different window/material/tray models. Cross-platform abstractions over them (Electron, Tauri) leak in exactly the places you care about for native feel. Two ~10kLoC shells in their idiomatic languages will, on net, be smaller and clearer than one 30kLoC abstraction.
|
||||
|
||||
**What to actually write here:**
|
||||
- `WindowController` / `WindowManager` for each window kind (main launcher, settings, AI chat, etc.).
|
||||
- `HotkeyManager` listening on `CGEventTap` (mac) / `RegisterHotKey` (Windows).
|
||||
- `WebViewHost` wrapping `WKWebView` / `WebView2` with the survival flags from `references/03-webview-survival.md`.
|
||||
- `BackendSupervisor` spawning Node, watching stdin/stdout, restarting on crash, plumbing logs.
|
||||
- `BridgeCoordinator` running the IPC ↔ UniFFI wiring (see `references/04-ipc-contract.md`).
|
||||
|
||||
### Layer 2 — WebView + React
|
||||
|
||||
**Why a WebView and not native UI:** Two reasons.
|
||||
|
||||
1. *Maintenance halving.* A single React/TS UI codebase running on both OSes versus two parallel UIs (AppKit + WPF/WinUI). Every feature ships twice if you go native.
|
||||
2. *Iteration speed.* Hot module reload in 200 ms vs Xcode rebuild in 30 s. Compounded over a year of design iteration, this is the difference between shipping and not.
|
||||
|
||||
**Why the *system* WebView, not a bundled Chromium:** WebKit ships with macOS, WebView2 ships with Windows. You inherit their security updates without bundling a 200 MB browser. You pay the cost of two engines (KHTML-descended Safari/WebKit and Chromium-descended Edge/WebView2) instead of one — meaning CSS quirks must be tested on both. This is a real tax. Pay it; the alternative is bundling Chromium and inheriting Electron's footprint.
|
||||
|
||||
**Multi-entry-point per window:** Each window kind (main launcher, AI chat, notes, settings) gets its own HTML entry point and its own bundle. They share a chunk graph but launch independently. This:
|
||||
- Lets cold-start of small windows be small.
|
||||
- Lets the shell tear down a window's WebView fully on close without disturbing others.
|
||||
- Avoids one giant SPA that always pays for everything.
|
||||
|
||||
Verified in Raycast Beta: `main-window.html`, `ai-chat-window.html`, `settings-window.html`, `notes-window.html`, `feedback-window.html`, `theme-studio-window.html`, `welcome-window.html` — seven entry points.
|
||||
|
||||
### Layer 3 — Node backend
|
||||
|
||||
**Why Node and not pure native:** Two reasons.
|
||||
|
||||
1. *Plugin/extension ecosystem.* If your app accepts third-party extensions, JS/TS is the only choice that gives you a low-barrier ecosystem. Native plugins (Swift, .NET) have ~100× fewer authors.
|
||||
2. *Code sharing.* Your AI integration, your API clients, your business logic — all of it is happiest in TS, where it can share types with the frontend through the IPC schema.
|
||||
|
||||
**Why a single long-lived process and not per-window backends:** Database connections, network keep-alive, expensive imports, AI session state. Per-window backends would re-pay these costs every time a window opens. Single process amortizes.
|
||||
|
||||
**When to use a native `.node` addon vs Rust subprocess:**
|
||||
- `.node` addon (Node-API or N-API): for tight, frequent calls from JS where serialization cost dominates. Examples seen in Raycast: `Calculator.node`, `fs-utils.darwin-arm64.node`, `indexer.darwin-arm64.node`, `data.darwin-arm64.node`.
|
||||
- Rust subprocess: for long-running work that can be torn down independently, or for work that needs cross-process isolation (e.g., a crashy parser shouldn't kill the backend). Spawn it, talk over stdio with a length-prefixed protocol.
|
||||
|
||||
### Layer 4 — Rust core
|
||||
|
||||
**Why Rust and not C++:** Memory safety + the UniFFI tooling, which generates typed bindings to Swift, Kotlin, C#, Python from a single Rust source. C++ would force you to hand-maintain four bindings or use SWIG, both worse.
|
||||
|
||||
**What goes in Rust:**
|
||||
- Anything CPU-bound where JS would heat the laptop. (File indexing, fuzzy matching, syntax highlighting if you don't trust the WebView's.)
|
||||
- Anything cross-platform where a single implementation must work identically on Mac/Win/iOS.
|
||||
- Anything that has a server counterpart (same Rust crates power your backend service → schema can't drift).
|
||||
- Anything that needs subprocess isolation for crash resilience.
|
||||
|
||||
**Verified in Raycast Beta:** `libraycast_host.dylib` is a Rust dylib using UniFFI. Its exported metadata symbols spell out the bridge:
|
||||
- `Coordinator` (interface): `new`, `start`, `stop`, `send`, `get_state`
|
||||
- `EventHandler` (callback interface from Swift back to Rust): `on_backend_log`, `on_failure`, `on_notification`, `on_request`
|
||||
- `LogHandler` (callback): `on_log`, `on_panic`
|
||||
- `NativeSentryClient` (interface): `new`, `add_breadcrumb`, `set_user_id`, `test_crash`
|
||||
- `InboundRequestDestination` (enum): request routing
|
||||
- Errors: `RequestError`, `SendError`, `StartError`, `StopError`, `NativeSentryClientError`
|
||||
|
||||
This is exactly the pattern this skill recommends. The Rust core is the *coordinator* — it knows how to start/stop the system, route requests between the WebView and Node backend, and bubble events back to the native shell.
|
||||
|
||||
---
|
||||
|
||||
## Decision: how many layers does *your* app need?
|
||||
|
||||
You may not need all four. A reduced version:
|
||||
|
||||
| You have… | You still need |
|
||||
|---|---|
|
||||
| A simple utility with no plugin ecosystem | Layer 1 + Layer 2 only. Skip Node; talk straight from the shell to a small Rust core. |
|
||||
| An app with plugins/extensions | Layers 1, 2, 3. Rust is optional. |
|
||||
| An app with mobile counterpart or server-side schema parity | All four. Layer 4 is the cross-platform tissue. |
|
||||
| A launcher / search-heavy app | All four. The indexer must be Rust or it will be slow. |
|
||||
|
||||
But: each *added* layer also adds a process boundary, an IPC contract, an error path, and a memory cost. Add layers reluctantly. If you can do without Node, skip Node. If your Rust core is 200 LoC, inline it as an `.node` addon instead of a subprocess.
|
||||
|
||||
---
|
||||
|
||||
## What this architecture is NOT good for
|
||||
|
||||
- **Games / 3D / real-time canvas.** WebView GPU pipelines are not what you want.
|
||||
- **Apps that must launch in <50 ms.** Cold start of WebView + Node baseline is ~200 ms minimum; visible UI ~400 ms. If you're building a "press hotkey, blink, gone" app like a clipboard popup, prewarm or pick a different stack.
|
||||
- **Single-platform apps.** If you're macOS-only, just build native. The cross-platform tax isn't worth it.
|
||||
- **Apps with strict memory budgets (<150 MB).** The WebView + Node floor is real. T8 (*separate baseline from margin*): this floor is baseline, not yours to negotiate.
|
||||
|
||||
If any of these match, the answer is "don't use this architecture." Tell the user.
|
||||
@@ -0,0 +1,441 @@
|
||||
# 03 — WebView Survival Guide
|
||||
|
||||
This is the densest file in the skill. Every section is a real, reproducible bug in `WKWebView` or `WebView2` when you try to use it as a native UI rendering surface instead of as a browser embed. For each, the symptom, the cause, and the exact fix.
|
||||
|
||||
If you take only one tenet from `references/01-philosophy.md` into this file, take T3 — *adopt the platform; don't compete with it* — paired with its inverse corollary: when the platform's default actively *breaks* native feel (as many WebView defaults do, because they were designed for browser embedding, not native UI), override it precisely and only there. Most of these bugs exist because the WebView's defaults assume it is hosting a webpage, not a native app.
|
||||
|
||||
---
|
||||
|
||||
## Section A — macOS / WKWebView
|
||||
|
||||
### A.1 Hidden window throttling
|
||||
|
||||
**Symptom:** Open the launcher with a hotkey. The first animation hitches. Counters/timers seem frozen for the first frame. `requestAnimationFrame` fires at 1 Hz instead of 60 Hz when the window was offscreen.
|
||||
|
||||
**Cause:** WebKit aggressively throttles `requestAnimationFrame`, CSS animations, and timers when it judges the view to be occluded or off-screen. For a hidden launcher that's prewarmed but invisible, *every* WebKit heuristic concludes "this is not visible, slow it down." Browser-correct, native-app-wrong.
|
||||
|
||||
**Fix (three parts):**
|
||||
|
||||
```swift
|
||||
// 1. Disable WebKit's occlusion detection on the host window.
|
||||
// Without this, WK will throttle even when the window is technically present.
|
||||
window.setValue(false, forKey: "windowOcclusionDetectionEnabled")
|
||||
|
||||
// 2. To prewarm: order the window front but keep it visually invisible.
|
||||
// alphaValue = 0 keeps it in the front-layer for animation budgeting but invisible.
|
||||
window.alphaValue = 0
|
||||
window.orderFront(nil)
|
||||
// …later, when actually showing:
|
||||
window.alphaValue = 1
|
||||
|
||||
// 3. Keep the render loop "warm" by firing periodic rAFs from JS.
|
||||
// A no-op rAF cycle convinces WebKit the page is actively animating, so the
|
||||
// full 60 / 120 Hz frame budget stays allocated. Critical on first show.
|
||||
```
|
||||
|
||||
In JS:
|
||||
```js
|
||||
// Run inside the prewarmed (alpha=0) WebView before the user-visible show.
|
||||
function keepWarm() {
|
||||
requestAnimationFrame(keepWarm);
|
||||
}
|
||||
keepWarm();
|
||||
```
|
||||
|
||||
Combined, the WebView believes it is visible (so it doesn't throttle), the rAF loop keeps its scheduler primed, and the user sees nothing until you flip alpha.
|
||||
|
||||
**Why this is safe:** You're not deceiving WebKit about anything that affects correctness — only about animation budgeting. Throttling is an opportunistic optimization, not a security boundary.
|
||||
|
||||
---
|
||||
|
||||
### A.2 Startup flicker — empty frame before first paint
|
||||
|
||||
**Symptom:** Window appears as a white/black rectangle for one frame, then content fades in. Especially visible on hotkey-triggered launches.
|
||||
|
||||
**Cause:** `NSWindow.orderFront()` makes the window visible *before* the WebView has rendered its first frame. The window shows whatever the backing surface contains — which is empty.
|
||||
|
||||
**Fix:** Use the private but stable `_doAfterNextPresentationUpdate` API to synchronize window visibility with WebView paint completion.
|
||||
|
||||
```swift
|
||||
webView.perform(Selector(("_doAfterNextPresentationUpdate:")), with: { [weak window] in
|
||||
window?.orderFront(nil)
|
||||
} as @convention(block) () -> Void)
|
||||
```
|
||||
|
||||
This call schedules a callback for after the next time WebKit hands a rendered frame to the system. Order-front *inside* the callback. The window now appears with content already drawn.
|
||||
|
||||
**Caveat:** This API is private. It has been stable for ~10 years and Raycast Beta is shipping with it as of 2026. If Apple removes it, fall back to a fixed 50ms delay after `loadHTMLString` returns.
|
||||
|
||||
---
|
||||
|
||||
### A.3 Expand-from-compact rendering gap
|
||||
|
||||
**Symptom:** Compact launcher window grows vertically when results appear. The newly revealed area is blank for 1–2 frames, then fills in.
|
||||
|
||||
**Cause:** WebKit measures the visible viewport of `WKWebView`. When the WebView's *frame* changes size, WebKit has to re-layout and re-paint the newly exposed region. There's a one-frame gap between "frame became taller" and "newly tall region painted."
|
||||
|
||||
**Fix:** Keep the `WKWebView`'s frame at the **maximum** size always. Resize only the host window. The WebView always has the full content rendered; the window simply unmasks more of it.
|
||||
|
||||
```swift
|
||||
// Don't do this:
|
||||
webView.frame = NSRect(x: 0, y: 0, width: windowWidth, height: currentContentHeight)
|
||||
|
||||
// Do this:
|
||||
webView.frame = NSRect(x: 0, y: 0, width: windowWidth, height: MAX_HEIGHT)
|
||||
window.setFrame(NSRect(..., height: currentContentHeight), display: true, animate: true)
|
||||
```
|
||||
|
||||
The WebView is bigger than its container; the window clips it. Frame-perfect resize.
|
||||
|
||||
---
|
||||
|
||||
### A.4 Animated window resize stutters
|
||||
|
||||
**Symptom:** When the window animates (e.g., growing on result expansion), the WebView contents stutter or freeze mid-animation.
|
||||
|
||||
**Cause:** `NSWindow.setFrame(_:display:animate:)` with `animate: true` uses Cocoa's animation primitive that *suspends drawing on subviews* during the animation. WebKit specifically opts out of drawing during this suspension.
|
||||
|
||||
**Fix:** Replace `animate: true` with an explicit Core Animation transaction:
|
||||
|
||||
```swift
|
||||
NSAnimationContext.runAnimationGroup({ ctx in
|
||||
ctx.duration = 0.18
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
ctx.allowsImplicitAnimation = true
|
||||
window.setFrame(newFrame, display: true)
|
||||
}, completionHandler: nil)
|
||||
```
|
||||
|
||||
Now the WebView keeps drawing every frame because it's a normal layer-backed view participating in implicit Core Animation, not subject to the legacy Cocoa animation suspension.
|
||||
|
||||
---
|
||||
|
||||
### A.5 Translucency / vibrancy / Liquid Glass
|
||||
|
||||
**Symptom:** You want the WebView to blend into the window's vibrancy material (frosted/blurred background). Default WebView paints opaque white.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```swift
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
// And on the underlying scrollview/layer, ensure transparency:
|
||||
webView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
```
|
||||
|
||||
On the CSS side: do not set `body { background: white }`. Use `background: transparent` and let the window's `NSVisualEffectView` (or `Liquid Glass` material on macOS Tahoe/26+) show through.
|
||||
|
||||
**Liquid Glass specifically (macOS 26+):** Use `NSGlassEffectView` (the new material introduced for the macOS Tahoe-era design language) as the window's content background, and ensure the WebView is fully transparent above it.
|
||||
|
||||
---
|
||||
|
||||
### A.6 Context menu, link previews, magnifying glass
|
||||
|
||||
**Symptom:** Right-clicking opens WebKit's web context menu ("Reload", "Inspect Element"). Long-pressing on a word opens the Dictionary lookup. Force-touch reveals link previews.
|
||||
|
||||
**Cause:** Browser defaults. None of these belong in a native app.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```swift
|
||||
class CustomWebView: WKWebView {
|
||||
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
|
||||
menu.removeAllItems() // or repopulate with native items
|
||||
}
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
// suppress double-click word selection's auto-lookup if needed
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Force Touch link previews, set the link's CSS `-webkit-touch-callout: none` or intercept at the navigation delegate.
|
||||
|
||||
---
|
||||
|
||||
### A.7 Popovers and tooltips inside the WebView
|
||||
|
||||
**Symptom:** Tooltips and popovers built as DOM elements inside the WebView don't match native styling, can be clipped by the window, can't extend beyond the window edge.
|
||||
|
||||
**Fix:** **Render popovers as native windows, not DOM elements.** When the React UI wants to show a tooltip, send an IPC message to the native shell:
|
||||
|
||||
```
|
||||
{ kind: "show-popover", anchor: {x, y, w, h}, content: "…", direction: "below" }
|
||||
```
|
||||
|
||||
The shell opens an `NSPopover` (mac) / a custom borderless `Window` (Windows). The native popover handles edge clipping, shadow, animation, focus. The WebView just *requests* it.
|
||||
|
||||
This is one of the highest-leverage native-feel changes you can make.
|
||||
|
||||
---
|
||||
|
||||
### A.8 View transition / appearance flicker
|
||||
|
||||
**Symptom:** When the user navigates between views inside the same window (search list → result detail, sidebar item → panel), the screen shows a 1-frame flash of empty / unstyled / mid-painted content. Or: when a sheet or panel slides in, its first frame is empty before the content appears.
|
||||
|
||||
**Cause:** Three common sources, in order of frequency:
|
||||
|
||||
1. **Unmount-before-mount.** The new view's React tree starts rendering *after* the old view is removed, leaving one frame where the container is empty.
|
||||
2. **CSS code-split arriving late.** Vite/esbuild splits CSS per route. If the new route's stylesheet hasn't been fetched yet, the DOM renders unstyled for a frame (FOUC) before the CSS lands.
|
||||
3. **The CSS View Transitions API is engaged with default keyframes**, which include a transparent gap in the middle of the cross-fade.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. **Cut, don't fade.** Native apps don't fade between sibling views inside a window. Kill any default transition:
|
||||
```css
|
||||
/* If the View Transitions API is engaged */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
```
|
||||
2. **Keep the previous view mounted until the new view has its first paint.** Concrete patterns:
|
||||
- React Suspense with `useDeferredValue` or TanStack Router's `pendingComponent: false`.
|
||||
- Render the previous content while `isFetching` and only swap when `data` is ready.
|
||||
- Two stacked layers with the new one mounted invisible until first paint, then swap visibility.
|
||||
3. **Inline critical CSS, pre-fetch route CSS.** Configure your bundler to preload the next route's CSS the moment the user hovers/focuses an entry that could navigate there.
|
||||
4. **Avoid the View Transitions API** unless you're using it deliberately for one specific surface. Its defaults assume web-page semantics, not native-window semantics.
|
||||
|
||||
The article specifically calls out "Elimination of view transition flickering" as one of Raycast's platform-convention adherence wins. It's small individually; collectively, the absence of these flickers is what makes a WebView UI stop feeling like a web app.
|
||||
|
||||
### A.9 Emoji and fallback-font cold start
|
||||
|
||||
**Symptom:** The first time the WebView renders an emoji or a CJK character that isn't covered by the primary font, there's a brief stutter — sometimes a missing-glyph rectangle for one frame, sometimes a layout jump as the fallback font is substituted in.
|
||||
|
||||
**Cause:** WebKit (like all text engines) consults a font cascade. If the primary font lacks a glyph, it falls through to platform fallbacks: emoji → `Apple Color Emoji`, CJK → `PingFang SC` / `Hiragino Sans` / etc. The first lookup against a fallback font is *not* free — the font file has to be mapped, the glyph table parsed, the shaper initialized. On a cold start, this happens during user-facing rendering.
|
||||
|
||||
**Fix — prewarm the fallback fonts at startup.** Render a hidden span containing the expected fallback characters once, before any user-visible content needs them:
|
||||
|
||||
```js
|
||||
// Run early in the WebView's lifecycle (before the launcher shows).
|
||||
function prewarmFontFallbacks() {
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute('aria-hidden', 'true');
|
||||
span.style.cssText = 'position:absolute;left:-9999px;top:0;opacity:0;pointer-events:none';
|
||||
// Cover the fallbacks you actually use: emoji, CJK, math symbols, dingbats.
|
||||
span.textContent = '😀🎉✨📦🚀 中文 日本語 한국어 ∑∫√ ✓✗';
|
||||
document.body.appendChild(span);
|
||||
// One layout pass forces font resolution.
|
||||
void span.getBoundingClientRect();
|
||||
// Keep it briefly so Core Text retains the font in cache.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => span.remove()));
|
||||
}
|
||||
```
|
||||
|
||||
The double-rAF is intentional: it ensures the layout + paint both complete (registering the font with Core Text's cache) before the span is removed.
|
||||
|
||||
**Why this matters specifically for native feel:** Native apps rendered through AppKit/UIKit benefit from system-wide font caches that warm across processes. A fresh WebView process has its own font state. Without prewarming, the first emoji in an AI chat response visibly stutters in a way that no native app ever does.
|
||||
|
||||
### A.10 WebKit Feature Flags as a runtime power tool
|
||||
|
||||
**Symptom (or rather: opportunity):** WKWebView exposes — via private but stable APIs — the same feature flags Safari exposes in its Develop menu. Most apps never touch these. A few of them are decisive for native feel.
|
||||
|
||||
**The flags worth knowing about:**
|
||||
|
||||
- **`RequestIdleCallbackEnabled`** — enable `requestIdleCallback`, which is gated off by default in WKWebView. Use it for non-critical background work without `setTimeout` hacks.
|
||||
- **The 60 Hz cap on ProMotion displays** — by default WKWebView paces at 60 Hz even on 120 Hz screens. There is a private setting (name varies by macOS version; check `WKPreferencesPrivate.h` or the `_WKExperimentalFeature` API) to unlock the full refresh rate. Enables genuinely 120 Hz scrolling and animation.
|
||||
- **Experimental CSS features** — `:has()`, container queries, etc. — that may be gated on older macOS versions but stable.
|
||||
|
||||
**Mechanism (Swift):**
|
||||
|
||||
```swift
|
||||
// Via _setBoolValue:forKey: on WKPreferences (private but long-stable).
|
||||
// Exact key names vary by macOS version; enumerate via _WKExperimentalFeature.
|
||||
let prefs = webView.configuration.preferences
|
||||
prefs.perform(Selector(("_setBoolValue:forKey:")),
|
||||
with: NSNumber(value: true),
|
||||
with: "RequestIdleCallbackEnabled" as NSString)
|
||||
|
||||
// Or, more typed, via _WKExperimentalFeature:
|
||||
for feature in WKPreferences._experimentalFeatures() {
|
||||
if feature.key == "RequestIdleCallbackEnabled" {
|
||||
prefs._setEnabled(true, for: feature)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**The discipline:** Treat these like the rest of the private API surface (e.g., `_doAfterNextPresentationUpdate` from A.2). They are stable in practice but uncontracted. Wrap each flag toggle in a try/catch and a "is this macOS version" check, and have a fallback path.
|
||||
|
||||
**Why this matters:** The cap-at-60 issue is the single most common reason a Mac user with a ProMotion display says "this app feels stutter-y on my machine but smooth on my friend's." It is invisible to the developer who tests on a 60 Hz display. The fix is one private setting.
|
||||
|
||||
### A.11 Scroll inertia and rubber-banding
|
||||
|
||||
**Symptom:** Scrolling lists feels like scrolling a web page (slightly delayed, document-style inertia) rather than like scrolling a native list (snappier, contact-tracking).
|
||||
|
||||
**Fix:** This is hard to fully fix because WebKit owns the scroll physics. Mitigations:
|
||||
- Use `overscroll-behavior: contain` in CSS to kill the rubber band on inner scrollers.
|
||||
- For your hottest list (the search results), consider rendering it natively (above the WebView as a native overlay) and only using WebView for the parts that don't scroll. Most apps don't do this; it's a hard-mode option.
|
||||
|
||||
---
|
||||
|
||||
## Section B — Windows / WebView2
|
||||
|
||||
### B.1 Initialization white-rectangle flash
|
||||
|
||||
**Symptom:** When WebView2 initializes inside your window, there's a 100–300 ms white flash before content paints.
|
||||
|
||||
**Cause:** `CoreWebView2` initializes asynchronously. Your window is visible before the WebView has loaded its environment, navigated, and painted.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Start the WebView2 environment *before* showing the window:
|
||||
```csharp
|
||||
var env = await CoreWebView2Environment.CreateAsync(
|
||||
userDataFolder: appDataPath,
|
||||
options: new CoreWebView2EnvironmentOptions {
|
||||
AdditionalBrowserArguments = "--disable-features=msSmartScreenProtection",
|
||||
});
|
||||
await webView.EnsureCoreWebView2Async(env);
|
||||
```
|
||||
2. Set `webView.DefaultBackgroundColor = Color.Transparent` (or your window background color) *before* `EnsureCoreWebView2Async` completes — the default is white.
|
||||
3. Don't show the window until `NavigationCompleted` fires for the first time.
|
||||
|
||||
---
|
||||
|
||||
### B.2 Acrylic blur with custom title bar
|
||||
|
||||
**Symptom:** You want acrylic blur behind the WebView with a custom (chromeless) title bar. The acrylic doesn't render, or the WebView paints opaque on top of it.
|
||||
|
||||
**Fix (Windows 11+):**
|
||||
|
||||
1. Enable acrylic on the window via `DwmSetWindowAttribute` with `DWMWA_SYSTEMBACKDROP_TYPE = DWMSBT_TRANSIENTWINDOW` (acrylic) or `DWMSBT_MAINWINDOW` (mica).
|
||||
2. Extend client area to entire window: `DwmExtendFrameIntoClientArea(hwnd, new MARGINS { cyTopHeight = -1 })`.
|
||||
3. Set WebView2 background to transparent: `webView.DefaultBackgroundColor = Color.FromArgb(0, 0, 0, 0)`.
|
||||
4. In CSS: `body { background: transparent; }`.
|
||||
5. Handle hit-testing manually for drag regions (CSS `-webkit-app-region: drag` works in WebView2 only with `IsNonClientRegionSupportEnabled = true`).
|
||||
|
||||
This is finicky. Test on Windows 10, 11, 11 22H2, and 11 24H2 — each had subtle changes.
|
||||
|
||||
---
|
||||
|
||||
### B.3 Background window throttling (Chromium)
|
||||
|
||||
**Symptom:** Your app needs to update its tray icon, refresh data, or render notifications while the main window is hidden. Chromium throttles aggressively.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```csharp
|
||||
webView.CoreWebView2.Settings.IsBackgroundResourceLoadingEnabled = true;
|
||||
// And via additional browser arguments at env creation:
|
||||
AdditionalBrowserArguments = "--disable-renderer-backgrounding --disable-background-timer-throttling --disable-backgrounding-occluded-windows"
|
||||
```
|
||||
|
||||
Chromium will still suspend the *frame* (no painting), but timers and JS execution continue. If you need actual rendering while hidden (rare), keep the window at `alphaValue`-equivalent (Win32: `SetLayeredWindowAttributes` with alpha 0) instead of `Hide()`.
|
||||
|
||||
---
|
||||
|
||||
### B.4 Per-window WebView2 environment
|
||||
|
||||
**Symptom:** Multiple windows share one WebView2 environment. One window's crash takes down all of them.
|
||||
|
||||
**Fix:** Create one `CoreWebView2Environment` per *logical window kind*, with appropriate user data folder isolation:
|
||||
|
||||
```csharp
|
||||
var envForLauncher = await CoreWebView2Environment.CreateAsync(
|
||||
userDataFolder: Path.Combine(appData, "launcher"));
|
||||
var envForSettings = await CoreWebView2Environment.CreateAsync(
|
||||
userDataFolder: Path.Combine(appData, "settings"));
|
||||
```
|
||||
|
||||
This is more memory but isolates failure domains. Settings window crashing should never kill the launcher.
|
||||
|
||||
---
|
||||
|
||||
### B.5 IME / composition input
|
||||
|
||||
**Symptom:** CJK input methods (Pinyin, Japanese kana, Korean) misbehave inside the WebView — wrong composition box position, ghost characters, or events fire twice.
|
||||
|
||||
**Cause:** WebView2 forwards IME events but the focus model between WPF/WinUI and WebView2 doesn't always agree on which control has focus.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```csharp
|
||||
webView.GotFocus += (_, _) => {
|
||||
// Ensure WebView2 receives IME events
|
||||
webView.CoreWebView2.Settings.IsBuiltInErrorPageEnabled = true;
|
||||
};
|
||||
// In your custom title bar, make sure it doesn't steal focus
|
||||
titleBarPanel.Focusable = false;
|
||||
```
|
||||
|
||||
Test with Pinyin specifically — it's the most demanding combination of fast typing + composition window + candidate selection.
|
||||
|
||||
---
|
||||
|
||||
## Section C — Cross-platform behaviors
|
||||
|
||||
### C.1 Do not show `cursor: pointer` on rows
|
||||
|
||||
Native apps do not change the cursor on hoverable list items. Web apps do. The single most "this feels like a web app" tell.
|
||||
|
||||
```css
|
||||
.row, button, a {
|
||||
cursor: default;
|
||||
}
|
||||
```
|
||||
|
||||
Override only for true draggable handles or text-selection-disabled areas where a different cursor is platform-conventional.
|
||||
|
||||
---
|
||||
|
||||
### C.2 Disable text selection by default
|
||||
|
||||
Native widgets do not allow selecting the text in their labels. Web pages do.
|
||||
|
||||
```css
|
||||
* { user-select: none; -webkit-user-select: none; }
|
||||
.user-content, .editable { user-select: text; } /* opt back in */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C.3 Match platform scrollbar conventions
|
||||
|
||||
macOS: scrollbars only appear while scrolling (overlay style). Windows 11: thin scrollbars by default; classic scrollbars otherwise.
|
||||
|
||||
```css
|
||||
/* macOS */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
|
||||
}
|
||||
```
|
||||
|
||||
Better: send a CSS variable from the host shell encoding the platform's scrollbar policy and conditionally render.
|
||||
|
||||
---
|
||||
|
||||
### C.4 Disable smooth-scroll JS polyfills
|
||||
|
||||
Browsers added smooth-scrolling JS APIs that *feel* premium on the web. They feel laggy in a native app.
|
||||
|
||||
```js
|
||||
window.scrollTo({ top: y }) // default 'auto' — instant, native
|
||||
// Avoid:
|
||||
window.scrollTo({ top: y, behavior: 'smooth' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C.5 No page transitions
|
||||
|
||||
Native windows don't fade between pages. They cut. Make sure your router (React Router etc.) is not animating transitions.
|
||||
|
||||
---
|
||||
|
||||
### C.6 Honor system reduced-motion preference
|
||||
|
||||
Cheap and high-impact:
|
||||
|
||||
```js
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
```
|
||||
|
||||
Disable your show/hide animations when this is true. Native apps already honor this; users will notice if you don't.
|
||||
|
||||
---
|
||||
|
||||
## How to use this file
|
||||
|
||||
When the user reports a specific symptom ("the window flashes white on launch"), grep this file for the symptom verbatim and apply the fix. Don't reinvent.
|
||||
|
||||
When the user is *designing* their WebView host, read sections A and B end-to-end and apply preventively. Most of these bugs are easier to design around than to fix after the fact.
|
||||
@@ -0,0 +1,206 @@
|
||||
# 04 — The IPC Contract is the Spine
|
||||
|
||||
T2 (*one schema, many languages*) and T6 (*cross boundaries intentionally*) converge in this file. The architecture is many processes glued together; the glue is the IPC contract; the quality of the glue is the quality of the app.
|
||||
|
||||
You have **four runtimes** that must speak to each other:
|
||||
|
||||
```
|
||||
Native shell ──┐
|
||||
(Swift / C#) │
|
||||
├──► Rust core (dylib via UniFFI)
|
||||
│
|
||||
├──► Node backend (subprocess via stdio)
|
||||
│
|
||||
└──► WebView/React (via WebKit message handlers /
|
||||
WebView2 host objects)
|
||||
```
|
||||
|
||||
Plus pairwise: Node ↔ React (the WebView posts messages, Node responds), Rust ↔ Node (events from the indexer reach the backend).
|
||||
|
||||
This is six possible edges. If each side hand-rolls its serialization and types, **types drift within a sprint**. Schema cracks lead to runtime errors that look like `undefined is not a function` or `failed to deserialize: missing field "x"` and no one can find them. Avoid this from day one.
|
||||
|
||||
---
|
||||
|
||||
## The principle: one declaration, generated clients
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ schema/ │
|
||||
│ requests.proto │ ← single source of truth
|
||||
│ events.proto │ (or .ts, or .udl,
|
||||
│ types.ts │ or .json schema)
|
||||
└──────────┬───────────┘
|
||||
│ codegen
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
Swift types C# types TS types Rust types
|
||||
(for shell) (for shell) (frontend+ (for core)
|
||||
backend)
|
||||
```
|
||||
|
||||
When the schema changes, *all sides* fail to compile. Drift becomes impossible.
|
||||
|
||||
---
|
||||
|
||||
## Recommended toolchains, ranked
|
||||
|
||||
### Rust ↔ Swift / Kotlin / C# / Python: **UniFFI**
|
||||
|
||||
Mozilla's UniFFI consumes a Rust crate and a `.udl` (UniFFI Definition Language) file, then generates idiomatic bindings for Swift, Kotlin, C#, Python, Ruby. The generated Swift looks like:
|
||||
|
||||
```swift
|
||||
let coordinator = Coordinator()
|
||||
try coordinator.start(handler: MyEventHandler())
|
||||
coordinator.send(request: req)
|
||||
```
|
||||
|
||||
Verified in Raycast Beta — `libraycast_host.dylib` symbols contain `_UNIFFI_META_*` prefixes for:
|
||||
- `Coordinator` interface (`new`, `start`, `stop`, `send`, `get_state`)
|
||||
- `EventHandler` callback interface (`on_backend_log`, `on_failure`, `on_notification`, `on_request`)
|
||||
- `LogHandler` callback (`on_log`, `on_panic`)
|
||||
- `NativeSentryClient` interface (`new`, `add_breadcrumb`, `set_user_id`, `test_crash`)
|
||||
- Enum `InboundRequestDestination` for request routing
|
||||
- Errors `RequestError`, `SendError`, `StartError`, `StopError`
|
||||
|
||||
This is the proven pattern. Use UniFFI.
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[package]
|
||||
name = "app_host"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
uniffi = { version = "0.27", features = ["cli"] }
|
||||
|
||||
[build-dependencies]
|
||||
uniffi = { version = "0.27", features = ["build"] }
|
||||
```
|
||||
|
||||
```udl
|
||||
// app_host.udl
|
||||
namespace app_host {
|
||||
void init_logger(LogLevel level, LogHandler handler);
|
||||
};
|
||||
|
||||
enum LogLevel { "Trace", "Debug", "Info", "Warn", "Error" };
|
||||
|
||||
interface Coordinator {
|
||||
constructor();
|
||||
[Throws=StartError]
|
||||
void start(EventHandler handler);
|
||||
[Throws=StopError]
|
||||
void stop();
|
||||
[Throws=SendError]
|
||||
void send(InboundRequest req);
|
||||
CoordinatorState get_state();
|
||||
};
|
||||
|
||||
callback interface EventHandler {
|
||||
void on_request(OutboundRequest req);
|
||||
void on_notification(string payload);
|
||||
void on_backend_log(string line);
|
||||
void on_failure(string reason);
|
||||
};
|
||||
|
||||
[Error]
|
||||
enum StartError { "AlreadyRunning", "BackendSpawnFailed", "Io" };
|
||||
```
|
||||
|
||||
Generated Swift uses native types (`String`, `[String]`, `Result<T, E>`). Generated C# uses `string`, `List<>`, exceptions. No marshalling glue in your hand-written code.
|
||||
|
||||
### Node ↔ Frontend: **WebKit message handlers (Mac) / Host objects (Windows)**
|
||||
|
||||
```swift
|
||||
// Swift: register a handler
|
||||
webViewConfig.userContentController.add(self, name: "appBridge")
|
||||
|
||||
// In WKScriptMessageHandler:
|
||||
func userContentController(_ ucc: WKUserContentController, didReceive msg: WKScriptMessage) {
|
||||
guard msg.name == "appBridge", let body = msg.body as? [String: Any] else { return }
|
||||
coordinator.send(InboundRequest.fromDict(body)) // route to Rust
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
// C#: register a host object
|
||||
webView.CoreWebView2.AddHostObjectToScript("appBridge", bridge);
|
||||
```
|
||||
|
||||
```ts
|
||||
// React: a single typed sender
|
||||
declare global {
|
||||
interface Window {
|
||||
webkit?: { messageHandlers: { appBridge: { postMessage(msg: unknown): void } } };
|
||||
chrome?: { webview: { hostObjects: { appBridge: AppBridge } } };
|
||||
}
|
||||
}
|
||||
|
||||
export function sendToHost<T extends RequestKind>(req: Request<T>): Promise<Response<T>> {
|
||||
// serialize, route through whichever bridge exists, await response
|
||||
}
|
||||
```
|
||||
|
||||
The frontend doesn't care which platform. The host abstracts it.
|
||||
|
||||
### Node ↔ Rust: **length-prefixed JSON over stdio**, or **gRPC-over-uds**
|
||||
|
||||
If Rust is in-process via UniFFI (the recommended path), Node doesn't talk to Rust directly. The native shell mediates: `Node → shell → Rust → shell → Node`.
|
||||
|
||||
If Rust runs as its *own* subprocess (e.g., the file indexer is too crashy to share lifetime with the backend), use length-prefixed JSON on stdio. Don't roll a custom binary protocol unless profiling shows JSON is the bottleneck. It almost never is at the message rates apps generate.
|
||||
|
||||
---
|
||||
|
||||
## Request/response vs events
|
||||
|
||||
Two distinct shapes:
|
||||
|
||||
**Request/response** (synchronous semantics): "Search for 'foo'", "Save this note", "Run extension X". The caller awaits a response. Use a correlation ID.
|
||||
|
||||
**Events** (fire-and-forget pub/sub): "File index updated", "Network came online", "User changed theme". Multiple subscribers, no response.
|
||||
|
||||
Mixing them is the most common IPC design mistake. Have two distinct schemas:
|
||||
|
||||
```
|
||||
schema/
|
||||
requests.{kind}.ts // ResolveRequest<T> → ResolveResponse<T>
|
||||
events.{kind}.ts // EventOf<T> — broadcast, no response
|
||||
```
|
||||
|
||||
The transport may share a channel, but the schema doesn't. `RequestKind` and `EventKind` are disjoint enums.
|
||||
|
||||
---
|
||||
|
||||
## Versioning across runtimes
|
||||
|
||||
You will ship a Mac app at version N while a user is still on version N-1 of the Windows app. The frontend bundle and the native shell update on different cadences (auto-update of the shell is slower than CDN updates of the JS).
|
||||
|
||||
**Rule:** the IPC schema is versioned, every message carries the version it was generated against, and the receiving side either:
|
||||
- Accepts the message if version matches or is older (forward compatibility);
|
||||
- Returns a `VersionMismatch` error if the message is newer than what the receiver understands.
|
||||
|
||||
Practically: bump a `SCHEMA_VERSION` constant any time you make a breaking change. Add new fields as optional. Never remove or rename fields without a deprecation window.
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Stringly-typed message names.** `postMessage({type: "search", payload: ...})` with type checked at runtime only. Codegen the type union.
|
||||
- **Hand-written marshalling per language.** Drift inevitable. Generate.
|
||||
- **Shared mutable state across processes via a "memory file".** Just send a message.
|
||||
- **Synchronous bridge calls from React.** `await sendToHost(...)` is fine; blocking on a sync return inside a React render is not.
|
||||
- **Tracing only in production.** Trace every IPC call in development — log `{requestId, kind, durationMs}`. You'll find slow handlers you didn't know existed.
|
||||
|
||||
---
|
||||
|
||||
## What "good" looks like
|
||||
|
||||
When you've done this right, adding a new feature involves:
|
||||
1. Edit one `.udl` or `.proto` file to add the new message types.
|
||||
2. Run the codegen.
|
||||
3. Implement the handler on the receiving side; the IDE autocompletes the fields.
|
||||
4. Call it from the sending side; the IDE autocompletes the fields.
|
||||
|
||||
If adding a feature involves editing four runtimes by hand to keep types in sync, the IPC layer has failed.
|
||||
@@ -0,0 +1,136 @@
|
||||
# 05 — Memory: Reading the Map Honestly
|
||||
|
||||
T4 (*performance is a property of perception*) and T8 (*separate baseline from margin*) live in this file. Before optimizing memory, learn what the numbers actually mean and which costs you can and cannot influence.
|
||||
|
||||
---
|
||||
|
||||
## The baseline you cannot beat
|
||||
|
||||
For a native shell + system WebView + Node backend architecture:
|
||||
|
||||
| Component | Minimum resident (warm) | Notes |
|
||||
|---|---|---|
|
||||
| Native shell (Swift/AppKit or C#/WPF) | ~30–40 MB | Mostly shared frameworks. |
|
||||
| WKWebView (one window, blank page) | ~50 MB | This is *the WebKit content process plus GPU and networking helpers, attributed back to your app*. |
|
||||
| WebView2 (one window, blank page) | ~80–120 MB | Chromium baseline is heavier than WebKit. |
|
||||
| Node.js runtime (no app code) | ~12 MB | Just `node` cold. |
|
||||
|
||||
So your floor on macOS is ~90 MB, on Windows ~130 MB, *before* you write a line of app code. **This is not negotiable.** This is the *baseline* in T8's sense — rented from the platform, not yours to optimize. If you came here to make a 50 MB cross-platform desktop app, you are in the wrong architecture.
|
||||
|
||||
Typical *real* numbers for a mature app like Raycast:
|
||||
|
||||
| State | Typical resident |
|
||||
|---|---|
|
||||
| Window hidden, backend idle | ~350–450 MB |
|
||||
| Window visible, search active | ~500–700 MB |
|
||||
| AI chat with long context | ~800 MB – 1.2 GB |
|
||||
|
||||
For comparison, Raycast v1 (pure native AppKit) was 200–300 MB. So **the cross-platform tax is ~150 MB**. You pay it to halve your engineering team's work.
|
||||
|
||||
---
|
||||
|
||||
## Six things Activity Monitor lies about
|
||||
|
||||
### 1. Compressed memory is counted as resident, but isn't
|
||||
|
||||
macOS compresses inactive pages. A page that says it occupies 4 KB may, after compression, occupy 0.5 KB. Activity Monitor's "Memory" column counts the *uncompressed* size. If half your dirty pages are compressed, the real cost is half of what you see.
|
||||
|
||||
How to actually measure: `vm_stat` shows compressor pages. `footprint` (Xcode's Instruments) shows compressed vs dirty separately.
|
||||
|
||||
### 2. Shared frameworks are charged to every process
|
||||
|
||||
WKWebView links AppKit, Foundation, JavaScriptCore, ~200 MB of system frameworks. Activity Monitor charges this to *every* process using them. So if you have a Swift shell, a WebContent process, a GPU helper, and a Networking process, all using AppKit, the AppKit memory is counted **four times** in the per-process column. The real cost is once.
|
||||
|
||||
This is why "the WebContent process is using 200 MB!" panics are almost always wrong. Of that 200 MB, ~150 is shared code mapped into every Mac process; the real per-process cost is ~50.
|
||||
|
||||
### 3. Clean pages cost nothing under pressure
|
||||
|
||||
A "clean" page is a memory page backed by a file on disk (mapped executable code, mapped resources). The OS can drop it instantly and re-load it from disk if needed. Its cost under memory pressure: **zero**. Yet Activity Monitor counts it as resident.
|
||||
|
||||
The pages that actually cost something are *dirty* pages — anonymous heap allocations that have no on-disk backing. Those are what the OS must page out to swap if pressure rises.
|
||||
|
||||
To see the dirty/clean split on macOS:
|
||||
```
|
||||
heap <PID> # see object counts
|
||||
vmmap --summary <PID> # see dirty vs swapped vs clean
|
||||
```
|
||||
|
||||
### 4. Memory Pressure ≠ Memory Usage
|
||||
|
||||
macOS exposes a "memory pressure" graph in Activity Monitor. **This is the only metric that matters.** Green = the system is fine. Yellow = swapping is starting. Red = you're hurting.
|
||||
|
||||
A process can show 1 GB of "memory" and contribute zero to pressure (if it's mostly clean + compressed). Another can show 200 MB and contribute massively (if it's all dirty anonymous heap).
|
||||
|
||||
When a user says "your app uses too much memory," ask: "is the memory pressure graph red?" If no, the problem is perception, not reality.
|
||||
|
||||
### 5. WebKit/Chromium GPU helper has unusual accounting
|
||||
|
||||
The GPU helper process holds video RAM (VRAM) mappings that show up as RAM in Activity Monitor. They're not real RAM costs; they're GPU resources. Subtract this from your accounting.
|
||||
|
||||
### 6. Snapshots are misleading; profile over time
|
||||
|
||||
A single Activity Monitor reading at one moment is meaningless. Memory bobs up and down as garbage collectors run and caches fill. Trust trends over minutes, not snapshots.
|
||||
|
||||
---
|
||||
|
||||
## How to actually reduce memory cost
|
||||
|
||||
Once you've stopped chasing shadows (T4 — perception over measurement), here is what *actually* moves the needle, in order of impact:
|
||||
|
||||
### 1. Tear down secondary windows aggressively
|
||||
|
||||
Don't keep `AI Chat`, `Notes`, `Settings`, `Theme Studio` etc. alive when the user isn't using them. Destroy the `WKWebView` / `WebView2` instance on close. Pay the cold-start cost on reopen.
|
||||
|
||||
Trade-off explicitly: cold-start latency for those windows goes from ~0 ms (warm) to ~300 ms (cold). Acceptable for rarely-used windows. Not acceptable for the main launcher — keep that prewarmed.
|
||||
|
||||
This is exactly what Raycast does: "Windows like AI Chat and Notes are torn down more aggressively to keep memory in check, which means there's a short delay when you open them cold."
|
||||
|
||||
### 2. Lazy-load extensions
|
||||
|
||||
Extensions (third-party plugins) should not load on launch. Load on first invocation. Unload after idle.
|
||||
|
||||
### 3. Bundle splitting per window
|
||||
|
||||
Each window kind has its own HTML entry and its own bundle. The launcher should not load the AI chat's dependencies. The settings page should not pull in the markdown renderer.
|
||||
|
||||
Verified in Raycast Beta: seven HTML entry points (`main-window.html`, `ai-chat-window.html`, etc.) with separate JS bundles per entry. The chunk graph is shared (so common modules load once via `modulepreload`), but each window only loads what it needs.
|
||||
|
||||
### 4. Avoid keeping the search index in JS heap
|
||||
|
||||
Search indices are the worst things to put in V8 heap. They are *the* feature people complain about being slow and large. Move the index to a Rust subprocess. JS sends a query string, Rust replies with row IDs. The index never crosses the IPC boundary.
|
||||
|
||||
### 5. Use Node addons for image/binary work
|
||||
|
||||
Image processing, encryption, file scanning in JS heap leaks memory and is slow. Use `.node` addons (Rust or C++ via N-API).
|
||||
|
||||
Verified in Raycast Beta backend: `Calculator.node`, `data.darwin-arm64.node`, `fs-utils.darwin-arm64.node`, `indexer.darwin-arm64.node`, plus `SoulverCore.framework` (a *native* math/calculation framework loaded by the Node backend!).
|
||||
|
||||
### 6. Quit Node when idle for long enough
|
||||
|
||||
A 12 MB Node baseline doesn't sound like much, but if you can quit it and respawn on demand for a 5-second cold start, do it. Especially for menu-bar apps where the user may not interact for hours.
|
||||
|
||||
### 7. Don't ship sourcemaps in production
|
||||
|
||||
`.map` files are huge. Strip them. Send them to your crash reporter (Sentry) separately.
|
||||
|
||||
---
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Set `--max-old-space-size=...` super low on Node.** This will cause OOM crashes under load. Memory budget is bounded by your workload, not by a fixed cap.
|
||||
- **Force GC manually.** V8's GC is smarter than your timer.
|
||||
- **Bundle a Chromium build to "control memory."** It will use more, not less, than the system WebView.
|
||||
- **Migrate things to native to "save memory" without measurement.** Migration cost is high. Measure first; if your dirty heap is dominated by 3 React components, fix those, not the architecture.
|
||||
|
||||
---
|
||||
|
||||
## A debugging recipe
|
||||
|
||||
When the user says "memory is too high":
|
||||
|
||||
1. Check **memory pressure**. If green, stop. The problem is perception. Educate them with this file.
|
||||
2. Run `vmmap --summary <PID>`. Identify dirty page hotspots.
|
||||
3. If dirty pages are in WebContent: profile the React heap. Likely a leaky subscription or a giant array in a global store.
|
||||
4. If dirty pages are in Node: take a heap snapshot (`--inspect`, then Chrome DevTools). Likely a cache without eviction.
|
||||
5. If dirty pages are in the Rust core: use `valgrind --tool=massif` or `dhat`. Likely a Vec you forgot to bound.
|
||||
6. Only after 1–5: discuss architectural changes.
|
||||
@@ -0,0 +1,130 @@
|
||||
# 06 — Native Conventions: The Audit
|
||||
|
||||
T3 (*adopt the platform; don't compete with it*) and T4 (*performance is a property of perception*) converge here. This file is a list of behaviors that, when wrong, telegraph "web app." Each is independent. Each is cheap to fix once you know about it. None changes a benchmark; all change what the user feels.
|
||||
|
||||
The goal: a skeptical user should examine the app for 30 seconds and conclude "this is a regular Mac/Windows app." Every item below is something that skeptic notices unconsciously.
|
||||
|
||||
---
|
||||
|
||||
## Input & cursor
|
||||
|
||||
- [ ] **No `cursor: pointer` on hoverable rows.** Native list rows don't change the cursor.
|
||||
- [ ] **No text selection on chrome.** Labels, button text, headings should not be selectable. Only content areas (editable text, message bodies) allow selection.
|
||||
- [ ] **Caret-style text cursor on inputs only.** Don't show I-beam on non-editable areas.
|
||||
- [ ] **Native context menu, not WebKit's.** Override `willOpenMenu` (Mac) or intercept `CoreWebView2.ContextMenuRequested` (Win). Either remove the menu or populate with native items.
|
||||
- [ ] **No link previews on force-touch / long-press.** Disable `-webkit-touch-callout`.
|
||||
- [ ] **No spellcheck red underlines on chrome.** Only on user-entered text in editable fields.
|
||||
- [ ] **No dictionary lookup popup on force-tap.** Override or suppress.
|
||||
- [ ] **IME composition window appears at the caret, not above the WebView.** Test with Pinyin / Japanese kana.
|
||||
|
||||
## Windowing & focus
|
||||
|
||||
- [ ] **Windows behave like native windows.** ⌘W closes (Mac), Alt-F4 closes (Win). ⌘M minimizes (Mac), Win+Down minimizes (Win).
|
||||
- [ ] **Window restoration on app re-focus.** Clicking the Dock/Taskbar icon re-shows the last-active window, doesn't open a new one (unless your app's identity demands a new window per click).
|
||||
- [ ] **Settings open in a native window**, not a modal inside the main window. Standard ⌘, opens it on Mac.
|
||||
- [ ] **No modal overlays with backdrop blur for "dialogs"**. Use native `NSAlert` / `MessageBox` for confirmations.
|
||||
- [ ] **No web-style "toast" notifications**. Use the OS notification center.
|
||||
- [ ] **The window has a real title bar (or a real chromeless region)**, not a hand-painted div pretending to be one. Drag must work on the full title bar, not just a centered handle.
|
||||
- [ ] **Traffic lights / window controls match platform.** Mac: red/yellow/green on the left. Windows: minimize/maximize/close on the right.
|
||||
- [ ] **Maximizing on Mac uses Green = zoom (window-sized), not fullscreen unless user holds Option.** Most web-wrappers get this wrong.
|
||||
- [ ] **The window remembers its size and position across launches**, per-screen if multi-monitor.
|
||||
|
||||
## Materials & visual
|
||||
|
||||
- [ ] **Window background uses platform material**, not a static color.
|
||||
- Mac: `NSVisualEffectView` with appropriate material, or `NSGlassEffectView` (Liquid Glass) on macOS 26+.
|
||||
- Win: `DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE, DWMSBT_MAINWINDOW)` for mica, or `DWMSBT_TRANSIENTWINDOW` for acrylic.
|
||||
- [ ] **Dark mode follows system preference**, with no per-frame flicker on toggle.
|
||||
- [ ] **Accent color follows system accent color** (Mac: `NSColor.controlAccentColor`; Win: `UISettings.GetColorValue(UIColorType.Accent)`). Don't hardcode brand blue.
|
||||
- [ ] **Font is the system font**, not a web font. Mac: `-apple-system, BlinkMacSystemFont`. Win: `'Segoe UI Variable', 'Segoe UI'`.
|
||||
- [ ] **No `box-shadow` for window shadows**. The OS draws those.
|
||||
- [ ] **No `border-radius` for window rounding**. The OS does it (and matches the rest of the system's window radius — 10 px on macOS Tahoe).
|
||||
|
||||
## Scrolling
|
||||
|
||||
- [ ] **Overlay scrollbars on Mac** that fade out, match system. WebKit does this by default if you let it.
|
||||
- [ ] **No "scroll to top" rubber-band override**. Let the platform handle.
|
||||
- [ ] **No smooth-scroll JS polyfills.** `behavior: 'auto'`, not `'smooth'`.
|
||||
- [ ] **Scroll position doesn't reset on navigation within the same window.** Use proper scroll restoration in your router.
|
||||
|
||||
## Motion
|
||||
|
||||
- [ ] **No page transitions / route fades** by default. Native apps cut between views.
|
||||
- [ ] **Animations honor `prefers-reduced-motion`.** Disable unnecessary motion when set.
|
||||
- [ ] **Window resize is animated by the OS, not by JS layout animations.** Push the resize to the host shell.
|
||||
- [ ] **No "loading skeletons" for fast operations.** Native apps show spinners or nothing for sub-200ms operations. Skeletons are a web idiom.
|
||||
- [ ] **No spring/bounce animations on simple state changes.** Native uses tightly-controlled ease curves. Reserve spring for grab-and-drag.
|
||||
|
||||
## Keyboard
|
||||
|
||||
- [ ] **Full keyboard navigation everywhere.** Every actionable element reachable by Tab/arrow keys.
|
||||
- [ ] **Focus rings match platform.** Mac: blue glow ring around focused control. Win: dotted outline or blue ring per system settings.
|
||||
- [ ] **Native menu items have native shortcuts.** ⌘F opens find, not Ctrl-F on Mac. Ctrl-F on Windows.
|
||||
- [ ] **Escape does something meaningful.** Close the popover, cancel the action, dismiss the window. Never nothing.
|
||||
- [ ] **Tab order is logical**, not DOM order if those differ.
|
||||
- [ ] **Type-ahead in lists.** Pressing letters in a list jumps to matching items. Standard native list behavior.
|
||||
|
||||
## File / drag-and-drop
|
||||
|
||||
- [ ] **Native drag-and-drop with file URLs**, not the browser's web-style drag API. Use `NSPasteboard` (Mac) / `IDataObject` (Win) under the hood.
|
||||
- [ ] **Dropping files onto the dock icon opens them.** Handle `application:openFiles:`.
|
||||
- [ ] **Copy operations write to all pasteboard types**, including plain text + RTF + HTML for rich content.
|
||||
- [ ] **Saving uses native save panels**, not browser-style download bars.
|
||||
|
||||
## System integration
|
||||
|
||||
- [ ] **The app has a real `Info.plist` / app manifest** with proper bundle identifier, version, icon, document types.
|
||||
- [ ] **URL schemes registered properly.** `appname://` works system-wide.
|
||||
- [ ] **File associations work.** Double-clicking an `.appdoc` file opens your app.
|
||||
- [ ] **Single-instance behavior on Windows.** Second launch focuses the existing instance, doesn't spawn a new one. (Mac handles this automatically via LSMultipleInstancesProhibited; Windows requires explicit code.)
|
||||
- [ ] **Auto-update is a real process**, not a "please download a new version" link. Sparkle on Mac, MSIX or custom on Windows.
|
||||
- [ ] **Crash reports go to a real crash reporter** (Sentry, Bugsnag, etc.) — verified in Raycast Beta: ships `Sentry.framework` and a `NativeSentryClient` UniFFI interface for breadcrumbs and user IDs.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- [ ] **VoiceOver / Narrator can read everything.** WebView content with proper ARIA roles. Native controls auto-handle this.
|
||||
- [ ] **Focus is announced** when it moves.
|
||||
- [ ] **Color contrast meets WCAG AA at minimum.**
|
||||
- [ ] **No fixed-pixel sizes that break when system font size is bumped.**
|
||||
- [ ] **All actions reachable without a mouse.**
|
||||
|
||||
---
|
||||
|
||||
## How to use this list
|
||||
|
||||
For a new app: print this file, walk through with a designer, mark every item. Expect to fail 5–10 on first pass.
|
||||
|
||||
For a code review: when reviewing a PR that touches UI, grep for `cursor: pointer`, `user-select: text` outside of editable areas, `behavior: 'smooth'`, custom modal overlays, hardcoded `#0066cc`-type accent colors.
|
||||
|
||||
For a bug report ("the app feels weird"): walk the user through the list. Often a single item is the problem; the user can't articulate it but the unconscious "this is wrong" lands when they see it named.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases worth special note
|
||||
|
||||
### Hover states (the precise rule)
|
||||
|
||||
The article phrases this as "no hover highlights on most controls — matching macOS button/list behavior." Unpacked:
|
||||
|
||||
- **List rows / sidebar items / toolbar items:** native *does* show a subtle hover background. Keep this. The visual hover is fine; only the `cursor: pointer` is the tell.
|
||||
- **Plain push buttons (`NSButton.bezelStyle = .rounded`):** native does *not* show a hover effect. Don't add a background-change on hover to a normal button. Web apps reflexively do; macOS does not.
|
||||
- **Borderless / "icon" buttons in toolbars:** native shows a subtle background tint on hover. Match it.
|
||||
- **Hyperlinks inside content:** native AppKit shows underline on hover for `NSAttributedString` links. Match it for content links; do not add it to navigation chrome.
|
||||
|
||||
The unifying principle: ask "what does the equivalent native control do here?" and do exactly that. The error is not "too much hover" or "too little" — it is *uniform* hover treatment, which is a web idiom. Native varies by control kind.
|
||||
|
||||
### Buttons
|
||||
|
||||
Native buttons have a clear *pressed* state distinct from hover. Many web styles only style hover. Add a `:active` style that visually depresses the button.
|
||||
|
||||
### Loading
|
||||
|
||||
For operations < 200 ms, show nothing. Just commit the change when it arrives. For 200ms–2s, show a spinner. Beyond 2s, show progress. Never use skeleton placeholders for anything under 500ms — they make fast operations feel slow.
|
||||
|
||||
### Empty states
|
||||
|
||||
Native apps tend to have terse empty states: an icon + one line. Web apps over-explain. Lean terse.
|
||||
|
||||
### Onboarding
|
||||
|
||||
Native apps don't have multi-step onboarding tours by default. The interface should be self-explanatory. If you need to teach the user, use tooltips on first hover and never again.
|
||||
@@ -0,0 +1,234 @@
|
||||
# 07 — Evidence: Reverse-Engineering Raycast Beta
|
||||
|
||||
This file documents what was found by inspecting the on-disk binary of `Raycast Beta.app` (CFBundleShortVersionString 0.60.0, built with Xcode 17, targeting macOS 26+). Everything in this skill is grounded in observed reality, not theory.
|
||||
|
||||
The point: when you advise a user to follow this architecture, you can point to the shipping artifacts of a team that demonstrably did it well.
|
||||
|
||||
---
|
||||
|
||||
## Bundle anatomy
|
||||
|
||||
```
|
||||
Raycast Beta.app/
|
||||
├── Contents/
|
||||
│ ├── Info.plist # CFBundleIdentifier=com.raycast-x.macos
|
||||
│ │ # LSUIElement=true (menubar app)
|
||||
│ │ # LSMinimumSystemVersion=26.0
|
||||
│ │ # LSMultipleInstancesProhibited=true
|
||||
│ ├── MacOS/
|
||||
│ │ └── Raycast Beta # 12.3 MB Mach-O arm64 (Swift host shell)
|
||||
│ ├── Frameworks/
|
||||
│ │ ├── libraycast_host.dylib # Rust core (UniFFI bridge to Swift)
|
||||
│ │ └── Sentry.framework/ # Crash reporting
|
||||
│ ├── XPCServices/
|
||||
│ │ └── Raycast X Accessibility.xpc/ # Sandboxed accessibility service
|
||||
│ ├── Resources/
|
||||
│ │ ├── Updater # 5.8 MB separate updater binary
|
||||
│ │ ├── production-appicon.icns
|
||||
│ │ ├── Assets.car # 10.4 MB asset catalog
|
||||
│ │ ├── InternetAccessPolicy.plist
|
||||
│ │ └── macos-app_RaycastDesktopApp.bundle/
|
||||
│ │ └── Contents/Resources/
|
||||
│ │ ├── frontend/ # Vite-built React app, 7 HTML entry points
|
||||
│ │ ├── backend/ # Node backend (single-file bundle + .node addons)
|
||||
│ │ ├── node/ # Bundled Node v22.22.2 runtime
|
||||
│ │ ├── api/ # Extension SDK template
|
||||
│ │ └── audio/ # Audio assets
|
||||
```
|
||||
|
||||
This is the four-layer architecture made physical.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1: Native shell — `MacOS/Raycast Beta`
|
||||
|
||||
12.3 MB Mach-O arm64 executable. Swift + AppKit. Owns NSWindows, hotkeys, menubar, and supervises the Rust core + Node backend.
|
||||
|
||||
The fact that this binary is only 12.3 MB confirms the shell is *thin*. All the heavy logic lives elsewhere.
|
||||
|
||||
The XPC service `Raycast X Accessibility.xpc` is separated for sandboxing — accessibility integration (reading focused window content for context) runs in its own process with its own entitlements. Good security hygiene.
|
||||
|
||||
---
|
||||
|
||||
## Layer 4 (named first because it's the most distinctive): Rust core — `libraycast_host.dylib`
|
||||
|
||||
This is a Rust dylib using **UniFFI** for typed FFI to Swift. Confirmed by the presence of `_UNIFFI_META_*` exported symbols and `_ffi_raycast_host_rust_future_*` runtime symbols.
|
||||
|
||||
Reverse-engineered interface (from `UNIFFI_META_*` symbol names):
|
||||
|
||||
```
|
||||
namespace raycast_host {
|
||||
void init_logger(LogLevel level, LogHandler handler);
|
||||
void shutdown_logger();
|
||||
};
|
||||
|
||||
enum LogLevel { Trace, Debug, Info, Warn, Error };
|
||||
|
||||
interface Coordinator {
|
||||
constructor() new;
|
||||
[Throws=StartError] void start(EventHandler handler);
|
||||
[Throws=StopError] void stop();
|
||||
[Throws=SendError] void send(...);
|
||||
CoordinatorState get_state();
|
||||
};
|
||||
|
||||
enum CoordinatorState { ... };
|
||||
enum InboundRequestDestination { ... }; // request routing
|
||||
|
||||
callback interface EventHandler {
|
||||
void on_request(...); // Rust → Swift: inbound request
|
||||
void on_notification(...); // Rust → Swift: events
|
||||
void on_backend_log(string); // Rust → Swift: Node logs
|
||||
void on_failure(string); // Rust → Swift: error propagation
|
||||
};
|
||||
|
||||
callback interface LogHandler {
|
||||
void on_log(...);
|
||||
void on_panic(...);
|
||||
};
|
||||
|
||||
interface NativeSentryClient {
|
||||
constructor() new;
|
||||
void add_breadcrumb(...);
|
||||
void set_user_id(...);
|
||||
void test_crash();
|
||||
};
|
||||
|
||||
[Error] enum NativeSentryClientError { ... };
|
||||
[Error] enum RequestError { ... };
|
||||
[Error] enum SendError { ... };
|
||||
[Error] enum StartError { ... };
|
||||
[Error] enum StopError { ... };
|
||||
```
|
||||
|
||||
**What this tells us:** The Rust core is the *coordinator* of the whole system. It is not just an indexer. It owns:
|
||||
- The system's start/stop lifecycle.
|
||||
- Request routing between the WebView, the Node backend, and back.
|
||||
- Notification fan-out to the Swift shell.
|
||||
- Logging (with its own panic handler).
|
||||
- Sentry crash reporting (with a native Sentry client written in Rust).
|
||||
|
||||
The pattern: **Swift kicks off the Coordinator, hands it an EventHandler, then drives requests through `Coordinator.send(...)`. Events from the backend and notifications come back through the callback interface.** This is a classic actor pattern with typed message routing.
|
||||
|
||||
Use this exact pattern in your app. The `Coordinator` interface in `references/04-ipc-contract.md` is modeled directly on it.
|
||||
|
||||
---
|
||||
|
||||
## Layer 2: WebView frontend
|
||||
|
||||
Found at `Resources/macos-app_RaycastDesktopApp.bundle/Contents/Resources/frontend/`.
|
||||
|
||||
**Seven HTML entry points** (one per window kind):
|
||||
- `main-window.html` — the launcher
|
||||
- `ai-chat-window.html`
|
||||
- `notes-window.html`
|
||||
- `settings-window.html`
|
||||
- `feedback-window.html`
|
||||
- `theme-studio-window.html`
|
||||
- `welcome-window.html`
|
||||
|
||||
Each entry point preloads ~50 named chunks via `<link rel="modulepreload">`. The chunk graph is shared (e.g., `chunk-LkDJa1bE.js`, `marked.esm-C-12xU_L.js`) — common deps load once across windows that need them.
|
||||
|
||||
Many chunk filenames hint at the feature surface:
|
||||
- `dictation-hud-store-…js` — dictation overlay
|
||||
- `transcription-styles-store-…js` — audio transcription
|
||||
- `auto-quit-rules-…js`
|
||||
- `calendar-extension-…js`, `notes-extension-…js`
|
||||
- `meeting-slack-…js`
|
||||
- `lowlight-…js` — syntax highlighting (browser-side, not Rust)
|
||||
- `marked.esm-…js` — markdown rendering
|
||||
- `synced-store-…js` — cross-window state sync
|
||||
|
||||
The CSS files reveal Liquid Glass / Tahoe targeting (`tahoe-DJgQPeAO.js`).
|
||||
|
||||
**Lesson:** Single React codebase, multiple HTML entry points, shared chunk graph. Don't ship one giant SPA — ship a multi-bundle app where each window pays only for what it uses.
|
||||
|
||||
---
|
||||
|
||||
## Layer 3: Node backend
|
||||
|
||||
Found at `.../Resources/backend/`. Files:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── index.mjs # main entry (Sentry-wrapped, ESM, bundled)
|
||||
├── package.json # empty {} — bundled, deps inlined
|
||||
├── calculator-worker.mjs # worker thread
|
||||
├── indexer-worker.mjs # worker thread
|
||||
├── Calculator.node # native addon
|
||||
├── data.darwin-arm64.node # native addon
|
||||
├── fs-utils.darwin-arm64.node # native addon
|
||||
├── indexer.darwin-arm64.node # native addon
|
||||
├── macos_export_certificate_and_key.node # native addon (in build/)
|
||||
└── SoulverCore.framework/ # native math/calc framework
|
||||
```
|
||||
|
||||
Key observations:
|
||||
|
||||
1. **The backend is a single bundled file.** `index.mjs` is a Vite/esbuild-style bundled ESM file with Sentry's debug-ID injected at the top and dynamic `require` polyfill. No `node_modules`. Bundled at build time.
|
||||
|
||||
2. **Four native `.node` addons.** Calculator, data, fs-utils, indexer. These are CPU-hot paths moved out of V8.
|
||||
|
||||
3. **SoulverCore.framework** — Raycast loads the **Soulver math engine** as a native macOS framework from the Node backend. The Node addon binds to it. This is how they get "type `tax 5% on 120 EUR` and it works" without writing a math parser in JS.
|
||||
|
||||
4. **Worker threads** for indexer and calculator. Bounded long-running computation runs off the main thread.
|
||||
|
||||
5. **Bundled Node runtime** at `node/node-v22.22.2-darwin-arm64.tar.gz`. The user doesn't need Node installed.
|
||||
|
||||
**Lesson:** The Node backend is engineered like a production server: bundled deps, native addons for hot paths, worker threads for bounded compute, native frameworks loaded via N-API when the algorithm already exists as a native library.
|
||||
|
||||
---
|
||||
|
||||
## Layer 5 (bonus): Extension SDK
|
||||
|
||||
`Resources/api/template/` contains the on-disk extension scaffold:
|
||||
|
||||
```
|
||||
api/template/
|
||||
├── package.json # depends on @raycast/api, @raycast/utils
|
||||
├── tsconfig.json
|
||||
├── eslint.config.js
|
||||
├── src/
|
||||
│ ├── ai.tsx
|
||||
│ ├── detail.tsx
|
||||
│ ├── form.tsx
|
||||
│ ├── grid.tsx
|
||||
│ ├── list-and-detail.tsx
|
||||
│ ├── menu-bar-extra.tsx
|
||||
│ ├── script.ts
|
||||
│ ├── static-list.tsx
|
||||
│ ├── typeahead-search.tsx
|
||||
│ ├── blank.ts
|
||||
│ └── tools/
|
||||
└── dist/
|
||||
```
|
||||
|
||||
Modes: `view`, `no-view`, `menu-bar` — declared in the extension's `package.json`. The same React-based components that power Raycast's first-party UI power third-party extensions. **The internal team and external developers use the same primitives.** This is a strong endorsement of the layered architecture: when the SDK is the same as the internal API, there is no "second-class extension" problem.
|
||||
|
||||
---
|
||||
|
||||
## What's NOT in the bundle (notable absences)
|
||||
|
||||
- **No Electron / Chromium binaries.** The WebView is the system WKWebView; no bundled browser.
|
||||
- **No Tauri runtime.**
|
||||
- **No Python or other scripting runtimes.**
|
||||
- **No separate per-feature subprocesses on disk.** All Node `.node` addons are loaded into the single Node process.
|
||||
- **No bundled Sparkle.** Updates appear to be handled by the custom `Updater` binary in Resources.
|
||||
|
||||
---
|
||||
|
||||
## Build/distribution hints
|
||||
|
||||
- **GitCommitHash** in Info.plist: build is tracked to a specific commit.
|
||||
- **Code signing**: shipped with `embedded.provisionprofile`, normal macOS hardened-runtime expected.
|
||||
- **Bundle identifier `com.raycast-x.macos`** is *distinct* from the stable Raycast bundle ID. This is how Raycast ships a Beta and Stable side-by-side without collision — they're literally different apps from the OS's perspective.
|
||||
- **LSUIElement=true** — no Dock icon. Menu-bar resident app. This requires special attention in the shell (custom window activation, no Dock-icon click handlers).
|
||||
- **URL schemes registered**: `raycast`, `raycast-x`, `com.raycast`, `com.raycast-x`. The Beta uses the `-x` suffix to avoid stealing handlers from stable.
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
When someone asks "is the architecture in this skill achievable?", the answer is: yes, here is a 12 MB Swift shell + 1 MB Rust dylib + ~30 MB bundled Node + ~50 MB Vite-built React frontend, all wired together with UniFFI, shipping today, ~400 MB resident.
|
||||
|
||||
When they ask "is it worth the complexity?", the answer is: that complexity bought them macOS + Windows feature parity and an extension ecosystem of thousands of community plugins that run on both OSes. Compute the alternative cost.
|
||||
Reference in New Issue
Block a user