diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e9cb34..8a33084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- Added: explicit `app-it` dev-server recipes for Vite + React, SvelteKit, and Astro, including disk detection signals, port behavior, and loopback-only start commands. +- Fixed: generated run scripts now preserve `$PORT`/`$API_PORT` inside configured start commands until the launcher has selected the runtime ports. +- Changed: hardened the shared Swift `WKWebView` shell against unusable restored window frames by clamping saved frames to the visible display and enforcing a minimum first-launch size. - Added: `desktop:doctor` — a self-diagnosing command for generated `app-it` launchers (`scripts/desktop-doctor.sh`). Run `npm run desktop:doctor` long after the build session to get a short, issue-ready report on one launcher: config + placeholder leakage, installed/build `.app`, Info.plist identity, ad-hoc signature, quarantine / iCloud signature-breaking xattrs, preferred-vs-runtime port, stale PID, **whether the process on the runtime port is actually in the recorded supervisor's descendant tree** (reuses the launcher's reattach gate), start-command binary resolution on the launcher's PATH, log/state paths, and **template drift** (feature-probes the installed `wrapper`/`run` against the current templates — no version stamp needed). `--tail[=N]` appends the launcher log. It is a diagnostic, not a fixer: read-only, deterministic, local (no network, no new dependencies), and it says "probably" when a check can't be certain. The opt-in `--fix-safe` flag touches **only app-it's own generated state** — stale pid/port files, this bundle's stale LaunchServices registration, the rebuilt icon, and quarantine on the generated `.app` — never the user's product code, dependencies, config, or anything outside app-it's artifacts. macOS `app-it` plugin only (the `app-it-static` companion has a different runtime model). Embodies Core principle #8 (*runtime truth beats build-time guess*) for end users. - Added: `app-it-static` companion plugin (`plugins/app-it-static/`) — a macOS sibling of `app-it` for **finished or buildable** apps. Builds once, then serves the built output (`dist/`/`build/`/`out/`/…) from a tiny zero-dependency static server (~15 MB) or directly via `file://` (~0 MB) — **no dev server**, instead of the 300–700 MB a dev server holds. Reuses `app-it`'s native Swift WebKit window, icon pipeline, and one-folder Dock install (the five shared templates are byte-identical and CI guards them against drift). The served output is a snapshot; `desktop:rebuild` refreshes it. Inspired by r/ClaudeAI launch feedback (see README → Community nudge) and recorded in [ADR 0006](docs/decisions/0006-static-companion-snapshot-model.md). - Added: Windows beta scaffold (`plugins/app-it-windows/`) — a sibling plugin mirroring the macOS contract with Windows primitives (WPF + WebView2 host, PowerShell lifecycle scripts, multi-resolution `.ico`, Start Menu `.lnk`). Build + lint gated by a required `windows-latest` CI job; **untested on real hardware, looking for a maintainer.** See [docs/WINDOWS.md](docs/WINDOWS.md). diff --git a/README.md b/README.md index aa5ad00..f3c175e 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ Turn a local web project into a macOS Dock-launchable `.app` bundle — a native WHAT YOU HAVE WHAT APP-IT DOES WHAT YOU GET ─────────────────────── ────────────────────── ─────────────────────────── a local web project inspects it from disk, YourApp.app on your Dock - Vite, Next, or a static picks a strategy, then · its own icon - site, run with ──▶ builds & signs a .app ──▶ · native window, one click - `npm run dev` in a tab around a WebKit shell · ⌘Q quits & frees the port + 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 ``` Under the hood, app-it: diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 90cc8b7..bfec9bf 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -9,7 +9,7 @@ | OS | macOS | | Primary shell | Swift `WKWebView` wrapper | | Fallback shell | Chrome `--app` mode | -| Common targets | Vite, Next.js, static sites, local multi-server apps | +| Common targets | Vite + React, SvelteKit, Astro, Next.js, static sites, local multi-server apps | | Install destination | `~/Applications/App It/` by default | | Signing | Ad-hoc local code signing only | 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 fd97d42..cde8a93 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 @@ -38,6 +38,8 @@ import WebKit private let DEFAULT_WIDTH: CGFloat = 1280 private let DEFAULT_HEIGHT: CGFloat = 820 +private let MIN_WIDTH: CGFloat = 720 +private let MIN_HEIGHT: CGFloat = 480 final class AppDelegate: NSObject, NSApplicationDelegate, @@ -84,6 +86,7 @@ final class AppDelegate: NSObject, installKeyboardShortcutMonitor() let frame = NSRect(x: 0, y: 0, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT) + let autosaveName = "App-itWindow.\(appName)" window = NSWindow( contentRect: frame, @@ -92,10 +95,11 @@ final class AppDelegate: NSObject, defer: false ) window.title = appName - window.setFrameAutosaveName("App-itWindow.\(appName)") + window.setFrameAutosaveName(autosaveName) + window.minSize = NSSize(width: MIN_WIDTH, height: MIN_HEIGHT) window.tabbingMode = .disallowed window.delegate = self - window.center() + restoreUsableWindowFrame(named: autosaveName) let config = WKWebViewConfiguration() config.websiteDataStore = .default() @@ -139,6 +143,56 @@ final class AppDelegate: NSObject, NSApp.activate(ignoringOtherApps: true) } + private func restoreUsableWindowFrame(named autosaveName: String) { + // AppKit can restore a frame saved on a now-disconnected display, or a + // tiny frame saved before the WebKit shell had a minimum size. Clamp + // the restored frame into a visible screen before the first paint so + // the WKWebView opens usable instead of off-screen or postage-stamp. + if !window.setFrameUsingName(autosaveName) { + window.center() + } + + guard let screen = bestScreen(for: window.frame) else { return } + var frame = window.frame + let visible = screen.visibleFrame + + frame.size.width = min(max(frame.size.width, MIN_WIDTH), visible.width) + frame.size.height = min(max(frame.size.height, MIN_HEIGHT), visible.height) + + if frame.maxX > visible.maxX { + frame.origin.x = visible.maxX - frame.size.width + } + if frame.minX < visible.minX { + frame.origin.x = visible.minX + } + if frame.maxY > visible.maxY { + frame.origin.y = visible.maxY - frame.size.height + } + if frame.minY < visible.minY { + frame.origin.y = visible.minY + } + + window.setFrame(frame, display: false) + } + + private func bestScreen(for frame: NSRect) -> NSScreen? { + // When a frame straddles two displays, the first intersecting screen is + // arbitrary. Pick the screen holding the largest slice of the frame, and + // only fall back to main/first when nothing intersects at all. + var best: NSScreen? + var largestArea: CGFloat = 0 + for screen in NSScreen.screens { + let overlap = screen.visibleFrame.intersection(frame) + guard !overlap.isNull else { continue } + let area = overlap.width * overlap.height + if area > largestArea { + largestArea = area + best = screen + } + } + return best ?? NSScreen.main ?? NSScreen.screens.first + } + private var hasSynthesizedGesture = false func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { diff --git a/plugins/app-it/skills/app-it/SKILL.md b/plugins/app-it/skills/app-it/SKILL.md index f74e4d8..2b8d6c4 100644 --- a/plugins/app-it/skills/app-it/SKILL.md +++ b/plugins/app-it/skills/app-it/SKILL.md @@ -190,6 +190,18 @@ For A3 multi-server, add `"backend_port"` and `"backend_start_command"`. The bui 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. +### Framework dev recipes + +Use these when disk signals are unambiguous. Examples use `npm`; translate to +the package manager actually present in the project while preserving the same +script arguments. + +| Framework | Reliable detection signals | Preferred port | `START_COMMAND` | Notes | +|---|---|---:|---|---| +| Vite + React | `vite.config.*`; `package.json` has `vite`, `react`, `react-dom`, and `@vitejs/plugin-react` or `@vitejs/plugin-react-swc`; fresh apps usually have `src/main.jsx` or `src/main.tsx` | 5173 | `npm run dev -- --host 127.0.0.1 --port "$PORT" --strictPort` | Vanilla single-server path. The CLI flags beat `vite.config.*` port literals without source edits. If a proxy target or backend port is hardcoded, route to A3.2 and make the ports env-driven. | +| SvelteKit | `svelte.config.*`; `package.json` has `@sveltejs/kit`, `@sveltejs/vite-plugin-svelte`, `svelte`, and `vite` | 5173 | `npm run dev -- --host 127.0.0.1 --port "$PORT" --strictPort` | SvelteKit runs through Vite, so use Vite CLI port flags instead of relying on `PORT` alone. First launch must happen after dependencies are installed. | +| Astro | `astro.config.*`; `package.json` has `astro`; `scripts.dev` is usually `astro dev` | 4321 | `npm run dev -- --host 127.0.0.1 --port "$PORT"` | Astro's dev server accepts `--port`, and the explicit flag works across current Astro releases. Keep the host loopback-only; do not use `--host 0.0.0.0`. | + Never: - Modify app business-logic source code. - Add runtime dependencies for Strategy A. @@ -511,13 +523,13 @@ Hard-won from real-project iteration. Do not rediscover these: | 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 + React / vanilla Vite (no proxy) | reads config's `server.port` literal; `strictPort: false` silently bumps | `npm run dev -- --host 127.0.0.1 --port "$PORT" --strictPort` — CLI flags win over config literals. 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..target` reads `process.env.API_PORT`. | +| SvelteKit | Vite-backed; `PORT` alone is not the reliable contract | `npm run dev -- --host 127.0.0.1 --port "$PORT" --strictPort`. | | 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`. | +| Astro | current releases accept `--port`; older Astro needed the flag | `npm run dev -- --host 127.0.0.1 --port "$PORT"`. | | Docusaurus | needs `--port` flag | embed `--port "$PORT"`. | **Recommended PORT-respecting invocations per package manager:** @@ -693,9 +705,12 @@ Replace `assets/-icon.png`, then `pnpm desktop:icons: && pnpm desktop | 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.*` + React deps (`react`, `react-dom`, `@vitejs/plugin-react*`) | A1 native | Vite + React recipe: `START_COMMAND="npm run dev -- --host 127.0.0.1 --port \"\$PORT\" --strictPort"`. | | `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.*` no build (vanilla) | A1 native | `START_COMMAND="npm run dev -- --host 127.0.0.1 --port \"\$PORT\" --strictPort"` — CLI flags win over config literals. | | `vite.config.*` + proxy block | A3.2 | Make ports env-driven (3 vite-config edits + 1 server-entry edit). | +| `svelte.config.*` + `@sveltejs/kit` | A1 native | SvelteKit recipe: Vite-backed dev server, use `--port "$PORT" --strictPort`; do not treat adapter choice as static unless using app-it-static. | +| `astro.config.*` + `astro` dependency | A1 native | Astro recipe: default preferred port 4321, `START_COMMAND="npm run dev -- --host 127.0.0.1 --port \"\$PORT\""`. | | `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 `.app`s. | diff --git a/plugins/app-it/skills/app-it/templates/inspect.sh b/plugins/app-it/skills/app-it/templates/inspect.sh index 6d515a7..2187d8b 100755 --- a/plugins/app-it/skills/app-it/templates/inspect.sh +++ b/plugins/app-it/skills/app-it/templates/inspect.sh @@ -40,7 +40,7 @@ if [ -d "$ROOT/.git" ] || [ -f "$ROOT/.git" ]; then fi print_section "Project type signals (verify from disk, ignore CLAUDE.md)" -for f in package.json next.config.ts next.config.js next.config.mjs vite.config.ts vite.config.js vite.config.mjs tauri.conf.json electron.json electron-builder.yml electron-builder.json pyproject.toml requirements.txt Cargo.toml Gemfile manifest.json index.html; do +for f in package.json next.config.ts next.config.js next.config.mjs vite.config.ts vite.config.js vite.config.mjs astro.config.ts astro.config.js astro.config.mjs svelte.config.ts svelte.config.js svelte.config.mjs tauri.conf.json electron.json electron-builder.yml electron-builder.json pyproject.toml requirements.txt Cargo.toml Gemfile manifest.json index.html; do [ -f "$ROOT/$f" ] && echo " $f" || true done [ -d "$ROOT/src-tauri" ] && echo " src-tauri/ (Tauri project)" || true @@ -76,6 +76,35 @@ for k, v in matched: print() print(f" package.json name: {pkg.get('name', '(none)')}") print(f" package.json displayName: {pkg.get('displayName', '(none)')}") + +deps = {} +for section in ("dependencies", "devDependencies", "optionalDependencies"): + deps.update(pkg.get(section, {})) + +def has(name): + return name in deps + +recipes = [] +if has("vite") and has("react") and has("react-dom") and ( + has("@vitejs/plugin-react") or has("@vitejs/plugin-react-swc") +): + recipes.append( + "Vite + React — port 5173; start_command ' run dev -- --host 127.0.0.1 --port $PORT --strictPort'" + ) +if has("@sveltejs/kit") and has("@sveltejs/vite-plugin-svelte") and has("svelte") and has("vite"): + recipes.append( + "SvelteKit — port 5173; start_command ' run dev -- --host 127.0.0.1 --port $PORT --strictPort'" + ) +if has("astro"): + recipes.append( + "Astro — port 4321; start_command ' run dev -- --host 127.0.0.1 --port $PORT'" + ) + +if recipes: + print() + print(" framework recipe candidates:") + for recipe in recipes: + print(f" - {recipe}") PY fi @@ -85,7 +114,7 @@ if [ -f "$ROOT/vite.config.ts" ] || [ -f "$ROOT/vite.config.js" ] || [ -f "$ROOT [ -f "$cfg" ] || continue if grep -nE 'server:\s*\{[^}]*port:\s*[0-9]+' "$cfg" 2>/dev/null | head -3; then echo " → vite.config.ts has hardcoded server.port literal." - echo " Vanilla single-server: pass --port via START_COMMAND ('npm run dev -- --port \$PORT')." + echo " Vanilla single-server: pass CLI flags via START_COMMAND ('npm run dev -- --host 127.0.0.1 --port \$PORT --strictPort')." echo " Multi-server / proxy: edit vite.config.ts — see SKILL.md A3.2 carve-out." fi if grep -nE 'proxy:\s*\{[^}]*target:\s*["'"'"']http://localhost:[0-9]+' "$cfg" 2>/dev/null | head -3; then diff --git a/plugins/app-it/skills/app-it/templates/run-template-chrome.sh b/plugins/app-it/skills/app-it/templates/run-template-chrome.sh index 14ec299..6a50226 100755 --- a/plugins/app-it/skills/app-it/templates/run-template-chrome.sh +++ b/plugins/app-it/skills/app-it/templates/run-template-chrome.sh @@ -25,9 +25,16 @@ APP_NAME="__APP_NAME__" APP_SLUG="__APP_SLUG__" PROJECT_ROOT="__PROJECT_ROOT__" PREFERRED_PORT=__PORT__ -START_COMMAND="__START_COMMAND__" POLYFILL_PATH="__POLYFILL_PATH__" +# Keep `$PORT` and other shell syntax literal until the daemon spawns below. +# A plain double-quoted assignment here would expand `$PORT` before the +# launcher has selected its runtime port, breaking Vite/SvelteKit recipes. +START_COMMAND="$(cat <<'APP_IT_START_COMMAND' +__START_COMMAND__ +APP_IT_START_COMMAND +)" + STATE_DIR="$HOME/Library/Application Support/app-it/$APP_SLUG" LOG_DIR="$HOME/Library/Logs/app-it/$APP_SLUG" mkdir -p "$STATE_DIR" "$LOG_DIR" diff --git a/plugins/app-it/skills/app-it/templates/run-template-multiserver.sh b/plugins/app-it/skills/app-it/templates/run-template-multiserver.sh index 91ebb0c..a8a2c95 100755 --- a/plugins/app-it/skills/app-it/templates/run-template-multiserver.sh +++ b/plugins/app-it/skills/app-it/templates/run-template-multiserver.sh @@ -34,11 +34,21 @@ APP_NAME="__APP_NAME__" APP_SLUG="__APP_SLUG__" PROJECT_ROOT="__PROJECT_ROOT__" PREFERRED_FE_PORT=__PORT__ -START_COMMAND="__START_COMMAND__" PREFERRED_BE_PORT=__BACKEND_PORT__ -BACKEND_START_COMMAND="__BACKEND_START_COMMAND__" POLYFILL_PATH="__POLYFILL_PATH__" +# Keep `$PORT` / `$API_PORT` and other shell syntax literal until the daemon +# spawns below. A plain double-quoted assignment here would expand those values +# before the launcher has selected its runtime ports. +START_COMMAND="$(cat <<'APP_IT_START_COMMAND' +__START_COMMAND__ +APP_IT_START_COMMAND +)" +BACKEND_START_COMMAND="$(cat <<'APP_IT_BACKEND_START_COMMAND' +__BACKEND_START_COMMAND__ +APP_IT_BACKEND_START_COMMAND +)" + STATE_DIR="$HOME/Library/Application Support/app-it/$APP_SLUG" LOG_DIR="$HOME/Library/Logs/app-it/$APP_SLUG" mkdir -p "$STATE_DIR" "$LOG_DIR" diff --git a/plugins/app-it/skills/app-it/templates/run-template.sh b/plugins/app-it/skills/app-it/templates/run-template.sh index 8d26584..2d2de8b 100755 --- a/plugins/app-it/skills/app-it/templates/run-template.sh +++ b/plugins/app-it/skills/app-it/templates/run-template.sh @@ -32,9 +32,16 @@ APP_NAME="__APP_NAME__" APP_SLUG="__APP_SLUG__" PROJECT_ROOT="__PROJECT_ROOT__" PREFERRED_PORT=__PORT__ -START_COMMAND="__START_COMMAND__" POLYFILL_PATH="__POLYFILL_PATH__" +# Keep `$PORT` and other shell syntax literal until the daemon spawns below. +# A plain double-quoted assignment here would expand `$PORT` before the +# launcher has selected its runtime port, breaking Vite/SvelteKit recipes. +START_COMMAND="$(cat <<'APP_IT_START_COMMAND' +__START_COMMAND__ +APP_IT_START_COMMAND +)" + STATE_DIR="$HOME/Library/Application Support/app-it/$APP_SLUG" LOG_DIR="$HOME/Library/Logs/app-it/$APP_SLUG" mkdir -p "$STATE_DIR" "$LOG_DIR" diff --git a/plugins/app-it/skills/app-it/templates/wrapper.swift b/plugins/app-it/skills/app-it/templates/wrapper.swift index fd97d42..cde8a93 100644 --- a/plugins/app-it/skills/app-it/templates/wrapper.swift +++ b/plugins/app-it/skills/app-it/templates/wrapper.swift @@ -38,6 +38,8 @@ import WebKit private let DEFAULT_WIDTH: CGFloat = 1280 private let DEFAULT_HEIGHT: CGFloat = 820 +private let MIN_WIDTH: CGFloat = 720 +private let MIN_HEIGHT: CGFloat = 480 final class AppDelegate: NSObject, NSApplicationDelegate, @@ -84,6 +86,7 @@ final class AppDelegate: NSObject, installKeyboardShortcutMonitor() let frame = NSRect(x: 0, y: 0, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT) + let autosaveName = "App-itWindow.\(appName)" window = NSWindow( contentRect: frame, @@ -92,10 +95,11 @@ final class AppDelegate: NSObject, defer: false ) window.title = appName - window.setFrameAutosaveName("App-itWindow.\(appName)") + window.setFrameAutosaveName(autosaveName) + window.minSize = NSSize(width: MIN_WIDTH, height: MIN_HEIGHT) window.tabbingMode = .disallowed window.delegate = self - window.center() + restoreUsableWindowFrame(named: autosaveName) let config = WKWebViewConfiguration() config.websiteDataStore = .default() @@ -139,6 +143,56 @@ final class AppDelegate: NSObject, NSApp.activate(ignoringOtherApps: true) } + private func restoreUsableWindowFrame(named autosaveName: String) { + // AppKit can restore a frame saved on a now-disconnected display, or a + // tiny frame saved before the WebKit shell had a minimum size. Clamp + // the restored frame into a visible screen before the first paint so + // the WKWebView opens usable instead of off-screen or postage-stamp. + if !window.setFrameUsingName(autosaveName) { + window.center() + } + + guard let screen = bestScreen(for: window.frame) else { return } + var frame = window.frame + let visible = screen.visibleFrame + + frame.size.width = min(max(frame.size.width, MIN_WIDTH), visible.width) + frame.size.height = min(max(frame.size.height, MIN_HEIGHT), visible.height) + + if frame.maxX > visible.maxX { + frame.origin.x = visible.maxX - frame.size.width + } + if frame.minX < visible.minX { + frame.origin.x = visible.minX + } + if frame.maxY > visible.maxY { + frame.origin.y = visible.maxY - frame.size.height + } + if frame.minY < visible.minY { + frame.origin.y = visible.minY + } + + window.setFrame(frame, display: false) + } + + private func bestScreen(for frame: NSRect) -> NSScreen? { + // When a frame straddles two displays, the first intersecting screen is + // arbitrary. Pick the screen holding the largest slice of the frame, and + // only fall back to main/first when nothing intersects at all. + var best: NSScreen? + var largestArea: CGFloat = 0 + for screen in NSScreen.screens { + let overlap = screen.visibleFrame.intersection(frame) + guard !overlap.isNull else { continue } + let area = overlap.width * overlap.height + if area > largestArea { + largestArea = area + best = screen + } + } + return best ?? NSScreen.main ?? NSScreen.screens.first + } + private var hasSynthesizedGesture = false func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {