diff --git a/README.md b/README.md index 3e65c0d..d8bb826 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # app-it -Turn a local web project into a macOS Dock-launchable `.app` bundle — a native window, its own Dock icon, and clean start/stop — **without Electron, Tauri, or a rewrite.** +Turn a local web project or hosted Claude Artifact into a macOS Dock-launchable `.app` bundle — a native window, its own Dock icon, and clean start/stop — **without Electron, Tauri, or a rewrite.** > **Unofficial community project** — not affiliated with, endorsed by, or sponsored by Anthropic or OpenAI. "Claude Code" and "Codex" are their respective owners' marks, named here only to say which assistants app-it plugs into. This is an independent open-source tool built by one developer. @@ -12,14 +12,14 @@ Turn a local web project into a macOS Dock-launchable `.app` bundle — a native **Windows beta** — macOS is in daily use; Windows is an early beta, now with its first real-hardware fixes but still needing more. A complete sibling plugin (`plugins/app-it-windows/`), gated by a required `windows-latest` CI job (build · PowerShell lint · manifest parse · icon round-trip), mirrors the macOS contract with Windows primitives. The author runs only macOS, so for a long time it had never touched real Windows hardware; that changed with [#8](https://github.com/Christian-Katzmann/app-it/pull/8), the first run on an actual Windows machine, and a run of fixes since — a real start, not a finish line. If you're on Windows and want to help harden it, the doorway is [docs/WINDOWS.md](docs/WINDOWS.md). -**Local-only** — app-it reads your project *on your machine* to choose a launcher strategy. It uploads nothing, runs no telemetry, adds no runtime dependencies, and never touches your business-logic source. The only thing it produces is an `.app` on your own Dock. See [Privacy](PRIVACY.md) and [Terms](TERMS.md) for the plain-language policy. +**Local-first** — app-it reads your project *on your machine* to choose a launcher strategy. It uploads nothing, runs no telemetry, adds no runtime dependencies, and never touches your business-logic source. The only thing it produces is an `.app` on your own Dock. For Claude Artifacts, the supported networked path is explicit: wrap a published/shared `claude.ai` artifact URL so Claude handles login and usage against each user's own plan. See [Privacy](PRIVACY.md) and [Terms](TERMS.md) for the plain-language policy. -`app-it` is an assistant-agnostic plugin/skill. It works with **Claude Code** and **Codex**, and builds a small, repeatable launcher around an existing local project so that double-clicking starts the dev server, opens a native window, keeps the Dock icon as *your* app, and cleans up when you quit. +`app-it` is an assistant-agnostic plugin/skill. It works with **Claude Code** and **Codex**, and builds a small, repeatable launcher around an existing local project or hosted artifact so that double-clicking starts the right runtime, opens a native window, keeps the Dock icon as *your* app, and cleans up when you quit. ## What app-it is not - **Not Electron, Tauri, or a native rewrite.** It wraps your existing dev setup; it doesn't replace it, migrate it, or add a bundler to your dependency tree. -- **Not a way to ship apps to other people.** No notarization, no App Store, no auto-update, no signed distribution. These are personal, ad-hoc-signed, local-use launchers. +- **Not a general signed distribution system.** No notarization, no App Store, no auto-update, no installer. Claude Artifact URL wrappers are self-contained enough to zip and share, but they are still ad-hoc-signed local bundles; recipients must trust the bundle and sign into Claude with their own account. - **Not cross-platform.** macOS only — and on purpose. Windows is a genuinely different problem (WebView2, `.lnk`, `.ico`, SmartScreen), so it belongs in a separate plugin rather than a blurred promise. See [Compatibility](docs/COMPATIBILITY.md). - **Not a hosted service.** Nothing runs in the cloud and there is no live demo to visit — the proof is the apps on your own Dock (the Stack further down is real). @@ -30,14 +30,15 @@ Turn a local web project into a macOS Dock-launchable `.app` bundle — a native ─────────────────────── ────────────────────── ─────────────────────────── a local web project inspects it from disk, YourApp.app on your Dock Vite React, SvelteKit, picks a strategy, then · its own icon - Astro, Next, or static ──▶ builds & signs a .app ──▶ · native window, one click - sites (`npm run dev`) around a WebKit shell · ⌘Q quits & frees the port + Astro, Next, static, or ──▶ builds & signs a .app ──▶ · native window, one click + Claude Artifact URL around a WebKit shell · ⌘Q quits & frees the port ``` Under the hood, app-it: - **Inspects before it touches anything** — project type, dev scripts, ports, browser-API needs, icon sources. - **Picks a launcher strategy** — a native Swift `WKWebView` shell by default (so the Dock icon stays *yours*), Chrome `--app` mode only when a project needs Chromium-only APIs. +- **Wraps hosted Claude Artifacts without shared secrets** — set `external_url`/`artifact_url` to a published artifact link; each user signs into Claude in the app window and uses their own Claude plan. - **Copies proven, hard-won templates** into the project rather than re-deriving fragile launcher logic each time. - **Builds and ad-hoc-signs a real `.app`** — universal (arm64 + x86_64), Gatekeeper-friendly, with a generated `.icns`. - **Gets the lifecycle right** — closing the window (⌘W / red-X) leaves the dev server warm for a ~250 ms re-launch; ⌘Q quits the app *and* frees the port. @@ -77,6 +78,8 @@ Then, from inside any local web project, ask your assistant: Natural triggers work too: *"make this clickable from the Dock"*, *"give this an icon"*, *"dockify this"*, *"package this as a local app"*. +For Claude Artifacts that call Claude from inside the artifact, use the hosted artifact link, not copied JSX source. A raw `.jsx` file can be packaged as an ordinary local React app only when it does not depend on Claude's hosted artifact runtime (`window.claude`, `window.storage`, MCP prompts, or Claude-provided auth). + *Optional:* for finished apps, also install the lighter companion — `claude plugin install app-it-static@app-it` (or `codex plugin add app-it-static@app-it`), then run `/app-it-static`. ### Local development (before publication) diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index bfec9bf..6d95c5e 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -9,10 +9,23 @@ | OS | macOS | | Primary shell | Swift `WKWebView` wrapper | | Fallback shell | Chrome `--app` mode | -| Common targets | Vite + React, SvelteKit, Astro, Next.js, static sites, local multi-server apps | +| Common targets | Vite + React, SvelteKit, Astro, Next.js, static sites, local multi-server apps, published Claude Artifact URLs | | Install destination | `~/Applications/App It/` by default | | Signing | Ad-hoc local code signing only | +## Claude Artifact URL wrappers + +`app-it` can build a URL-only `.app` around a published/shared Claude Artifact +link. That path intentionally keeps Claude in charge of authentication and +artifact runtime APIs: the bundle loads the hosted `claude.ai` URL, the user +signs into Claude inside the app window, and usage belongs to that user's plan. + +Raw JSX/TSX exported from an artifact is only a normal local web app source +file. If it calls Claude's hosted artifact APIs (`window.claude`, +`window.storage`, MCP prompts, or Claude-provided auth), do not shim credentials, +cookies, or API keys into the local bundle. Publish/share the artifact in Claude +and set `external_url` or `artifact_url` in `scripts/app-it.config.json`. + ## Companion · `app-it-static` For **finished or buildable** apps, the `app-it-static` sibling plugin serves the @@ -51,6 +64,7 @@ The author runs only macOS and will not dogfood the Windows build, so it ships a - Notarized distribution to other users. - Auto-update. - Installer generation. +- Local redistribution of Claude Artifact auth, session cookies, or API keys. - Production Electron or Tauri migrations. ## Why Windows Should Be Separate diff --git a/plugins/app-it-static/skills/app-it-static/templates/wrapper.swift b/plugins/app-it-static/skills/app-it-static/templates/wrapper.swift index b586d08..522450c 100644 --- a/plugins/app-it-static/skills/app-it-static/templates/wrapper.swift +++ b/plugins/app-it-static/skills/app-it-static/templates/wrapper.swift @@ -2,7 +2,7 @@ // .app bundle owns its window (and therefore its Dock icon, activation, and // single-instance semantics). // -// Usage: wrapper [app-name] [port] [pid-file] [polyfill-js-path] +// Usage: wrapper [app-name] [port] [pid-file] [polyfill-js-path] [allow-external-hosts] // url — http(s) URL to load (typically http://localhost:PORT) // app-name — window title and Dock badge (e.g. "Momó Studio") // port — optional, used by Cmd+Q only with PID ownership proof @@ -19,6 +19,10 @@ // The polyfill is the agent's responsibility — see the // skill's `fsa-polyfill-template.js` for a worked // example. Pass an empty string to skip injection. +// allow-external-hosts — optional literal. Used by URL-only launchers such as +// Claude Artifact wrappers, where auth, iframes, and API +// bridges must remain inside the hosted web app instead +// of being kicked out to the default browser. // // Build: swiftc wrapper.swift -o -framework Cocoa -framework WebKit // @@ -53,6 +57,7 @@ final class AppDelegate: NSObject, private let port: Int? private let pidFilePath: String? private let polyfillJSPath: String? + private let allowExternalHosts: Bool private var window: NSWindow! private var webView: WKWebView! private var quittingViaWindowClose = false @@ -65,13 +70,15 @@ final class AppDelegate: NSObject, appName: String, port: Int?, pidFilePath: String?, - polyfillJSPath: String? + polyfillJSPath: String?, + allowExternalHosts: Bool ) { self.url = url self.appName = appName self.port = port self.pidFilePath = pidFilePath self.polyfillJSPath = polyfillJSPath + self.allowExternalHosts = allowExternalHosts super.init() } @@ -295,6 +302,10 @@ final class AppDelegate: NSObject, decisionHandler(.allow) return } + if allowExternalHosts { + decisionHandler(.allow) + return + } // External links open in the user's default browser, not in the .app // window. Anything localhost stays in-window. if let host = target.host, @@ -950,13 +961,14 @@ final class FindBar: NSView, NSTextFieldDelegate, NSSearchFieldDelegate { let arguments = CommandLine.arguments guard arguments.count >= 2, let url = URL(string: arguments[1]) else { FileHandle.standardError.write( - Data("usage: wrapper [app-name] [port] [pid-file] [polyfill-js-path]\n".utf8)) + Data("usage: wrapper [app-name] [port] [pid-file] [polyfill-js-path] [allow-external-hosts]\n".utf8)) exit(2) } let appName = arguments.count >= 3 ? arguments[2] : "App" let port = arguments.count >= 4 ? Int(arguments[3]) : nil let pidFilePath = arguments.count >= 5 && !arguments[4].isEmpty ? arguments[4] : nil let polyfillJSPath = arguments.count >= 6 && !arguments[5].isEmpty ? arguments[5] : nil +let allowExternalHosts = arguments.count >= 7 && arguments[6] == "allow-external-hosts" let app = NSApplication.shared let delegate = AppDelegate( @@ -964,7 +976,8 @@ let delegate = AppDelegate( appName: appName, port: port, pidFilePath: pidFilePath, - polyfillJSPath: polyfillJSPath + polyfillJSPath: polyfillJSPath, + allowExternalHosts: allowExternalHosts ) app.delegate = delegate app.setActivationPolicy(.regular) diff --git a/plugins/app-it/skills/app-it/SKILL.md b/plugins/app-it/skills/app-it/SKILL.md index 2fe31ad..75c1bb4 100644 --- a/plugins/app-it/skills/app-it/SKILL.md +++ b/plugins/app-it/skills/app-it/SKILL.md @@ -1,14 +1,14 @@ --- name: app-it description: >- - Turn a local project into a macOS Dock-launchable .app. Use when the user - wants a clickable Dock app, local app package, icon, App It install, or - repeatable desktop launcher. Defaults to native Swift WebKit, shipped - templates, and verification of build, launch, ports, quit, warm relaunch, and - cleanup. + Turn a local project or hosted Claude Artifact URL into a macOS + Dock-launchable .app. Use when the user wants a clickable Dock app, local app + package, icon, App It install, Artifact wrapper, or repeatable desktop + launcher. Defaults to native Swift WebKit, shipped templates, and + verification of build, launch, ports, quit, warm relaunch, and cleanup. --- -# app-it - Make a local project launchable from the Dock +# app-it - Make a local project or hosted Artifact launchable from the Dock App It installs local projects under `~/Applications/App It/` as clickable macOS apps: click opens, window close stays warm, Cmd+Q cleans up. @@ -26,6 +26,10 @@ macOS apps: click opens, window close stays warm, Cmd+Q cleans up. project already has one or Strategy A cannot satisfy the requirement. 6. Verify the installed app path, runtime port truth, warm relaunch, Cmd+Q cleanup, and report honestly when GUI-only checks need a human. +7. Keep Claude Artifact auth with Claude. If an Artifact uses hosted runtime + APIs (`window.claude`, `window.storage`, MCP prompts, or Claude-provided + auth), package a published/shared `claude.ai` URL. Never copy sessions, + cookies, API keys, or another user's Claude auth into a local bundle. ## Reference Map diff --git a/plugins/app-it/skills/app-it/references/generated-files.md b/plugins/app-it/skills/app-it/references/generated-files.md index dc70c50..fb0b2a5 100644 --- a/plugins/app-it/skills/app-it/references/generated-files.md +++ b/plugins/app-it/skills/app-it/references/generated-files.md @@ -11,6 +11,8 @@ Copy from `templates/`: - `run-template.sh` - `run-template-chrome.sh` - `run-template-multiserver.sh` +- `run-template-url.sh` +- `run-template-url-chrome.sh` - `native-run-stub.c` - `desktop-build.sh` - `desktop-icons.sh` @@ -59,13 +61,16 @@ Use `scripts/app-it.config.json`: "start_command": "npm run dev -- --host 127.0.0.1 --port $PORT --strictPort", "bundle_id": "com.user.my-app", "version": "0.1.0", - "polyfill_path": "" + "polyfill_path": "", + "external_url": "" } ] } ``` -For A3, add `backend_port` and `backend_start_command`. +For A3, add `backend_port` and `backend_start_command`. For Strategy E +URL-only apps, set `external_url` (or alias `artifact_url` / `url`) and leave +local-server fields empty or `null`; URL-only mode wins if both are present. Use `port_mode: "fixed"` only when the frontend origin must stay exact. The default `fallback` mode is friendlier for sibling local apps because it scans @@ -81,6 +86,7 @@ Build-time substitution writes: - `__PORT__` - `__PORT_MODE__` - `__START_COMMAND__` +- `__APP_URL__` - `__BUNDLE_ID__` - `__VERSION__` - `__POLYFILL_PATH__` diff --git a/plugins/app-it/skills/app-it/references/project-inspection.md b/plugins/app-it/skills/app-it/references/project-inspection.md index ce423e9..ea56d69 100644 --- a/plugins/app-it/skills/app-it/references/project-inspection.md +++ b/plugins/app-it/skills/app-it/references/project-inspection.md @@ -11,6 +11,7 @@ read-only and prints the signals that decide the packaging route. - `dev` and `start` script inventory, especially scripts with hardcoded `-p`/`--port` values or multi-process orchestrators. - Framework port literals in config files. +- Claude Artifact URLs and hosted Artifact runtime API usage. - Two-stage File System Access usage. - Existing App It sibling apps and current port listeners. - Toolchain availability, especially `swiftc`. @@ -35,6 +36,10 @@ persistent absolute path. - Existing desktop: `electron`, `electron-builder`, `src-tauri/`, `nw.js`. - PWA: `manifest.json` and service worker. Still build a Strategy A `.app` and mention PWA install as optional. +- Published/shared Claude Artifact URL: choose Strategy E URL-only. +- Raw Claude Artifact source using `window.claude`, `window.storage`, MCP + prompts, or Claude-provided auth: block local credential shims and ask for a + published/shared Artifact URL. Ignore stale docs when they disagree with the files that actually run. diff --git a/plugins/app-it/skills/app-it/references/strategies.md b/plugins/app-it/skills/app-it/references/strategies.md index 5a0a4e4..4dfaf77 100644 --- a/plugins/app-it/skills/app-it/references/strategies.md +++ b/plugins/app-it/skills/app-it/references/strategies.md @@ -3,17 +3,21 @@ Choose exactly one strategy per user-facing app. ```text -Existing Electron/Tauri/NW.js config? - yes -> Strategy B - no -> native desktop requirements beyond web shell? - yes -> Strategy D - no -> FSA real-I/O or Chromium-only API? - yes -> A1 Chrome fallback - no -> static built bundle, no server? - yes -> A2 - no -> cohabiting frontend + backend? - yes -> A3 - no -> A1 native WebKit +Published/shared Claude Artifact URL? + yes -> Strategy E + no -> raw Artifact source uses window.claude/window.storage/MCP/auth? + yes -> blocked until user provides/publishes a Claude Artifact URL + no -> Existing Electron/Tauri/NW.js config? + yes -> Strategy B + no -> native desktop requirements beyond web shell? + yes -> Strategy D + no -> FSA real-I/O or Chromium-only API? + yes -> A1 Chrome fallback + no -> static built bundle, no server? + yes -> A2 + no -> cohabiting frontend + backend? + yes -> A3 + no -> A1 native WebKit ``` ## A1 Native WebKit - Default @@ -84,6 +88,25 @@ proxy targets read `API_PORT`, and backend entrypoints read `API_PORT` before Only for scripts with no UI. It spawns Terminal and should be flagged clearly in the report. Do not choose this for web apps. +## Strategy E URL-Only Hosted App + +Use when the app already lives at a hosted URL and the host must own auth and +runtime behavior. The primary case is a published/shared Claude Artifact: +Claude provides the artifact sandbox, AI bridge, storage, login, and plan usage. +App It wraps the URL; it does not recreate Claude's runtime locally. + +Set `external_url`, `artifact_url`, or `url` in `scripts/app-it.config.json`. +The generated launcher starts no local daemon, writes no `server.port`, and +passes `allow-external-hosts` to the Swift wrapper so Claude auth redirects, +hosted iframe traffic, and API bridge navigation remain in-window. + +Raw JSX/TSX exported from a Claude Artifact is only normal local web app source +when it does not depend on Claude's hosted runtime APIs. If it calls +`window.claude`, `window.storage`, MCP prompts, or Claude-provided auth, do not +shim local credentials, cookies, sessions, or API keys. Publish/share the +artifact in Claude and package that hosted URL so each recipient signs in with +their own Claude account and plan. + ## Strategy B Existing Desktop Config If the repo already has Electron, Tauri, or NW.js, use it instead of stacking diff --git a/plugins/app-it/skills/app-it/references/verification.md b/plugins/app-it/skills/app-it/references/verification.md index 53bb61b..b762c5e 100644 --- a/plugins/app-it/skills/app-it/references/verification.md +++ b/plugins/app-it/skills/app-it/references/verification.md @@ -29,6 +29,14 @@ For fixed-port apps, verify that `ports.mode` is `fixed` and the recorded runtime port equals the configured preferred port. A busy preferred port should fail launch with an explanation; fallback is deliberately disabled. +For Strategy E URL-only apps, rows 3-9 are `n/a - no local server`. +Programmatic verification is: `APP_IT_SMOKE=1 desktop/.app/Contents/MacOS/run` +prints the configured URL, no `server.port` is written, `run.sh` contains the +URL and `allow-external-hosts` in Swift mode, and rows 1-2 still pass. GUI +verification is opening the installed app, signing into Claude if needed, and +confirming the hosted artifact runs in-window. Do not `curl` private Artifact +URLs as proof; auth-protected Artifacts may correctly redirect. + | # | Check | Idiom | | ---: | --- | --- | | 1 | Build succeeded | `.app` exists; `file Contents/MacOS/run` reports Mach-O; `run.sh` executable; wrapper Mach-O; icon file valid | diff --git a/plugins/app-it/skills/app-it/templates/app-it.config.example.json b/plugins/app-it/skills/app-it/templates/app-it.config.example.json index 0f438ba..dd94b04 100644 --- a/plugins/app-it/skills/app-it/templates/app-it.config.example.json +++ b/plugins/app-it/skills/app-it/templates/app-it.config.example.json @@ -10,7 +10,8 @@ "version": "Marketing version. Doesn't matter for unsigned use; pick anything semver-shaped.", "polyfill_path": "Optional absolute path to a JS polyfill file (FSA shim). Use @ROOT@ to reference the repo root: '@ROOT@/assets/-polyfill.js'. Empty string when no polyfill is needed.", "backend_port": "Optional. Set for A3.2 multi-server cohabiting apps. The backend service's preferred port. The launcher allocates a free port starting here for the backend, exports it as API_PORT to both processes.", - "backend_start_command": "Optional. The command to start the backend service. Must honor API_PORT env var. The frontend dev server (e.g. Vite) must proxy to http://localhost:$API_PORT (config edits required — see SKILL.md A3.2 section)." + "backend_start_command": "Optional. The command to start the backend service. Must honor API_PORT env var. The frontend dev server (e.g. Vite) must proxy to http://localhost:$API_PORT (config edits required — see SKILL.md A3.2 section).", + "external_url": "Optional. When set, desktop-build.sh creates a URL-only launcher and ignores port/start_command/backend fields. Use this for published/shared Claude Artifact URLs so each user signs into Claude with their own plan. Aliases accepted: artifact_url, url." }, "apps": [ { @@ -23,7 +24,8 @@ "version": "0.1.0", "polyfill_path": "", "backend_port": null, - "backend_start_command": null + "backend_start_command": null, + "external_url": "" } ] } diff --git a/plugins/app-it/skills/app-it/templates/desktop-build.sh b/plugins/app-it/skills/app-it/templates/desktop-build.sh index 6f7875a..2a665ff 100755 --- a/plugins/app-it/skills/app-it/templates/desktop-build.sh +++ b/plugins/app-it/skills/app-it/templates/desktop-build.sh @@ -16,7 +16,8 @@ # "version": "0.1.0", # "polyfill_path": "", # "backend_port": null, // optional, A3.2 multi-server -# "backend_start_command": null // optional, A3.2 multi-server +# "backend_start_command": null, // optional, A3.2 multi-server +# "external_url": "" // optional URL-only app, e.g. published Claude Artifact # } # ] # } @@ -35,29 +36,33 @@ CONFIG_FILE="$SCRIPT_DIR/app-it.config.json" # --- Load apps from JSON (preferred) or bash APPS array (backward compat) ---- APPS=() if [ -f "$CONFIG_FILE" ]; then - # Internal record: name|slug|port|port_mode|start_command|bundle_id|version|polyfill_path|backend_port|backend_start_command + # Internal record: name|slug|port|port_mode|start_command|bundle_id|version|polyfill_path|backend_port|backend_start_command|external_url while IFS= read -r line; do [ -n "$line" ] && APPS+=("$line") done < <(/usr/bin/python3 - "$CONFIG_FILE" <<'PY' import json, sys with open(sys.argv[1]) as f: cfg = json.load(f) +def text(value): + return "" if value is None else str(value) for a in cfg.get("apps", []): port_mode = a.get("port_mode", "fallback") if port_mode not in ("fallback", "fixed"): print(f"ERROR: app {a.get('slug') or a.get('name') or ''} has invalid port_mode {port_mode!r}; expected 'fallback' or 'fixed'", file=sys.stderr) sys.exit(1) + external_url = a.get("external_url") or a.get("artifact_url") or a.get("url") or "" fields = [ - a.get("name", ""), - a.get("slug", ""), - str(a.get("port", "")), - port_mode, - a.get("start_command", ""), - a.get("bundle_id", ""), - a.get("version", "0.1.0"), - a.get("polyfill_path", ""), - str(a.get("backend_port") or ""), - a.get("backend_start_command") or "", + text(a.get("name", "")), + text(a.get("slug", "")), + text(a.get("port", "")), + text(port_mode), + text(a.get("start_command", "")), + text(a.get("bundle_id", "")), + text(a.get("version", "0.1.0")), + text(a.get("polyfill_path", "")), + text(a.get("backend_port") or ""), + text(a.get("backend_start_command") or ""), + text(external_url), ] # Reject any field containing pipe — would corrupt parsing. if any("|" in f for f in fields): @@ -71,8 +76,8 @@ else echo " Recommended: copy templates/app-it.config.example.json to scripts/." >&2 APPS=( # Replace these with your apps. One line per app. - # Format: name|slug|port|port_mode|start_command|bundle_id|version|polyfill_path|backend_port|backend_start_command - "__APP_NAME__|__APP_SLUG__|__PORT__|fallback|__START_COMMAND__|__BUNDLE_ID__|__VERSION__|__POLYFILL_PATH_ENTRY__||" + # Format: name|slug|port|port_mode|start_command|bundle_id|version|polyfill_path|backend_port|backend_start_command|external_url + "__APP_NAME__|__APP_SLUG__|__PORT__|fallback|__START_COMMAND__|__BUNDLE_ID__|__VERSION__|__POLYFILL_PATH_ENTRY__|||" ) fi @@ -86,7 +91,7 @@ fi # and refuse local bundles with `_LSOpenURLs... error -600 / procNotFound`. USER_PREFIX="com.$(id -un | tr 'A-Z' 'a-z')." for entry in "${APPS[@]}"; do - IFS='|' read -r _ _ _ _ _ BID _ _ _ _ <<<"$entry" + IFS='|' read -r _ _ _ _ _ BID _ _ _ _ _ <<<"$entry" BID_LOWER="$(echo "$BID" | tr 'A-Z' 'a-z')" case "$BID_LOWER" in "$USER_PREFIX"*) @@ -111,14 +116,17 @@ RUN_STUB_BUILD="$ROOT/assets/icons/build/run-stub" if [ "$LAUNCHER_MODE" = "swift" ]; then RUN_TEMPLATE_SINGLE="$SCRIPT_DIR/run-template.sh" + RUN_TEMPLATE_URL="$SCRIPT_DIR/run-template-url.sh" else RUN_TEMPLATE_SINGLE="$SCRIPT_DIR/run-template-chrome.sh" + RUN_TEMPLATE_URL="$SCRIPT_DIR/run-template-url-chrome.sh" fi RUN_TEMPLATE_MULTI="$SCRIPT_DIR/run-template-multiserver.sh" -if [ ! -f "$RUN_TEMPLATE_SINGLE" ] || [ ! -f "$PLIST_TEMPLATE" ]; then +if [ ! -f "$RUN_TEMPLATE_SINGLE" ] || [ ! -f "$RUN_TEMPLATE_URL" ] || [ ! -f "$PLIST_TEMPLATE" ]; then echo "Missing templates next to this script. Expected:" >&2 echo " $RUN_TEMPLATE_SINGLE" >&2 + echo " $RUN_TEMPLATE_URL" >&2 echo " $PLIST_TEMPLATE" >&2 exit 1 fi @@ -229,7 +237,7 @@ PY # --- Build each app ----------------------------------------------------- for entry in "${APPS[@]}"; do - IFS='|' read -r APP_NAME APP_SLUG PORT PORT_MODE START_COMMAND BUNDLE_ID VERSION POLYFILL_PATH BACKEND_PORT BACKEND_START_COMMAND <<<"$entry" + IFS='|' read -r APP_NAME APP_SLUG PORT PORT_MODE START_COMMAND BUNDLE_ID VERSION POLYFILL_PATH BACKEND_PORT BACKEND_START_COMMAND EXTERNAL_URL <<<"$entry" POLYFILL_PATH="${POLYFILL_PATH//@ROOT@/$ROOT}" APP_DIR="$ROOT/desktop/${APP_NAME}.app" @@ -240,13 +248,23 @@ for entry in "${APPS[@]}"; do echo "Building: $APP_DIR" mkdir -p "$MACOS" "$RESOURCES" - # Multi-server only when backend fields are configured and the template exists. - if [ -n "$BACKEND_PORT" ] && [ -n "$BACKEND_START_COMMAND" ] && [ -f "$RUN_TEMPLATE_MULTI" ]; then + # URL-only apps (Claude Artifact links, hosted dashboards) do not start a + # local daemon. Multi-server is only for local apps. + if [ -n "$EXTERNAL_URL" ]; then + if [ -n "$PORT" ] || [ -n "$START_COMMAND" ] || [ -n "$BACKEND_PORT" ] || [ -n "$BACKEND_START_COMMAND" ]; then + echo "Warning: $APP_NAME sets an external URL and local server fields; URL-only mode wins and local server fields are ignored." >&2 + fi + SELECTED_RUN_TEMPLATE="$RUN_TEMPLATE_URL" + IS_MULTI=0 + IS_URL=1 + elif [ -n "$BACKEND_PORT" ] && [ -n "$BACKEND_START_COMMAND" ] && [ -f "$RUN_TEMPLATE_MULTI" ]; then SELECTED_RUN_TEMPLATE="$RUN_TEMPLATE_MULTI" IS_MULTI=1 + IS_URL=0 else SELECTED_RUN_TEMPLATE="$RUN_TEMPLATE_SINGLE" IS_MULTI=0 + IS_URL=0 fi substitute "$PLIST_TEMPLATE" \ @@ -260,7 +278,14 @@ for entry in "${APPS[@]}"; do RUN_SCRIPT="$MACOS/run.sh" fi - if [ "$IS_MULTI" = "1" ]; then + if [ "$IS_URL" = "1" ]; then + substitute "$SELECTED_RUN_TEMPLATE" \ + "__APP_NAME__=$APP_NAME" \ + "__APP_SLUG__=$APP_SLUG" \ + "__APP_URL__=$EXTERNAL_URL" \ + "__POLYFILL_PATH__=$POLYFILL_PATH" \ + > "$RUN_SCRIPT" + elif [ "$IS_MULTI" = "1" ]; then substitute "$SELECTED_RUN_TEMPLATE" \ "__APP_NAME__=$APP_NAME" \ "__APP_SLUG__=$APP_SLUG" \ diff --git a/plugins/app-it/skills/app-it/templates/desktop-doctor.sh b/plugins/app-it/skills/app-it/templates/desktop-doctor.sh index 98334d4..e1f8f06 100755 --- a/plugins/app-it/skills/app-it/templates/desktop-doctor.sh +++ b/plugins/app-it/skills/app-it/templates/desktop-doctor.sh @@ -132,14 +132,18 @@ try: cfg = json.load(open(sys.argv[1])) except Exception as e: sys.stderr.write(f"could not parse app-it.config.json: {e}\n"); sys.exit(3) +def text(value): + return "" if value is None else str(value) for a in cfg.get("apps", []): - name = a.get("name", "") + name = a.get("name") or "" slug = a.get("slug") or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + external_url = a.get("external_url") or a.get("artifact_url") or a.get("url") or "" fields = [ - name, slug, str(a.get("port", "")), a.get("port_mode", "fallback"), - a.get("start_command", ""), - a.get("bundle_id", ""), a.get("version", "0.1.0"), a.get("polyfill_path", ""), - str(a.get("backend_port") or ""), a.get("backend_start_command") or "", + text(name), text(slug), text(a.get("port", "")), text(a.get("port_mode", "fallback")), + text(a.get("start_command", "")), + text(a.get("bundle_id", "")), text(a.get("version", "0.1.0")), text(a.get("polyfill_path", "")), + text(a.get("backend_port") or ""), text(a.get("backend_start_command") or ""), + text(external_url), ] print("|".join(f.replace("|", " ") for f in fields)) PY @@ -164,7 +168,9 @@ else SELECTED="${APPS[0]}" fi -IFS='|' read -r APP_NAME APP_SLUG PORT PORT_MODE START_COMMAND BUNDLE_ID VERSION POLYFILL_PATH BACKEND_PORT BACKEND_START <<<"$SELECTED" +IFS='|' read -r APP_NAME APP_SLUG PORT PORT_MODE START_COMMAND BUNDLE_ID VERSION POLYFILL_PATH BACKEND_PORT BACKEND_START EXTERNAL_URL <<<"$SELECTED" +IS_URL_ONLY=0 +[ -n "$EXTERNAL_URL" ] && IS_URL_ONLY=1 # --- Paths (mirror run-template.sh / desktop-quit.sh conventions) ------------ STATE_DIR="$HOME/Library/Application Support/app-it/$APP_SLUG" @@ -292,6 +298,9 @@ if [ "$JSON_MODE" = "0" ]; then printf ' %sbundle id%s %s\n' "$C_DIM" "$C_OFF" "${BUNDLE_ID:-(unset)}" printf ' %sport mode%s %s\n' "$C_DIM" "$C_OFF" "$PORT_MODE" printf ' %sproject%s %s\n' "$C_DIM" "$C_OFF" "$ROOT" + if [ "$IS_URL_ONLY" = "1" ]; then + printf ' %surl%s %s\n' "$C_DIM" "$C_OFF" "$EXTERNAL_URL" + fi printf ' %ssubject%s %s\n' "$C_DIM" "$C_OFF" "${APP_UNDER_TEST:-}" fi if [ "${#APPS[@]}" -gt 1 ] && [ -z "$SELECTOR" ]; then @@ -306,7 +315,7 @@ ok "scripts/app-it.config.json present and parses" # Placeholder leakage — an unsubstituted __PLACEHOLDER__ means a broken build. leaked="" -for v in "$APP_NAME" "$APP_SLUG" "$PORT" "$BUNDLE_ID" "$START_COMMAND"; do +for v in "$APP_NAME" "$APP_SLUG" "$PORT" "$BUNDLE_ID" "$START_COMMAND" "$EXTERNAL_URL"; do case "$v" in *__*__*) leaked="$leaked $v" ;; esac done if [ -n "$leaked" ]; then @@ -325,16 +334,27 @@ case "$bid_lc" in *) warn "bundle id '$BUNDLE_ID' is not reverse-DNS shaped (expected something like com.user.$APP_SLUG)" ;; esac -# Preferred port sanity. -case "$PORT" in - ''|*[!0-9]*) warn "preferred port '$PORT' is not a plain number" ;; - *) ok "preferred port :$PORT" ;; -esac -case "$PORT_MODE" in - fallback) ok "port mode: fallback (scan upward if the preferred port is busy)" ;; - fixed) ok "port mode: fixed (requires exactly :$PORT; no collision fallback)" ;; - *) fail "port_mode '$PORT_MODE' is invalid — expected fallback or fixed" ;; -esac +if [ "$IS_URL_ONLY" = "1" ]; then + case "$EXTERNAL_URL" in + http://*|https://*) ok "URL-only launcher: $EXTERNAL_URL" ;; + *) fail "URL-only launcher has a non-http(s) URL: $EXTERNAL_URL" ;; + esac + if [ -n "$PORT" ] || [ -n "$START_COMMAND" ] || [ -n "$BACKEND_PORT" ] || [ -n "$BACKEND_START" ]; then + warn "external URL is set alongside local server fields; URL-only mode wins and local server fields are ignored" + fi + info "no local dev server configured; this app loads the hosted URL directly" +else + # Preferred port sanity. + case "$PORT" in + ''|*[!0-9]*) warn "preferred port '$PORT' is not a plain number" ;; + *) ok "preferred port :$PORT" ;; + esac + case "$PORT_MODE" in + fallback) ok "port mode: fallback (scan upward if the preferred port is busy)" ;; + fixed) ok "port mode: fixed (requires exactly :$PORT; no collision fallback)" ;; + *) fail "port_mode '$PORT_MODE' is invalid — expected fallback or fixed" ;; + esac +fi # ============================================================================= section "Installed bundle" @@ -437,133 +457,141 @@ fi section "Runtime — port, server, ownership" RUNTIME_PORT=""; [ -f "$PORT_FILE" ] && RUNTIME_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" REC_PID=""; [ -f "$PID_FILE" ] && REC_PID="$(cat "$PID_FILE" 2>/dev/null || true)" - -# Preferred vs runtime port. -if [ -z "$RUNTIME_PORT" ]; then - if [ "$PORT_MODE" = "fixed" ]; then - info "frontend not currently running (no recorded runtime port). Fixed-port mode requires :$PORT." - else - info "frontend not currently running (no recorded runtime port). Preferred frontend is :$PORT." - fi -elif [ "$RUNTIME_PORT" = "$PORT" ]; then - if [ "$PORT_MODE" = "fixed" ]; then - ok "fixed-port runtime is using required frontend port :$RUNTIME_PORT" - else - ok "frontend runtime port :$RUNTIME_PORT matches preferred frontend port" - fi -else - if [ "$PORT_MODE" = "fixed" ]; then - fail "fixed-port mode is configured for :$PORT, but runtime state says :$RUNTIME_PORT — quit/rebuild/relaunch before trusting browser storage" - else - info "frontend running on runtime port :$RUNTIME_PORT, preferred frontend :$PORT — fell back (a sibling app or another process probably held :$PORT at launch)" - fi -fi -report_preferred_port_holder "frontend" "$PORT" "$RUNTIME_PORT" "$PORT_MODE" - -# Stale PID — low severity, because the launcher self-heals on the next click. PID_ALIVE=0 -if [ -n "$REC_PID" ]; then - if kill -0 "$REC_PID" 2>/dev/null; then - PID_ALIVE=1 - ok "recorded supervisor PID $REC_PID is alive" - else - warn "stale server.pid: recorded PID $REC_PID is dead. Low severity — the launcher clears this on next click. Clear now with --fix-safe." - fi -fi +BRUNTIME="" +BREC_PID="" +BPID_ALIVE=0 -# Is the listener in this launcher's descendant tree? -if [ -n "$RUNTIME_PORT" ]; then - LISTENERS="$(lsof -ti tcp:"$RUNTIME_PORT" 2>/dev/null || true)" - if [ -z "$LISTENERS" ]; then - if [ "$PID_ALIVE" = "1" ]; then - warn "supervisor PID $REC_PID is alive but nothing is listening on :$RUNTIME_PORT — server is probably still starting, or crashed after spawn (check the log)" +if [ "$IS_URL_ONLY" = "1" ]; then + ok "URL-only app; no local daemon, runtime port, or server ownership to check" +else + # Preferred vs runtime port. + if [ -z "$RUNTIME_PORT" ]; then + if [ "$PORT_MODE" = "fixed" ]; then + info "frontend not currently running (no recorded runtime port). Fixed-port mode requires :$PORT." else - info "nothing is listening on :$RUNTIME_PORT (app is stopped)" + info "frontend not currently running (no recorded runtime port). Preferred frontend is :$PORT." fi - elif [ "$PID_ALIVE" = "1" ]; then - TREE=" $(walk_descendants "$REC_PID") " - owned=0 - for p in $LISTENERS; do case "$TREE" in *" $p "*) owned=1; break ;; esac; done - if [ "$owned" = "1" ]; then - ok "the process on :$RUNTIME_PORT belongs to this launcher (in PID $REC_PID's tree)" - code="$(curl -sS -o /dev/null --max-time 1 -w "%{http_code}" "http://localhost:$RUNTIME_PORT" 2>/dev/null || true)" - if [ -n "$code" ] && [ "$code" != "000" ]; then ok "server responds on http://localhost:$RUNTIME_PORT (HTTP $code)" - else warn "server is bound to :$RUNTIME_PORT but not answering HTTP yet — probably mid-startup"; fi + elif [ "$RUNTIME_PORT" = "$PORT" ]; then + if [ "$PORT_MODE" = "fixed" ]; then + ok "fixed-port runtime is using required frontend port :$RUNTIME_PORT" else - warn "a process holds :$RUNTIME_PORT but it is probably NOT this launcher's server (not in PID $REC_PID's tree) — could be a foreign app or a stale listener" + ok "frontend runtime port :$RUNTIME_PORT matches preferred frontend port" fi else - warn "the recorded supervisor is gone yet :$RUNTIME_PORT is held — probably a stale or foreign process; the launcher will scan past it on next click" + if [ "$PORT_MODE" = "fixed" ]; then + fail "fixed-port mode is configured for :$PORT, but runtime state says :$RUNTIME_PORT — quit/rebuild/relaunch before trusting browser storage" + else + info "frontend running on runtime port :$RUNTIME_PORT, preferred frontend :$PORT — fell back (a sibling app or another process probably held :$PORT at launch)" + fi fi -fi + report_preferred_port_holder "frontend" "$PORT" "$RUNTIME_PORT" "$PORT_MODE" -# Backend check only when config declares one. -BRUNTIME="" -BREC_PID="" -BPID_ALIVE=0 -if [ -n "$BACKEND_PORT" ]; then - BRUNTIME=""; [ -f "$BPORT_FILE" ] && BRUNTIME="$(cat "$BPORT_FILE" 2>/dev/null || true)" - BREC_PID=""; [ -f "$BPID_FILE" ] && BREC_PID="$(cat "$BPID_FILE" 2>/dev/null || true)" - if [ -n "$BREC_PID" ] && kill -0 "$BREC_PID" 2>/dev/null; then - BPID_ALIVE=1 + # Stale PID — low severity, because the launcher self-heals on the next click. + if [ -n "$REC_PID" ]; then + if kill -0 "$REC_PID" 2>/dev/null; then + PID_ALIVE=1 + ok "recorded supervisor PID $REC_PID is alive" + else + warn "stale server.pid: recorded PID $REC_PID is dead. Low severity — the launcher clears this on next click. Clear now with --fix-safe." + fi fi - if [ -z "$BRUNTIME" ]; then - if [ "$BPID_ALIVE" = "1" ]; then - warn "backend supervisor PID $BREC_PID is alive but no backend.port is recorded — backend may still be starting, or state is incomplete" + # Is the listener in this launcher's descendant tree? + if [ -n "$RUNTIME_PORT" ]; then + LISTENERS="$(lsof -ti tcp:"$RUNTIME_PORT" 2>/dev/null || true)" + if [ -z "$LISTENERS" ]; then + if [ "$PID_ALIVE" = "1" ]; then + warn "supervisor PID $REC_PID is alive but nothing is listening on :$RUNTIME_PORT — server is probably still starting, or crashed after spawn (check the log)" + else + info "nothing is listening on :$RUNTIME_PORT (app is stopped)" + fi + elif [ "$PID_ALIVE" = "1" ]; then + TREE=" $(walk_descendants "$REC_PID") " + owned=0 + for p in $LISTENERS; do case "$TREE" in *" $p "*) owned=1; break ;; esac; done + if [ "$owned" = "1" ]; then + ok "the process on :$RUNTIME_PORT belongs to this launcher (in PID $REC_PID's tree)" + code="$(curl -sS -o /dev/null --max-time 1 -w "%{http_code}" "http://localhost:$RUNTIME_PORT" 2>/dev/null || true)" + if [ -n "$code" ] && [ "$code" != "000" ]; then ok "server responds on http://localhost:$RUNTIME_PORT (HTTP $code)" + else warn "server is bound to :$RUNTIME_PORT but not answering HTTP yet — probably mid-startup"; fi + else + warn "a process holds :$RUNTIME_PORT but it is probably NOT this launcher's server (not in PID $REC_PID's tree) — could be a foreign app or a stale listener" + fi else - info "multi-server backend not currently running (no recorded runtime port). Preferred backend is :$BACKEND_PORT." + warn "the recorded supervisor is gone yet :$RUNTIME_PORT is held — probably a stale or foreign process; the launcher will scan past it on next click" fi - else - if [ "$BRUNTIME" = "$BACKEND_PORT" ]; then - ok "backend runtime port :$BRUNTIME matches preferred backend port" - else - info "backend running on runtime port :$BRUNTIME, preferred backend :$BACKEND_PORT — fell back" + fi + + # Backend check only when config declares one. + if [ -n "$BACKEND_PORT" ]; then + BRUNTIME=""; [ -f "$BPORT_FILE" ] && BRUNTIME="$(cat "$BPORT_FILE" 2>/dev/null || true)" + BREC_PID=""; [ -f "$BPID_FILE" ] && BREC_PID="$(cat "$BPID_FILE" 2>/dev/null || true)" + if [ -n "$BREC_PID" ] && kill -0 "$BREC_PID" 2>/dev/null; then + BPID_ALIVE=1 fi - if [ -n "$BREC_PID" ]; then + if [ -z "$BRUNTIME" ]; then if [ "$BPID_ALIVE" = "1" ]; then - ok "recorded backend supervisor PID $BREC_PID is alive" + warn "backend supervisor PID $BREC_PID is alive but no backend.port is recorded — backend may still be starting, or state is incomplete" else - warn "stale backend.pid: recorded PID $BREC_PID is dead. Low severity — the launcher clears this on next click. Clear now with --fix-safe." + info "multi-server backend not currently running (no recorded runtime port). Preferred backend is :$BACKEND_PORT." fi - fi - - BLISTENERS="$(lsof -ti tcp:"$BRUNTIME" 2>/dev/null || true)" - if [ -z "$BLISTENERS" ]; then - if [ "$BPID_ALIVE" = "1" ]; then - warn "backend supervisor PID $BREC_PID is alive but nothing is listening on :$BRUNTIME — backend is probably still starting, or crashed after spawn (check backend.log)" + else + if [ "$BRUNTIME" = "$BACKEND_PORT" ]; then + ok "backend runtime port :$BRUNTIME matches preferred backend port" else - warn "backend absent: runtime state says :$BRUNTIME, but no backend listener is present" + info "backend running on runtime port :$BRUNTIME, preferred backend :$BACKEND_PORT — fell back" fi - elif [ "$BPID_ALIVE" = "1" ]; then - BTREE=" $(walk_descendants "$BREC_PID") " - bowned=0 - for p in $BLISTENERS; do case "$BTREE" in *" $p "*) bowned=1; break ;; esac; done - if [ "$bowned" = "1" ]; then - ok "backend runtime port :$BRUNTIME is listening (preferred :$BACKEND_PORT) and belongs to this launcher" + + if [ -n "$BREC_PID" ]; then + if [ "$BPID_ALIVE" = "1" ]; then + ok "recorded backend supervisor PID $BREC_PID is alive" + else + warn "stale backend.pid: recorded PID $BREC_PID is dead. Low severity — the launcher clears this on next click. Clear now with --fix-safe." + fi + fi + + BLISTENERS="$(lsof -ti tcp:"$BRUNTIME" 2>/dev/null || true)" + if [ -z "$BLISTENERS" ]; then + if [ "$BPID_ALIVE" = "1" ]; then + warn "backend supervisor PID $BREC_PID is alive but nothing is listening on :$BRUNTIME — backend is probably still starting, or crashed after spawn (check backend.log)" + else + warn "backend absent: runtime state says :$BRUNTIME, but no backend listener is present" + fi + elif [ "$BPID_ALIVE" = "1" ]; then + BTREE=" $(walk_descendants "$BREC_PID") " + bowned=0 + for p in $BLISTENERS; do case "$BTREE" in *" $p "*) bowned=1; break ;; esac; done + if [ "$bowned" = "1" ]; then + ok "backend runtime port :$BRUNTIME is listening (preferred :$BACKEND_PORT) and belongs to this launcher" + else + warn "backend runtime port :$BRUNTIME is held but it is probably NOT this launcher's backend (not in PID $BREC_PID's tree) — could be foreign or stale" + fi else - warn "backend runtime port :$BRUNTIME is held but it is probably NOT this launcher's backend (not in PID $BREC_PID's tree) — could be foreign or stale" + warn "backend runtime port :$BRUNTIME is held but the recorded backend PID is dead or missing — probably foreign or stale" fi - else - warn "backend runtime port :$BRUNTIME is held but the recorded backend PID is dead or missing — probably foreign or stale" fi + report_preferred_port_holder "backend" "$BACKEND_PORT" "$BRUNTIME" "fallback" fi - report_preferred_port_holder "backend" "$BACKEND_PORT" "$BRUNTIME" "fallback" fi section "Live windows — diagnostic only" report_live_app_windows # Catch "works in terminal, dead from Dock" by using the launcher's PATH. -CMD="$START_COMMAND" -case "$CMD" in cd\ *\ \&\&\ *) CMD="${CMD#* && }" ;; esac -FIRST_BIN="$(printf '%s' "$CMD" | awk '{print $1}')" -if [ -n "$FIRST_BIN" ]; then - if PATH="$LAUNCHER_PATH" command -v "$FIRST_BIN" >/dev/null 2>&1; then - ok "start command's binary '$FIRST_BIN' resolves on the launcher's PATH" - else - warn "start command's binary '$FIRST_BIN' is NOT on the launcher's PATH — the app would fail from a Dock click even if it works in your terminal" +if [ "$IS_URL_ONLY" = "1" ]; then + info "URL-only launcher: no start command binary is expected" +else + CMD="$START_COMMAND" + case "$CMD" in cd\ *\ \&\&\ *) CMD="${CMD#* && }" ;; esac + FIRST_BIN="$(printf '%s' "$CMD" | awk '{print $1}')" + if [ -n "$FIRST_BIN" ]; then + if PATH="$LAUNCHER_PATH" command -v "$FIRST_BIN" >/dev/null 2>&1; then + ok "start command's binary '$FIRST_BIN' resolves on the launcher's PATH" + else + warn "start command's binary '$FIRST_BIN' is NOT on the launcher's PATH — the app would fail from a Dock click even if it works in your terminal" + fi fi fi @@ -575,7 +603,9 @@ if [ -f "$RUNTIME_SUMMARY_FILE" ]; then else info "no runtime summary yet: $RUNTIME_SUMMARY_FILE" fi -if [ -f "$SERVER_LOG" ]; then +if [ "$IS_URL_ONLY" = "1" ]; then + info "URL-only launcher: no server log is expected" +elif [ -f "$SERVER_LOG" ]; then sz="$(wc -c < "$SERVER_LOG" 2>/dev/null | tr -d ' ')" info "server log: $SERVER_LOG (${sz:-0} bytes)" else @@ -597,7 +627,11 @@ section "Template drift" # No version stamp exists; feature-probe installed artifacts against templates. # Keep `grep -qboa` for wrapper markers because older builds hid them from strings. WRAPPER_SRC="$SCRIPT_DIR/wrapper.swift" -RUN_SRC="$SCRIPT_DIR/run-template.sh" +if [ "$IS_URL_ONLY" = "1" ]; then + RUN_SRC="$SCRIPT_DIR/run-template-url.sh" +else + RUN_SRC="$SCRIPT_DIR/run-template.sh" +fi INSTALLED_WRAPPER="${APP_UNDER_TEST:+$APP_UNDER_TEST/Contents/MacOS/wrapper}" INSTALLED_RUN="${APP_UNDER_TEST:+$APP_UNDER_TEST/Contents/MacOS/run}" INSTALLED_RUN_SH="${APP_UNDER_TEST:+$APP_UNDER_TEST/Contents/MacOS/run.sh}" @@ -619,9 +653,18 @@ if [ -n "$APP_UNDER_TEST" ] && [ -f "$WRAPPER_SRC" ] && [ -f "${INSTALLED_WRAPPE fi if [ -n "$APP_UNDER_TEST" ] && [ -f "$RUN_SRC" ] && [ -f "${INSTALLED_LAUNCHER:-/nonexistent}" ] && grep -q "MacOS/wrapper" "$INSTALLED_LAUNCHER" 2>/dev/null; then - for probe in \ - "Reattach to our own existing server|fast warm-relaunch (descendant-walk reattach)" \ - "Two-stage readiness probe|the two-stage readiness probe"; do + if [ "$IS_URL_ONLY" = "1" ]; then + RUN_PROBES=( + "allow-external-hosts|hosted auth/API navigation stays in-window" + "APP_IT_SMOKE|URL-only smoke seam" + ) + else + RUN_PROBES=( + "Reattach to our own existing server|fast warm-relaunch (descendant-walk reattach)" + "Two-stage readiness probe|the two-stage readiness probe" + ) + fi + for probe in "${RUN_PROBES[@]}"; do marker="${probe%%|*}"; human="${probe##*|}" if grep -qF "$marker" "$RUN_SRC" 2>/dev/null && ! grep -qF "$marker" "$INSTALLED_LAUNCHER" 2>/dev/null; then warn "installed launcher script is missing $human — predates that template; rebuild" @@ -731,6 +774,8 @@ if [ "$JSON_MODE" = "1" ]; then DOCTOR_BUNDLE_ID="$BUNDLE_ID" \ DOCTOR_VERSION="$VERSION" \ DOCTOR_PROJECT_ROOT="$ROOT" \ + DOCTOR_EXTERNAL_URL="$EXTERNAL_URL" \ + DOCTOR_IS_URL_ONLY="$IS_URL_ONLY" \ DOCTOR_SUBJECT="$APP_UNDER_TEST" \ DOCTOR_SUBJECT_LOCATION="$APP_LOC" \ DOCTOR_INSTALL_APP="$INSTALL_APP" \ @@ -780,6 +825,8 @@ payload = { "slug": os.environ["DOCTOR_APP_SLUG"], "bundle_id": empty_to_none(os.environ["DOCTOR_BUNDLE_ID"]), "version": empty_to_none(os.environ["DOCTOR_VERSION"]), + "url_only": os.environ["DOCTOR_IS_URL_ONLY"] == "1", + "external_url": empty_to_none(os.environ["DOCTOR_EXTERNAL_URL"]), }, "project": { "root": os.environ["DOCTOR_PROJECT_ROOT"], diff --git a/plugins/app-it/skills/app-it/templates/desktop-launcher.md.template b/plugins/app-it/skills/app-it/templates/desktop-launcher.md.template index aacca0a..0d8fe30 100644 --- a/plugins/app-it/skills/app-it/templates/desktop-launcher.md.template +++ b/plugins/app-it/skills/app-it/templates/desktop-launcher.md.template @@ -5,7 +5,7 @@ Click an icon in `~/Applications/App It/` (or its Dock Stack) to launch. ## First launch 1. Right-click the app icon and choose **Open**, then click **Open** in the dialog. macOS remembers and skips this on subsequent launches (Gatekeeper, unsigned bundle). -2. The first cold start takes 5–15 s while the dev server compiles. +2. The first cold start takes 5–15 s while the dev server compiles. URL-only apps (for example, Claude Artifact wrappers) do not compile locally; first launch is WebKit startup plus sign-in/network time. 3. If a "couldn't be opened" alert appears citing the dev server, open `~/Library/Logs/app-it//server.log`. The alert quotes the tail; the full log usually shows the cause. ## Apps @@ -24,6 +24,7 @@ The launcher is **persistent** — designed for daily use, not single-shot demos - **Cmd+Q (or right-click → Quit in the Dock) DOES kill the server.** Full-shutdown path — use when you actually want everything to stop. - **Re-clicking the icon while the window is open** brings the existing window forward. No second window. - **Sibling appified apps coexist by default.** With `port_mode: "fallback"`, if another `.app` already grabbed the configured port, this launcher scans upward and picks a free port. With `port_mode: "fixed"`, the configured port is required and a busy port fails clearly so browser storage stays on one origin. The runtime port is recorded at `~/Library/Application Support/app-it//server.port`. +- **Claude Artifact wrappers.** Sharing the `.app` shares only the hosted URL wrapper, not Claude credentials. Each recipient signs into Claude and uses their own plan. To stop the persistent dev servers from the terminal: diff --git a/plugins/app-it/skills/app-it/templates/desktop-quit.sh b/plugins/app-it/skills/app-it/templates/desktop-quit.sh index ce93fe6..890f91d 100755 --- a/plugins/app-it/skills/app-it/templates/desktop-quit.sh +++ b/plugins/app-it/skills/app-it/templates/desktop-quit.sh @@ -30,10 +30,12 @@ if [ -f "$CONFIG_FILE" ]; then import json, re, sys with open(sys.argv[1]) as f: cfg = json.load(f) +def text(value): + return "" if value is None else str(value) for a in cfg.get("apps", []): - name = a.get("name", "") + name = a.get("name") or "" slug = a.get("slug") or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") - print(f'{name}|{slug}|{a.get("port","")}|{a.get("backend_port") or ""}') + print(f'{name}|{slug}|{text(a.get("port",""))}|{text(a.get("backend_port") or "")}') PY ) else @@ -227,14 +229,15 @@ for entry in "${APPS[@]}"; do done # Native WebKit wrapper windows. Prefer the generated pid-file argument over -# broad process names; it is unique to this app's launcher state. +# broad process names; it is unique to this app's launcher state. URL-only +# wrappers have no pid-file argument, so fall back to the app name in argv. for entry in "${APPS[@]}"; do IFS='|' read -r APP_NAME APP_SLUG _ _ <<<"$entry" STATE_DIR="$HOME/Library/Application Support/app-it/$APP_SLUG" PID_FILE="$STATE_DIR/server.pid" for p in $(pgrep -f "MacOS/wrapper " 2>/dev/null); do cmdline="$(ps -o command= -p "$p" 2>/dev/null || true)" - if echo "$cmdline" | grep -qF "$PID_FILE"; then + if echo "$cmdline" | grep -qF "$PID_FILE" || echo "$cmdline" | grep -qF "$APP_NAME"; then kill -TERM "$p" 2>/dev/null || true CLOSED_ANY=1 fi diff --git a/plugins/app-it/skills/app-it/templates/inspect.sh b/plugins/app-it/skills/app-it/templates/inspect.sh index fffecb3..65548dd 100755 --- a/plugins/app-it/skills/app-it/templates/inspect.sh +++ b/plugins/app-it/skills/app-it/templates/inspect.sh @@ -183,6 +183,37 @@ if has_next_signal: PY fi +print_section "Claude Artifact signals" +ARTIFACT_URL_PATTERN="https://claude\.ai/[^ )\"'>]+" +ARTIFACT_API_PATTERN="window\.claude|window\.storage|claude\.complete|claude\.request" +if command -v rg >/dev/null 2>&1; then + ARTIFACT_URLS="$(rg --no-heading -n -E "$ARTIFACT_URL_PATTERN" \ + -g '*.md' -g '*.json' -g '*.html' -g '*.js' -g '*.jsx' -g '*.ts' -g '*.tsx' \ + -g '!node_modules/**' -g '!desktop/**' -g '!assets/icons/**' -g '!.git/**' . 2>/dev/null | head -8 || true)" + ARTIFACT_API="$(rg --no-heading -n -E "$ARTIFACT_API_PATTERN" \ + -g '*.html' -g '*.js' -g '*.jsx' -g '*.ts' -g '*.tsx' \ + -g '!node_modules/**' -g '!desktop/**' -g '!assets/icons/**' -g '!.git/**' . 2>/dev/null | head -8 || true)" +else + ARTIFACT_URLS="$(grep -RnE "$ARTIFACT_URL_PATTERN" . \ + --include='*.md' --include='*.json' --include='*.html' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' \ + --exclude-dir=node_modules --exclude-dir=desktop --exclude-dir=.git 2>/dev/null | head -8 || true)" + ARTIFACT_API="$(grep -RnE "$ARTIFACT_API_PATTERN" . \ + --include='*.html' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' \ + --exclude-dir=node_modules --exclude-dir=desktop --exclude-dir=.git 2>/dev/null | head -8 || true)" +fi +if [ -n "$ARTIFACT_URLS" ]; then + echo "$ARTIFACT_URLS" | sed 's/^/ possible hosted artifact URL: /' +else + echo " (no claude.ai URLs found in local project files)" +fi +if [ -n "$ARTIFACT_API" ]; then + echo "$ARTIFACT_API" | sed 's/^/ Claude Artifact runtime API usage: /' + echo " -> If this source needs the logged-in Claude plan, package a published/shared Claude Artifact URL with external_url/artifact_url." + echo " Do not shim Claude auth, cookies, or API keys into a local JSX bundle." +else + echo " (no window.claude/window.storage usage found in local source)" +fi + print_section "Framework port literals (would override launcher's PORT env)" if [ -f "$ROOT/vite.config.ts" ] || [ -f "$ROOT/vite.config.js" ] || [ -f "$ROOT/vite.config.mjs" ]; then for cfg in "$ROOT"/vite.config.{ts,js,mjs}; do diff --git a/plugins/app-it/skills/app-it/templates/run-template-url-chrome.sh b/plugins/app-it/skills/app-it/templates/run-template-url-chrome.sh new file mode 100644 index 0000000..b18e3bd --- /dev/null +++ b/plugins/app-it/skills/app-it/templates/run-template-url-chrome.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# app-it URL-only launcher (Chrome --app fallback variant). +# +# Used only when APP_IT_LAUNCHER_MODE=chrome or swiftc is unavailable. It wraps +# a hosted URL without starting a local server. For Claude Artifacts this still +# avoids shared keys/auth: each user signs into Claude inside this browser +# profile and usage counts against that user's Claude plan. + +set -e + +APP_NAME="__APP_NAME__" +APP_SLUG="__APP_SLUG__" + +APP_URL="$(cat <<'APP_IT_URL' +__APP_URL__ +APP_IT_URL +)" + +STATE_DIR="$HOME/Library/Application Support/app-it/$APP_SLUG" +PROFILE="$STATE_DIR/BrowserProfile" +mkdir -p "$PROFILE" + +show_alert() { + /usr/bin/osascript - "$1" "$2" <<'APPLESCRIPT' +on run argv + display alert (item 1 of argv) message (item 2 of argv) +end run +APPLESCRIPT +} + +case "$APP_URL" in + http://*|https://*) ;; + *) + show_alert "$APP_NAME failed to launch" "URL-only launchers require an http(s) URL. + +Configured URL: +$APP_URL" + exit 1 + ;; +esac + +if [ -n "${APP_IT_SMOKE:-}" ]; then + echo "app-it smoke: $APP_NAME ready at $APP_URL (url-only chrome fallback; no local server)" + exit 0 +fi + +CHROME_BIN="" +for browser in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" \ + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \ + "/Applications/Arc.app/Contents/MacOS/Arc"; do + if [ -x "$browser" ]; then + CHROME_BIN="$browser" + break + fi +done + +if [ -z "$CHROME_BIN" ]; then + exec open "$APP_URL" +fi + +exec "$CHROME_BIN" --app="$APP_URL" --user-data-dir="$PROFILE" diff --git a/plugins/app-it/skills/app-it/templates/run-template-url.sh b/plugins/app-it/skills/app-it/templates/run-template-url.sh new file mode 100644 index 0000000..3ce179e --- /dev/null +++ b/plugins/app-it/skills/app-it/templates/run-template-url.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# app-it URL-only launcher (Swift WebKit shell variant). +# +# Used for hosted apps that should not start a local dev server. The main +# current case is a published/shared Claude Artifact URL: Claude's artifact +# host provides the AI bridge, storage, MCP prompts, and user authentication. +# app-it only wraps that URL in a Dock-launchable native window; it never +# ships API keys, cookies, or copied Claude auth state. +# +# Substituted by desktop-build.sh: +# __APP_NAME__ human display name +# __APP_SLUG__ file-safe slug +# __APP_URL__ http(s) URL to load +# __POLYFILL_PATH__ optional JS injected at document_start (usually empty) + +set -e + +APP_NAME="__APP_NAME__" +APP_SLUG="__APP_SLUG__" +POLYFILL_PATH="__POLYFILL_PATH__" + +APP_URL="$(cat <<'APP_IT_URL' +__APP_URL__ +APP_IT_URL +)" + +HERE="$(cd "$(dirname "$0")" && pwd)" + +show_alert() { + /usr/bin/osascript - "$1" "$2" <<'APPLESCRIPT' +on run argv + display alert (item 1 of argv) message (item 2 of argv) +end run +APPLESCRIPT +} + +case "$APP_URL" in + http://*|https://*) ;; + *) + show_alert "$APP_NAME failed to launch" "URL-only launchers require an http(s) URL. + +Configured URL: +$APP_URL" + exit 1 + ;; +esac + +# Headless seam for fixture tests and SSH sessions. URL-only launchers have no +# daemon or runtime port; proving the run script validates and selects the URL +# mode is enough before GUI verification. +if [ -n "${APP_IT_SMOKE:-}" ]; then + echo "app-it smoke: $APP_NAME ready at $APP_URL (url-only; no local server)" + exit 0 +fi + +WRAPPER="$HERE/wrapper" +if [ ! -x "$WRAPPER" ]; then + show_alert "$APP_NAME failed to launch" "Native wrapper missing at: +$WRAPPER + +Run desktop:build to rebuild the bundle." + exit 1 +fi + +# Empty port/pid args mean Cmd+Q only closes the wrapper. The final flag keeps +# hosted auth redirects, Claude's artifact iframe, and AI API bridge traffic +# inside this window instead of ejecting them to the default browser. +exec "$WRAPPER" "$APP_URL" "$APP_NAME" "" "" "$POLYFILL_PATH" "allow-external-hosts" diff --git a/plugins/app-it/skills/app-it/templates/wrapper.swift b/plugins/app-it/skills/app-it/templates/wrapper.swift index b586d08..522450c 100644 --- a/plugins/app-it/skills/app-it/templates/wrapper.swift +++ b/plugins/app-it/skills/app-it/templates/wrapper.swift @@ -2,7 +2,7 @@ // .app bundle owns its window (and therefore its Dock icon, activation, and // single-instance semantics). // -// Usage: wrapper [app-name] [port] [pid-file] [polyfill-js-path] +// Usage: wrapper [app-name] [port] [pid-file] [polyfill-js-path] [allow-external-hosts] // url — http(s) URL to load (typically http://localhost:PORT) // app-name — window title and Dock badge (e.g. "Momó Studio") // port — optional, used by Cmd+Q only with PID ownership proof @@ -19,6 +19,10 @@ // The polyfill is the agent's responsibility — see the // skill's `fsa-polyfill-template.js` for a worked // example. Pass an empty string to skip injection. +// allow-external-hosts — optional literal. Used by URL-only launchers such as +// Claude Artifact wrappers, where auth, iframes, and API +// bridges must remain inside the hosted web app instead +// of being kicked out to the default browser. // // Build: swiftc wrapper.swift -o -framework Cocoa -framework WebKit // @@ -53,6 +57,7 @@ final class AppDelegate: NSObject, private let port: Int? private let pidFilePath: String? private let polyfillJSPath: String? + private let allowExternalHosts: Bool private var window: NSWindow! private var webView: WKWebView! private var quittingViaWindowClose = false @@ -65,13 +70,15 @@ final class AppDelegate: NSObject, appName: String, port: Int?, pidFilePath: String?, - polyfillJSPath: String? + polyfillJSPath: String?, + allowExternalHosts: Bool ) { self.url = url self.appName = appName self.port = port self.pidFilePath = pidFilePath self.polyfillJSPath = polyfillJSPath + self.allowExternalHosts = allowExternalHosts super.init() } @@ -295,6 +302,10 @@ final class AppDelegate: NSObject, decisionHandler(.allow) return } + if allowExternalHosts { + decisionHandler(.allow) + return + } // External links open in the user's default browser, not in the .app // window. Anything localhost stays in-window. if let host = target.host, @@ -950,13 +961,14 @@ final class FindBar: NSView, NSTextFieldDelegate, NSSearchFieldDelegate { let arguments = CommandLine.arguments guard arguments.count >= 2, let url = URL(string: arguments[1]) else { FileHandle.standardError.write( - Data("usage: wrapper [app-name] [port] [pid-file] [polyfill-js-path]\n".utf8)) + Data("usage: wrapper [app-name] [port] [pid-file] [polyfill-js-path] [allow-external-hosts]\n".utf8)) exit(2) } let appName = arguments.count >= 3 ? arguments[2] : "App" let port = arguments.count >= 4 ? Int(arguments[3]) : nil let pidFilePath = arguments.count >= 5 && !arguments[4].isEmpty ? arguments[4] : nil let polyfillJSPath = arguments.count >= 6 && !arguments[5].isEmpty ? arguments[5] : nil +let allowExternalHosts = arguments.count >= 7 && arguments[6] == "allow-external-hosts" let app = NSApplication.shared let delegate = AppDelegate( @@ -964,7 +976,8 @@ let delegate = AppDelegate( appName: appName, port: port, pidFilePath: pidFilePath, - polyfillJSPath: polyfillJSPath + polyfillJSPath: polyfillJSPath, + allowExternalHosts: allowExternalHosts ) app.delegate = delegate app.setActivationPolicy(.regular) diff --git a/scripts/fixtures/claude-artifact-url/app-it.config.json b/scripts/fixtures/claude-artifact-url/app-it.config.json new file mode 100644 index 0000000..9891d08 --- /dev/null +++ b/scripts/fixtures/claude-artifact-url/app-it.config.json @@ -0,0 +1,17 @@ +{ + "apps": [ + { + "name": "Claude Artifact", + "slug": "claude-artifact", + "port": null, + "port_mode": "fallback", + "start_command": "", + "bundle_id": "com.user.claude-artifact", + "version": "0.1.0", + "polyfill_path": "", + "backend_port": null, + "backend_start_command": null, + "artifact_url": "https://claude.ai/public/artifacts/00000000-0000-4000-8000-000000000000" + } + ] +} diff --git a/scripts/test-fixtures.sh b/scripts/test-fixtures.sh index 06956ca..7fe0156 100755 --- a/scripts/test-fixtures.sh +++ b/scripts/test-fixtures.sh @@ -413,6 +413,23 @@ lacks "inspect emits no hardcoded-port warning" "$INSPECT" "hardcoded port liter build assert_bundle "Next Basic" next-basic com.user.next-basic swift +# ============================================================================= +section "claude-artifact-url — URL-only hosted Artifact bundle" +setup_proj claude-artifact-url app-it claude-artifact +build +assert_bundle "Claude Artifact" claude-artifact com.user.claude-artifact swift +RUN_SCRIPT="$(cat "$PROJ/desktop/Claude Artifact.app/Contents/MacOS/run.sh")" +has "URL-only template selected" "$RUN_SCRIPT" "claude.ai/public/artifacts" +has "URL-only wrapper keeps hosted navigation in-window" "$RUN_SCRIPT" "allow-external-hosts" +if run_capped 10 "$PROJ/url-smoke.log" env APP_IT_SMOKE=1 "$PROJ/desktop/Claude Artifact.app/Contents/MacOS/run"; then + ok "URL-only launcher smoke run succeeds without starting a server" +else + bad "URL-only launcher smoke run failed"; sed 's/^/ /' "$PROJ/url-smoke.log" +fi +no_path "URL-only launcher records no server.port" "$(state_dir claude-artifact)/server.port" +APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/desktop-doctor.sh" claude-artifact >"$PROJ/doctor-artifact.log" 2>&1 || true +has "desktop-doctor reports URL-only mode" "$(cat "$PROJ/doctor-artifact.log")" "URL-only app; no local daemon" + # ============================================================================= section "next-hardcoded-port — inspect recommends direct Next binary" setup_proj next-hardcoded-port app-it next-hardcoded-port diff --git a/scripts/validate.sh b/scripts/validate.sh index 76029a4..4b69df7 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -33,6 +33,8 @@ require_file "plugins/app-it/skills/app-it/templates/desktop-doctor.sh" require_file "plugins/app-it/skills/app-it/templates/desktop-verify.sh" require_file "plugins/app-it/skills/app-it/templates/desktop-icons-preview.sh" require_file "plugins/app-it/skills/app-it/templates/placeholder-icon-gen.sh" +require_file "plugins/app-it/skills/app-it/templates/run-template-url.sh" +require_file "plugins/app-it/skills/app-it/templates/run-template-url-chrome.sh" require_file "scripts/coverage.sh" require_file "scripts/plugin-eval-score.sh" require_file "README.md"