Help us improve
Share bugs, ideas, or general feedback.
From app-it
Creates macOS .app bundles to launch any project from the Dock. Handles packaging, icons, Swift WKWebView shell, menu bar shortcuts, and warm reattach.
npx claudepluginhub christian-katzmann/app-it --plugin app-itHow this skill is triggered — by the user, by Claude, or both
Slash command
/app-it:app-itThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **Minimum work for the user. Maximum repeatability. No over-engineering.**
templates/app-it.config.example.jsontemplates/desktop-build.shtemplates/desktop-icons.shtemplates/desktop-install.shtemplates/desktop-launcher.md.templatetemplates/desktop-quit.shtemplates/fsa-polyfill-template.jstemplates/info-plist-template.xmltemplates/inspect.shtemplates/placeholder-icon-gen.shtemplates/run-template-chrome.shtemplates/run-template-multiserver.shtemplates/run-template.shtemplates/wrapper.swiftTurns a finished or buildable web app into a macOS Dock-launchable .app that serves the built output with no dev server. Detects build command and output directory, builds once, serves via tiny static server or file://.
Packages a local web project as a Windows desktop app with a Start Menu shortcut, .exe launcher, and Taskbar identity. Mirrors macOS app-it contract (soft-close vs quit, warm dev-server reuse). Beta — requires Windows maintainer to verify hardware-dependent steps.
Adds production features like CI/CD, auto-updates, logging, SwiftLint, localization, Launch at Login to existing macOS Swift apps after analyzing project status.
Share bugs, ideas, or general feedback.
.app is reversible — pick the best option, ship it, document tradeoffs in the report. Only ask if the project is genuinely ambiguous and the default would do something destructive. Treat explicit /app-it invocation as the user's plan approval; project CLAUDE.md "check in first" notes do not require a second prompt.npm install, no manual server starts, no "first run setup". Double-click → window appears showing the app → red-X leaves the dev server warm for fast re-launch → Cmd+Q kills everything.~/Applications/App It/ by default. Users can drag that folder to the right side of the Dock once as a Stack. Use ~/Desktop/MyApps/ or /Applications/ only when explicitly requested..app per user-facing app; do not bundle them..app keeps its own Dock icon. This means the foreground process must be ours, not Chrome's. Default launcher is a small Swift WKWebView shell that the skill ships and compiles. Chrome --app= is a documented fallback only.CLAUDE.md, AGENTS.md, README.md may be stale, template-copied from another project, or describe an intended state not yet implemented. Always verify project type from package.json + config files. If docs and disk disagree, trust disk and note the discrepancy in the report.The user almost never wants:
Trigger on any of: "launch from Dock", "give this an icon", "make this an app", "app-it", "appify", "dockify", ".app for this", "in /Applications", "in MyApps", "clickable launcher", "desktop shortcut for this project", "package as a desktop app".
Do not use this skill for:
This skill ships working templates next to SKILL.md. The agent's job is to copy them into the project and customize via app-it.config.json — not rewrite them from scratch. They encode hard-won lessons (autoplay handling, NFC/NFD-safe pgrep, daemon-mode dev server, two-stage cleanup, runtime port-fallback, descendant-walk reattach, expanded PATH for Bun/Volta/mise/asdf/Deno) that took 12 real-project sessions to get right.
templates/
wrapper.swift # Swift WKWebView shell (~230 lines)
info-plist-template.xml # Info.plist with placeholders
run-template.sh # bash launcher → execs wrapper (Swift mode)
run-template-chrome.sh # bash launcher → Chrome --app (fallback / FSA real-I/O)
run-template-multiserver.sh # bash launcher for cohabiting FE+BE
desktop-build.sh # builds the bundles, compiles wrapper (universal)
desktop-icons.sh # generates AppIcon.icns from a source PNG/SVG
desktop-install.sh # copies bundles to ~/Applications/App It/, refreshes Dock
desktop-quit.sh # stops daemonized servers + wrapper windows
inspect.sh # Phase-1 inspection helper (one-shot project probe)
placeholder-icon-gen.sh # last-resort icon generator (SVG via brand tokens)
fsa-polyfill-template.js # File System Access shim (only if needed)
app-it.config.example.json # single source of truth — copy + customize
desktop-launcher.md.template # user-facing doc
Do not re-derive the patterns. The comments inside templates document traps that will silently bite a fresh implementation.
Phases run in order. Don't skip ahead.
Run templates/inspect.sh first. It emits a one-page report covering worktree status, project type, dev scripts with hardcoded -p flags, framework port literals, FSA usage, sibling-app port collisions, runtime-binary availability, and gitignored data paths the launcher will need at runtime. Read its output before answering anything below.
Then answer all of these. Do not modify files.
inspect.sh. If yes, pick a strategy:
APP_IT_PROJECT_ROOT env override — when app-it scripts should ship as a reviewable diff on the worktree's feature branch. Build from worktree, point baked path at main checkout via env.desktop:build from main afterward. Do not let agents fall into this by default.CLAUDE.md/README.md). Look for: package.json (with dependencies/scripts), next.config.*, vite.config.*, tauri.conf.json/src-tauri/, electron.*/main.js/electron-builder.*, pyproject.toml/requirements.txt, index.html at root, Cargo.toml, Gemfile, manifest.json + service worker.dev:* and start:* scripts (inspect.sh does this). Default to dev (canonical full-fidelity). Prefer a dev:bypass / dev:no-db / dev:offline variant when the canonical dev requires external services that won't be reachable from a Dock click. Surface alternatives in the final report so the user can flip without rebuilding from scratch.-p 3002 or --port 5173, the framework will ignore the launcher's PORT env. Either swap for a clean direct-binary call (pnpm exec next dev) or add a new dev:app-it script without the literal. For Vite specifically, prefer START_COMMAND="npm run dev -- --port \$PORT" over editing vite.config.ts (CLI flag wins over config literal in vanilla single-server projects).electron, electron-builder, tauri, nw.js, pkg, or nativefier is already present — strong signal, use it (Strategy B).concurrently/npm-run-all/turbo run dev/pnpm -r dev in scripts.dev; a proxy block in vite.config.*/next.config.* targeting a different localhost: port; a separate server/ directory with its own start script. → A3 multi-server. See Strategy A3.grep -RnIE "showDirectoryPicker|FileSystemDirectoryHandle|FileSystemFileHandle" --include='*.{ts,tsx,js,jsx}' src/ — any usage at all → polyfill candidate.grep -RnIE "\.createWritable\(|\.getFile\(\)|writable\.write\(" --include='*.{ts,tsx,js,jsx}' src/ services/ — real-I/O usage → polyfill cannot satisfy this; route to A1 chrome-fallback (Chrome supports FSA natively) or Strategy D.command -v swiftc. If absent, A1 chrome-fallback; document the warts. The build script auto-detects and falls back.manifest.json first when present. Reject icons whose filenames mirror src/features/<name>/ — those are content, not the app's own mark.package.json name, metadata.json name, in-app titles, and recent commit subjects disagree, score by priority: recent commit subjects (user's actual vocabulary) → displayName → human-looking metadata.json name → folder humanized → package.json name last and only if not slug-shaped. Reject package.json names containing --- or matching scaffold patterns (vite-project, next-app). Surface conflicts in the report so the user can override.com.user.<slug> as the default. Reject com.$(id -un).* — LaunchServices treats it as a personal-team developer prefix and refuses unsigned bundles with _LSOpenURLs… error -600 / procNotFound. Country-coded reverse-DNS (dk.example.app) is also a clean choice for projects with a real domain.~/Applications/App It/ (auto-create if missing) unless the user explicitly requested ~/Desktop/MyApps/, /Applications/, or another path.$0 after install.For each app detected, pick one strategy:
Existing Electron/Tauri/NW.js config for this app?
├── YES → Strategy B
└── NO →
Hard requirement for native menu bar / tray / file associations / shipping signed?
├── YES → Strategy D (Tauri wrapper)
└── NO →
FSA real-I/O usage? (createWritable / getFile-then-blob)
├── YES → A1 chrome-fallback (Chrome supports FSA natively, zero rewrite)
└── NO →
Other Chromium-only Web APIs needed? (Web USB/Bluetooth/HID/MIDI)
├── YES → A1 chrome-fallback
└── NO →
Static built bundle, no server?
├── YES → A2
└── NO →
Cohabiting frontend + backend?
├── YES → A3 (one .app starts both)
└── NO → A1 native (DEFAULT)
Within Strategy A1, choose:
swiftc.swiftc unavailable AND user can't run xcode-select --install. Documented warts.index.html, no server..app that spawns Terminal. Flag loudly in the report.PWA install (formerly Strategy C) is no longer a primary path — when the project has a manifest, also ship a Strategy A .app and mention the PWA install option in the doc.
Touch as few project files as possible. Allowed additions:
assets/<slug>-icon.{png,svg} per app (or assets/app-icon.{png,svg} if single-app).assets/icons/ — generated icon artifacts (gitignore the contents).assets/icons/build/wrapper — compiled Swift binary (gitignore).scripts/wrapper.swift, scripts/run-template*.sh, scripts/info-plist-template.xml, scripts/desktop-*.sh, scripts/inspect.sh, scripts/placeholder-icon-gen.sh — copied verbatim from templates/.scripts/app-it.config.json — single source of truth for the APPS list (see below).assets/<slug>-polyfill.js — only when FSA usage is detected.desktop/<AppName>.app/ per app (gitignore — regenerated by build).docs/desktop-launcher.md.docs/desktop-launcher.app-it-report.md — agent decision provenance (see Phase 5).package.json scripts entries: desktop:build, desktop:icons, desktop:install, desktop:quit.Single source of truth: scripts/app-it.config.json
{
"apps": [
{
"name": "Momó Studio",
"slug": "momo-studio",
"port": 5173,
"start_command": "npm run dev -- --port $PORT",
"bundle_id": "com.user.momo-studio",
"version": "0.1.0",
"polyfill_path": ""
}
]
}
For A3 multi-server, add "backend_port" and "backend_start_command". The build script reads this file; desktop-quit.sh reads it too — no APPS-table drift between scripts. (For backward compat, the build script also accepts a bash APPS=(...) array if no JSON is present.)
Substitution placeholders baked into the run-script at build time:
__APP_NAME__, __APP_SLUG__ — display name (may be non-ASCII), file-safe slug.__PROJECT_ROOT__ — absolute path to repo, baked at build time.__PORT__ — preferred port. Launcher tries first, scans upward for free port if taken, records actual runtime port to ~/Library/Application Support/app-it/<slug>/server.port.__START_COMMAND__ — must honor PORT env. See Framework PORT cheat sheet.__BUNDLE_ID__, __VERSION__ — reverse-DNS bundle id, marketing version.__POLYFILL_PATH__ — absolute path to a JS polyfill file (empty if none).Config-file edits to make ports env-driven are expected and necessary (not a violation of "don't touch app source"). You MAY (and often MUST) edit:
vite.config.*, next.config.*, webpack.config.*) to read process.env.PORT and route proxy targets through process.env.API_PORT (multi-server case).server/index.{ts,js,py}, app.py) to read API_PORT before falling back to PORT — needed for cohabiting projects where --env-file=.env injection of PORT=... would otherwise override the launcher.strictPort: true to Vite configs so the launcher's port allocation isn't silently overridden by Vite's own bump-on-collision.Edits should be minimal and additive (env-var reads with sensible defaults), so existing developer workflows (npm run dev from terminal without env vars set) keep working unchanged.
Never:
For each .app, run the checks below. Three buckets — never claim success in a bucket the agent can't actually verify.
| # | Check | Programmatic | Idiom |
|---|---|---|---|
| 1 | Build succeeded | [x] | .app exists; file <wrapper> reports Mach-O … executable; file <AppIcon.icns> reports Mac OS X icon |
| 2 | Bundle metadata | [x] | /usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' .../Info.plist; … CFBundleName; substituted, no __PLACEHOLDER__ left |
| 3 | Runtime port discovery | [x] | RUNTIME_PORT=$(cat "$HOME/Library/Application Support/app-it/<slug>/server.port") — always read this first, never hardcode PREFERRED_PORT |
| 4 | Server responding | [x] | curl -sS -o /dev/null -w "%{http_code}" http://localhost:$RUNTIME_PORT — any non-000 counts (5xx is a project-state issue, not a launcher issue) |
| 5 | Wrapper alive, single instance | [x] | pgrep -af "<App>.app/Contents/MacOS/wrapper" exactly 1 row (use bundle-name path, not bare wrapper, to avoid cross-app noise) |
| 6 | Bundle identity registered | [x] | lsappinfo info -only bundleid <ASN> matches <bundle-id> from config |
| 7 | Cmd+Q kills server tree | [x] | osascript -e 'tell application id "<bundle-id>" to quit', then lsof -ti tcp:$RUNTIME_PORT is empty within 2s. Multi-server (A3.2): also assert lsof -ti tcp:$BACKEND_RUNTIME_PORT is empty (read from ~/Library/Application Support/app-it/<slug>/backend.port). wrapper.swift discovers backend.pid/backend.port as siblings of server.pid; if the backend leaks, the sibling-discovery code path is broken or the multiserver template stopped writing those files. Never use kill -TERM to wrapper PID — bypasses applicationShouldTerminate and gives a false-fail. |
| 8 | Red-X leaves server warm | [x] | osascript -e 'tell application id "<bundle-id>" to close every window'; lsof -ti tcp:$RUNTIME_PORT is non-empty 1s later |
| 9 | Warm re-launch fast | [x] | re-open; HTTP 200 within ~250ms (cold-start would be 3s+); confirms F38 reattach gate works for this START_COMMAND shape |
| 10 | Install path opens cleanly | [x] | open "$HOME/Applications/App It/<App>.app"; echo "exit=$?" — must be 0. Never substitute open <build-path> — different LS paths, different failure modes. |
| 11 | Install path matches build | [x] | lsregister -dump 2>/dev/null | grep -B1 "<bundle-id>" | head — exactly one entry; if two, run lsregister -u <build-path> |
| 12 | Window shows app content (not error page) | [ ] needs human | unless display available |
| 13 | Dock icon is OUR icon (not Chrome's, not Safari's) | [ ] needs human | unless display available |
| 14 | Autoplay video plays without user click (if media) | [ ] needs human | |
| 15 | FSA reconnect-on-load works (if FSA polyfill) | [ ] needs human | |
| 16 | Standard keyboard shortcuts respond | [ ] needs human | Cmd+Q kills app+server; Cmd+W closes window leaving server warm; Cmd+R reloads; Cmd+Shift+R force-reloads; Cmd+-/=/0 zoom out/in/reset; Cmd+M minimizes; Cmd+Ctrl+F fullscreen; Edit menu (Cmd+X/C/V/Z/A). All wired in wrapper.swift's buildMenu(). Programmatic check: grep -qboa "reloadPageIgnoringCache" app.app/Contents/MacOS/wrapper — exits 0 if shortcuts are present. Do NOT use strings | grep "Force Reload" — Swift -O inlines string literals in a format strings misses. If absent: the installed wrapper is a pre-menu-bar binary — run desktop:build && desktop:install in that project. |
Defer-and-document bucket: when the agent's environment makes verification hostile — same-project dev server already running on the preferred port (would corrupt .next/ cache via competing Turbopack), or different-project holding a port that this project's launcher can't fall back from (hardcoded proxy target) — do not spawn a competing instance. Mark these [ ] deferred — env hostile, write the user-action one-liner in the report (e.g., pkill -f "next dev.*$PROJECT_ROOT" && open "$HOME/Applications/App It/<App>.app").
Pre-flight smoke test before clicking the .app (separates project-broken from launcher-broken):
( cd "$PROJECT_ROOT_BAKED" && PORT=$SMOKE_PORT timeout 30 bash -c "$START_COMMAND" ) &
SMOKE_PID=$!
# poll for HTTP, then kill
If smoke fails, report launcher-built-but-project-broken — not launcher-broken.
If GUI verification is impossible (sandboxed environment, no display), say so explicitly under Known limitations — don't claim success.
Two outputs:
docs/desktop-launcher.app-it-report.md written to disk — same content plus a ## Decision history section that future agent sessions append to. Cost is zero (the agent already produced the content). Future sessions skim this before re-deriving anything.Stage new files with git add; do not create a commit unless the user explicitly asks.
desktop-build.sh ends with an ad-hoc codesign step that satisfies macOS 15+ (Sequoia / Tahoe) Gatekeeper without needing an Apple Developer account:
/usr/bin/xattr -cr "$APP_DIR" # strip iCloud/Finder metadata first
/usr/bin/codesign --force --deep --sign - "$APP_DIR" # ad-hoc (self) signature
This is automatic — no action needed at build time. The verification table row 10 (open exits 0) is the practical Gatekeeper test. spctl --assess will say "rejected" for ad-hoc bundles — that is normal and expected; ignore it.
Rescue: app shows ⊘ prohibition symbol after a macOS update
Apps built before the codesign step was added show ⊘ in Finder and refuse to open. Preferred fix is to rebuild each project (desktop:build && desktop:install), which re-compiles the wrapper and re-signs. If rebuilding is impractical, sign in place:
cd "$HOME/Applications/App It"
for app in *.app; do
/usr/bin/xattr -cr "$app" 2>/dev/null || true
/usr/bin/codesign --force --deep --sign - "$app" 2>/dev/null && echo "OK: $app"
done
iCloud-synced apps (Desktop / Documents with iCloud Drive enabled): macOS adds com.apple.fileprovider.fpfs#P to directories in iCloud-synced folders. This xattr is system-protected — xattr -cr doesn't remove it, and codesign refuses to sign bundles that have it. Fix: copy without metadata, sign clean, replace:
app="Broken App.app"
ditto --noextattr --norsrc "$app" /tmp/clean.app
/usr/bin/codesign --force --deep --sign - /tmp/clean.app
mv -f "$app" "${app}.bak" && mv /tmp/clean.app "$app" && rm -rf "${app}.bak"
After signing, clear the Launch Services cached verdict and restart Finder:
LSREG="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
"$LSREG" -f "$PWD/$app"
killall Finder
Keyboard shortcuts — old wrapper binaries: wrapper.swift's buildMenu() ships the full standard macOS menu bar. If a user reports missing shortcuts (Cmd+R, Cmd+-, Cmd+=, etc.), their installed wrapper was compiled before buildMenu() was added. Running desktop:build && desktop:install in the project directory recompiles and reinstalls the wrapper with the current template.
Strong signals (treat as multi-app):
apps/*/package.json, packages/*/package.json with dev/start scripts; turbo.json, nx.json, pnpm-workspace.yaml, lerna.json listing multiple apps.dev:* or start:* scripts in root package.json running on different ports.sanity dev) alongside a main web app.Weak/false signals (treat as single-app):
/admin, /studio on the same dev server).apps/api (server only, no UI) — bundle with the frontend that consumes it (A3 cohabiting).public/app-icons/{ToolA,ToolB}_Icon.png typically denote in-app feature branding, NOT separate apps. Cross-check against src/features/. If every icon's filename maps to a feature, they're content — build one .app for the parent project.Naming: Single app — name after the project. Multi-app — prefix or suffix consistently (Momo.app, Momo Studio.app).
Why this is the default and not Chrome --app=:
| Issue | Chrome --app= | Native WebKit shell |
|---|---|---|
| Dock icon while window open | Chrome's icon, not ours | Ours |
| Re-click while window open | Opens a duplicate window | Activates existing window |
| Window-startup latency | Multi-second profile init | ~200 ms |
| Cmd+Q vs red-X | Indistinguishable | Distinguishable |
| Single-instance | Manual AppleScript hack | Native via NSApplication |
Issues 1–3 are structural to Chrome — not patchable. They surface within minutes of daily use.
Bundle layout:
desktop/<AppName>.app/
Contents/
Info.plist # CFBundleExecutable = "run"
MacOS/
run # bash launcher (server boot + exec)
wrapper # compiled Swift WKWebView shell (universal)
Resources/
AppIcon.icns # generated by desktop-icons.sh
Shipped runtime defenses (don't reimplement, don't drop):
[PREFERRED..PREFERRED+50] at click time, picks first free port, records actual port to ~/Library/Application Support/app-it/<slug>/server.port. Sibling appified apps coexist without coordination.pnpm dev) is treated as the root of an ownership tree — actual listener (next-server) can be a great-grandchild. Warm re-launch reattaches in ~250ms even for pnpm/npm/yarn/bun/concurrently supervisor chains.setsid daemonization. Detaches the dev server from the wrapper's process group so SIGHUP propagation can't kill it on wrapper exit.PROJECT_ROOT exists, command -v <START_COMMAND-bin> resolves under augmented PATH, node_modules/.bin/<framework> exists when applicable. Surfaces actionable alert in <1s instead of 60s misdirected timeout.desktop-quit.sh. TERM the recorded PID tree → port-sweep stragglers → SIGKILL holdouts. Catches reparented children that single-stage cleanup misses.wrapper.swift::killServer(). Cmd+Q on a multi-server (A3.2) .app would otherwise leak the backend — the wrapper only knows the FE pid/port via argv. The wrapper looks for backend.pid and backend.port as siblings of the FE pid file and tears them down on quit. No-op for single-server. Discovered 2026-04-29 on Music Videolizer (FE :5173 freed in <1s, BE :3002 kept listening).PROJECT_ROOT is baked at build time. Honors APP_IT_PROJECT_ROOT env override. Never derive from $0's parent — the .app is copied to ~/Applications/App It/ on install.
--app=Use when:
swiftc unavailable AND xcode-select --install not feasible, ORhandle.createWritable() returns WritableStream, handle.getFile() returns blob), ORThe Chrome template ships with feature parity to the Swift template: runtime port-fallback, server.port recording, two-stage readiness probe, expanded PATH. Documented warts that remain:
desktop-quit.sh. Mark Cmd+Q-kills-daemon [ ] needs desktop:quit in Phase 4, document desktop:quit as the primary shutdown command in docs/desktop-launcher.md.Opt-in APP_IT_CHROME_KEEP_WARM=0 makes Chrome exit also tear down the daemon (loses the warm-server benefit).
Adapt run-template.sh: drop the daemon-server block, point the URL at file://$PROJECT_ROOT/<dist>/index.html, hand off to wrapper. Pass empty string for the port argv.
For a finished/buildable app whose whole point is to skip the dev server, the
app-it-staticcompanion skill is the better tool: it detects the build command and output dir, builds once, and serves the result from a tiny static server orfile://— with a proper snapshot/desktop:rebuildmodel. Use it when static serving is the goal, not just an A2 corner case.
Three sub-strategies — pick by project context:
If the project already has multi-process orchestration (concurrently, npm-run-all -p, turbo run dev, pnpm -r dev, custom scripts/dev.sh), use it as a single START_COMMAND in the standard A1 template. Strictly simpler than reimplementing parallel-spawn. The orchestrator's signal-forwarding tears down both children on TERM; desktop-quit.sh's port-sweep catches stragglers. ~30 lines instead of ~120.
run-template-multiserver.sh with env-driven portsWhen no orchestrator exists or the orchestrator misbehaves on signals. The shipped run-template-multiserver.sh allocates two ports (FE + BE), exports them as distinct env vars (PORT, API_PORT), boots both via sequential setsid spawn, waits for the frontend port, records both ports. wrapper.swift's killServer() discovers backend.pid / backend.port as siblings of the FE pid file in ~/Library/Application Support/app-it/<slug>/, so Cmd+Q tears down both servers without further argv plumbing — desktop-quit.sh is the defensive fallback for re-parented children, not the primary path.
Required edits (carve-out from "don't touch app source"):
server.port reads process.env.PORT; strictPort: true (Vite); proxy target reads process.env.API_PORT.process.env.API_PORT before process.env.PORT so --env-file=.env injection of PORT=... doesn't override the launcher.For Vite + Express specifically, this means three edits to vite.config.ts (server.port, strictPort, proxy.target) and one to server/index.ts (API_PORT first). The skill's anti-pattern "don't touch app source" explicitly carves these out — they make ports env-driven, which is what the launcher needs. They're additive and don't break terminal npm run dev.
When the project's .env and tooling depend on the literal port (e.g. proxy target at localhost:3001 is referenced from many places, user explicitly didn't ask for source edits), the launcher refuses to start with a clear alert if either fixed port is busy. No source edits. The behavioral contract is "your project's daily-development setup, made clickable" — not refactored. Document the trade in §12 of the report so the user can flip if they want fallback later.
Builds anyway, flags loudly. Spawns Terminal because there's no other way to show output.
exec /usr/bin/osascript -e "tell application \"Terminal\" to do script \"cd '$PROJECT_ROOT' && $START_COMMAND\""
Repo already has it — use it. Do not stack Strategy A on top.
desktop:build → electron-builder --mac. Wire icons via build.icon in package.json, pointed at assets/app-icon.png. desktop-install.sh copies from dist//out/.desktop:build → tauri build. Regenerate icons with tauri icon assets/app-icon.png. Output .app at src-tauri/target/release/bundle/macos/.nw-builder.Point each build to assets/app-icon.png — one file for the user to replace later.
Reach for D only when Strategy A genuinely can't deliver:
(FSA real-I/O no longer routes here — A1 chrome-fallback is the lower-effort answer.)
Default to Tauri. Minimum config wrapping the existing app (devPath at the running port, distDir at the built output, beforeDevCommand and beforeBuildCommand pointing at the existing scripts).
Per app, search in this order before considering a placeholder:
manifest.json (or app/manifest.{json,ts}, static/manifest.json) — parse it and prefer the largest declared icon with purpose containing any or maskable. The project already curated this; don't re-derive.app-icon.*, app_icon.*, appicon.*, icon.png, icon.svg, icon@*.png, *.icns, *.ico in ./, assets/, public/, static/, src/assets/, app/, resources/, images/.logo.*, brand.*, mark.*, logo-square.*, logo-mark.*. Prefer ≥ 512×512.favicon.svg, favicon-512.png, apple-touch-icon.png, apple-icon.png, app/icon.png (Next.js convention). Ignore 32×32 favicons when anything larger exists.templates/placeholder-icon-gen.sh): parses globals.css --color-* custom properties and emits a 30-line SVG keyed to the project's palette. Preferred over a single-letter monogram on a flat color.For each candidate:
find ... -size +10k or file MIME type to avoid .gitkeep artifacts.public/app-icons/<Tool>_Icon.png) outrank the master mark by resolution, cross-check against src/features/<name>/. If filenames map 1:1 to features, they're content — pick the lower-res project-named master instead.Decision rule: pick the single best source per app, copy to assets/<slug>-icon.png (or assets/app-icon.png for single-app). The user must have one file per app to replace later.
WebKit does not implement File System Access. Apps that gate on 'showDirectoryPicker' in window will show "Browser not supported" inside the Swift wrapper unless polyfilled.
When to use the polyfill (A1 native):
showDirectoryPicker() to pick a workspace folder.When NOT to use it (route to A1 chrome-fallback or D):
handle.getFile() → blob.handle.createWritable() → WritableStream from JS.
Synthetic handles can't satisfy this contract.How to apply:
createWritable, getFile).indexedDB.open(, createObjectStore(, the workspace-handle key.templates/fsa-polyfill-template.js to assets/<slug>-polyfill.js.__WORKSPACE_PATH__, __WORKSPACE_NAME__, __APP_DB_NAME__, __APP_STORE_NAME__, __APP_KEY_NAME__.polyfill_path in app-it.config.json to @ROOT@/assets/<slug>-polyfill.js.documentStart.If handle.getDirectoryHandle('subdir', {create: true}) is expected to land real files: pre-create directories in desktop-build.sh or run-template.sh.
Hard-won from real-project iteration. Do not rediscover these:
--app= as the default for vanilla web apps — it steals the Dock icon, breaks single-instance, is slower. Use Swift. Exception: chrome-fallback IS the right answer when the app needs Chromium-only APIs (FSA real-I/O, Web USB/Bluetooth/HID/MIDI).$PREFERRED_PORT, scan upward and start your own. Even when the existing server seems like it must be ours (matching path, matching framework), the cost of being wrong is showing the user another project's UI inside your app's window. The descendant-walk reattach gate enforces this; don't replace it with a bare curl 200 → attach.osascript to dedup Chrome --app= windows. Fragile, requires Accessibility permission.WKPreferences private SPI for autoplay. Keys throw NSUnknownKeyException, crash happens in applicationDidFinishLaunching before the WebView is constructed. The fix in wrapper.swift is a synthetic NSEvent mouseDown/mouseUp pair after first navigation — that counts as a real platform gesture.pgrep -f on paths with non-ASCII characters. macOS stores command lines in NFD; shell strings are typically NFC. The templates key on URL/port (ASCII). When matching wrappers, use <App>.app/Contents/MacOS/wrapper (bundle-name path) — the bundle name is uniquely identifying and .app/ is ASCII even when the bundle name itself contains accented characters.curl HTTP 200 as page-works verification. Several "should work" theories pass curl and still show a blank window in the wrapper. Verification requires opening the actual .app and seeing the actual content. Read server.port first, then curl that port, never the configured port.kill -TERM against the wrapper PID to verify Cmd+Q semantics. Signals bypass AppKit's lifecycle. Use osascript -e 'tell application id "<bundle-id>" to quit' — that sends a Quit Apple Event, routing through applicationShouldTerminate, which is the real Cmd+Q code path.PROJECT_ROOT from $0's parent. The .app is copied to ~/Applications/App It/ on install. Bake the absolute repo path at build time via the build script's ROOT="$(cd "$(dirname "$0")/.." && pwd)", honoring APP_IT_PROJECT_ROOT env override for worktree workflows. The launcher refuses to start if the path no longer exists.node_modules from main into a worktree. Turbopack and several other bundlers reject it (Symlink node_modules is invalid, it points out of the filesystem root). The only correct answer is baking the canonical path.launchd before the trap fires. Use the two-stage pattern in desktop-quit.sh: TERM the recorded PID tree → sweep lsof -ti tcp:$PORT with TERM → wait 1.5s → SIGKILL stragglers.PATH=/usr/bin:/bin. The shipped template covers Homebrew, nvm-latest, pnpm-store, Bun ($HOME/.bun/bin), Deno ($HOME/.deno/bin), Volta ($HOME/.volta/bin), mise/asdf shims, cargo. Don't strip entries when adapting.windowShouldClose setting a flag that applicationShouldTerminate checks.npm run dev blindly. If a dev script wraps the dev-server binary in a TTY-assuming launcher (ASCII-art mascot, ANSI cursor escapes, interactive prompt), Finder/Dock launches have no TTY and the wrapper hangs. Read the script. Prefer dev:server/dev:vite/the bare command. If npm run start (production build) makes more sense for daily use, prefer it.-p/--port literal. The launcher's chosen port is silently ignored. Either swap for a clean direct-binary call (pnpm exec next dev) or add a dev:app-it script without the literal.com.$(id -un).* as the bundle ID prefix. LaunchServices may reject unsigned bundles claiming that personal-team identity with _LSOpenURLs… error -600 / procNotFound. The build script warns; you should reject. Default to com.user.<slug> or country-coded reverse-DNS.START_COMMAND if you want auto-fallback. Write the command so PORT env flows through. Vite needs --port "$PORT" (not just PORT= env); see Framework PORT cheat sheet.assets/, desktop/, scripts/, docs/, package.json scripts. Carve-out: edits to vite.config.* / next.config.* / server/index.{ts,js,py} to make ports env-driven are expected and necessary — see Phase 3..app. One .app per user-facing app.Cmd+=, Cmd+R, and similar shortcuts before NSApplication.performKeyEquivalent: runs. The template's installKeyboardShortcutMonitor() fixes this by catching those events first via NSEvent.addLocalMonitorForEvents. Do not remove the monitor or move these shortcuts back to menu-only.ditto --noextattr rescue in the Gatekeeper section handles this without rebuilding. Re-sign after every major macOS upgrade if apps show ⊘.wrapper.swift has had buildMenu() since early 2026; apps built before that only get AppKit's default Cmd+Q stub. Any report of missing Cmd+R / zoom / Cmd+W means the installed wrapper is stale — rebuild, don't patch.| Framework | Default behavior | What START_COMMAND should do |
|---|---|---|
Next.js (next dev) | reads PORT env, exits if busy | nothing — works out of the box. But check package.json "dev" for hardcoded -p N; if present, replace with pnpm exec next dev (or add dev:app-it). |
| Vite (vanilla, no proxy) | reads config's server.port literal; strictPort: false silently bumps | npm run dev -- --port "$PORT" — CLI flag wins over config literal. No source edits. |
| Vite (cohabiting w/ proxy) | as above, plus proxy target hardcoded | edit vite.config.ts: server.port reads process.env.PORT; strictPort: true; server.proxy.<route>.target reads process.env.API_PORT. |
| Express (typical) | process.env.PORT || 3001 | none — works. For cohabiting, rename to API_PORT in the entrypoint. |
| Flask | reads PORT/FLASK_RUN_PORT env | none. |
CRA (react-scripts start) | reads PORT env | none. |
| Astro 4+ | reads PORT env | none. |
| Astro 3 | needs --port flag | embed --port "$PORT" in START_COMMAND. |
| Docusaurus | needs --port flag | embed --port "$PORT". |
Recommended PORT-respecting invocations per package manager:
pnpm exec <bin> (bypasses package.json wrapper script)npx <bin> or npm exec -- <bin>yarn <bin> or yarn exec <bin>bunx <bin> or bun x <bin>python -m <module>package.json script namingSingle-app:
{
"scripts": {
"desktop:icons": "APP_NAME='MyApp' APP_SLUG='myapp' ./scripts/desktop-icons.sh",
"desktop:build": "./scripts/desktop-build.sh",
"desktop:install": "./scripts/desktop-install.sh",
"desktop:quit": "./scripts/desktop-quit.sh"
}
}
Multi-app (per-app icon variants, aggregate build/install/quit):
{
"scripts": {
"desktop:icons:main": "APP_NAME='Momo' APP_SLUG='momo' ./scripts/desktop-icons.sh",
"desktop:icons:studio": "APP_NAME='Momo Studio' APP_SLUG='momo-studio' ./scripts/desktop-icons.sh",
"desktop:build": "./scripts/desktop-build.sh",
"desktop:install": "./scripts/desktop-install.sh",
"desktop:quit": "./scripts/desktop-quit.sh"
}
}
If the project doesn't have package.json, expose the same commands via Makefile or a top-level shell script.
docs/desktop-launcher.md)Always write this file from templates/desktop-launcher.md.template. Keep under one screen. The first post-title section must be First launch:
First launch
- Right-click the app icon and choose Open, then click Open in the dialog. macOS will remember and skip this on subsequent launches (Gatekeeper, unsigned bundle).
- The first cold start takes 5–15 s while the dev server compiles.
- If a "couldn't be opened" alert appears citing the dev server, open
~/Library/Logs/app-it/<slug>/server.log. The alert quotes the tail; the full log usually shows the cause.
For chrome-fallback launchers, document desktop:quit as the primary shutdown command (Cmd+Q does not kill the daemon).
Linux — ~/.local/share/applications/<slug>.desktop Desktop Entry; update-desktop-database.
Windows — .lnk shortcut via PowerShell pointing at launcher.bat mirroring run-template.sh. ImageMagick for .ico. NSIS or Inno Setup if installer needed.
The Swift WebKit shell is macOS-only. On Windows/Linux, the equivalent is Tauri (Strategy D).
End every app-it session with exactly this report. No section omitted; "n/a" if truly inapplicable. Inline in chat and written to docs/desktop-launcher.app-it-report.md.
## App-it report
**1. Project type detected:**
<e.g. pnpm monorepo, Vite + React on :5173, Next.js 16 on :3000, no existing desktop config, swiftc available, worktree at .claude/worktrees/<name>/>
**1.5. Name resolution** *(if multiple naming sources disagreed)*
Picked: "<chosen>". Sources surveyed: <folder>, <package.json name>, <metadata.json>, <recent commits>. Reason: <one line>. To override: edit `scripts/app-it.config.json`, then desktop:build && desktop:install.
**2. Apps detected:** <N>
- **<AppName 1>** — <runtime shape, port, start command>
**3. Strategy chosen per app:**
- <AppName 1>: <A1 native | A1 chrome-fallback | A2 static | A3.1 reuse-orchestrator | A3.2 multi-server-template | A3.3 refuse-on-collision | A4 CLI | B | D> — <one-line name>
**4. Why these are the lowest-effort robust approaches:**
<2–4 sentences. What was ruled out and why. Mention if Chrome was ruled out due to Dock-icon/single-instance issues, or chosen because of FSA real-I/O / Chromium-only APIs.>
**5. Files added/changed:**
- `assets/<slug>-icon.png` per app (sources listed in §6)
- `assets/<slug>-polyfill.js` per app *(if FSA polyfill needed)*
- `desktop/<AppName>.app/...`
- `scripts/wrapper.swift`, `scripts/run-template*.sh`, `scripts/info-plist-template.xml`
- `scripts/desktop-build.sh`, `scripts/desktop-icons.sh`, `scripts/desktop-install.sh`, `scripts/desktop-quit.sh`
- `scripts/inspect.sh`, `scripts/placeholder-icon-gen.sh` *(if used)*
- `scripts/app-it.config.json`
- *(if A3.2)* `vite.config.ts` / `server/index.ts` edits — env-driven ports
- `package.json` — added scripts
- `docs/desktop-launcher.md`, `docs/desktop-launcher.app-it-report.md`
- `.gitignore` — added: `desktop/`, `assets/icons/build/`, `assets/icons/<slug>/`
**6. Icon source per app:**
- <AppName 1>: `<path>` — <resolution>, <why this beat alternatives>. Considered: <list>.
**7. To change an app icon later:**
Replace `assets/<slug>-icon.png`, then `pnpm desktop:icons:<app> && pnpm desktop:build && pnpm desktop:install`. The install step refreshes the Dock and Finder icon caches automatically.
**8. Build / install / quit commands:**
- Build: `pnpm desktop:build`
- Install: `pnpm desktop:install` (→ ~/Applications/App It/)
- Quit: `pnpm desktop:quit` (stops daemonized servers)
**9. Generated launcher locations:**
- Repo: `desktop/<AppName>.app`
- Installed: `~/Applications/App It/<AppName>.app`
- Runtime port (after first click): `~/Library/Application Support/app-it/<slug>/server.port`
**10. Verification (per app):**
- [x] Build succeeded; `.app` exists; wrapper is universal Mach-O; `.icns` is multi-resolution
- [x] Bundle metadata correct (no `__PLACEHOLDER__` leakage)
- [x] Cold launch: `server.port` recorded; HTTP responds on runtime port
- [x] Single instance; `lsappinfo` confirms bundle id
- [x] Cmd+Q (via osascript) kills server tree
- [x] Red-X leaves server warm
- [x] Warm re-launch responds in ~250ms (descendant-walk reattach works)
- [x] Install-path open exits 0; `lsregister` shows exactly one entry
- [ ] needs human: window content, Dock icon identity, autoplay (if media), FSA reconnect (if polyfill)
- [ ] deferred — env hostile: <reason, with user-action one-liner> *(if applicable)*
**11. Dock Stack:**
- [x] `~/Applications/App It/` exists
- [ ] User has dragged `~/Applications/App It/` to the right side of the Dock (one-time setup; mention if not done)
**12. Known limitations:**
- <e.g. unsigned bundle — Gatekeeper warns on first launch>
- <e.g. WebKit, not Chromium — open in regular Chrome for Chromium devtools>
- <e.g. baked PROJECT_ROOT — re-run desktop:build if repo moves>
- <e.g. Chrome fallback used for FSA real-I/O — Dock icon may show Chrome's, re-clicks open duplicates, Cmd+Q does not kill daemon (use desktop:quit)>
- <e.g. worktree — rebuild from main checkout after merge>
- <e.g. arm64+x86_64 universal binary>
## Decision history
- <YYYY-MM-DD>: Initial build (Strategy <X>, bundle-id <Y>, port <P> → fallback to <P'>, icon: <source>).
- <next session appends here>
| Signal | Strategy | Notes |
|---|---|---|
next.config.*, dev on :3000 | A1 native | Check dev script for -p N literal; bypass via pnpm exec next dev if found. |
vite.config.* + existing dist/ | A2 | Static — file:// URL, no server. |
vite.config.* no build (vanilla) | A1 native | START_COMMAND="npm run dev -- --port \$PORT" — CLI flag wins over config literal. |
vite.config.* + proxy block | A3.2 | Make ports env-driven (3 vite-config edits + 1 server-entry edit). |
concurrently / npm-run-all -p / turbo run dev in dev | A3.1 | Reuse orchestrator as single START_COMMAND. |
apps/web + apps/api (cohabiting, no orchestrator) | A3.2 | Multi-server template. |
apps/web + apps/studio (separate) | A1 native × 2 | Two .apps. |
Sanity sanity.config.* alongside web | A1 native × 2 | One for web, one for sanity dev. |
package.json with electron | B | Use electron-builder. |
src-tauri/ | B | tauri build. |
index.html at root, no build | A2 | file:// URL. |
manifest.json + service worker | A1 native | Build the .app; mention PWA install in the doc. |
| Flask / FastAPI | A1 native | Activate venv inside run if present; python -m foo as START_COMMAND. |
| Pure Python CLI (no UI) | A4 | Spawns Terminal — flag in limitations. |
Existing electron-builder.yml | B | Don't add A on top. |
App uses showDirectoryPicker (no real I/O) | A1 native + FSA polyfill | Grep IDB names; customize fsa-polyfill-template.js. |
App reads/writes via getFile/createWritable | A1 chrome-fallback | Chrome supports FSA natively. Document Cmd+Q-needs-desktop:quit. |
| App needs Web USB/Bluetooth/HID/MIDI | A1 chrome-fallback | Chromium-only APIs. |
swiftc not available | A1 chrome-fallback | Suggest xcode-select --install; fall back if user can't. |
Bun (bun run dev) | A1 native | Shipped PATH includes $HOME/.bun/bin. |
Worktree (.claude/worktrees/<name>/) | strategy depends | See Phase 1 step 1 — bypass / env-override / bake-and-document. |