Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
58 changes: 56 additions & 2 deletions plugins/app-it-static/skills/app-it-static/templates/wrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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 }
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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!) {
Expand Down
23 changes: 19 additions & 4 deletions plugins/app-it/skills/app-it/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.<route>.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:**
Expand Down Expand Up @@ -693,9 +705,12 @@ Replace `assets/<slug>-icon.png`, then `pnpm desktop:icons:<app> && 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. |
Expand Down
33 changes: 31 additions & 2 deletions plugins/app-it/skills/app-it/templates/inspect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 '<pm> 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 '<pm> run dev -- --host 127.0.0.1 --port $PORT --strictPort'"
)
if has("astro"):
recipes.append(
"Astro — port 4321; start_command '<pm> 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

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 12 additions & 2 deletions plugins/app-it/skills/app-it/templates/run-template-multiserver.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 8 additions & 1 deletion plugins/app-it/skills/app-it/templates/run-template.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading