diff --git a/.github/workflows/scaffold-smoke.yml b/.github/workflows/scaffold-smoke.yml index fd12284..8c291d5 100644 --- a/.github/workflows/scaffold-smoke.yml +++ b/.github/workflows/scaffold-smoke.yml @@ -26,3 +26,6 @@ jobs: - run: grep -q APPLE_NOTARIZE ./tmp/example/scripts/create-dmg.mjs - run: grep -q shell-status ./tmp/example/src/mainview/index.html - run: grep -q appbun.generated.json ./tmp/example/README.md + - run: grep -q 'ApplicationMenu.setApplicationMenu' ./tmp/example/src/bun/index.ts + - run: grep -q 'process.platform === "darwin"' ./tmp/example/src/bun/index.ts + - run: grep -q 'role: "copy"' ./tmp/example/src/bun/index.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8359c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +`appbun` is a Node CLI that scaffolds an inspectable [Electrobun](https://electrobun.dev) project from a URL or a local dev server, and can optionally build/sign/notarize a macOS DMG. The published artifact is the compiled `dist/` plus `bin/appbun.js`, which boots `dist/cli.js`. + +## Commands + +```bash +bun install # install deps (Bun is the canonical PM; engines: node>=20, bun>=1.3) +bun run check # tsc --noEmit (strict TS, NodeNext modules) +bun run test # bun test src (only file: src/__tests__/generator.test.ts) +bun run build # clean + tsc emit into dist/ +bun run release:check # build + npm pack --dry-run (validates published files) +node ./bin/appbun.js ... # run the CLI against the local source after a build +``` + +Run a single test by file or name: + +```bash +bun test src/__tests__/generator.test.ts +bun test src --test-name-pattern "writes electrobun.config" +``` + +CI (`.github/workflows/ci.yml`) runs `check`, `test`, `build`, and `npm pack --dry-run`. A separate `scaffold-smoke.yml` smoke-tests `node ./bin/appbun.js https://example.com ...` and asserts specific files in the generated project — keep these references intact in templates: `APPLE_SIGN_IDENTITY`, `APPLE_NOTARIZE`, `shell-status`, `appbun.generated.json`, `ApplicationMenu.setApplicationMenu`, `process.platform === "darwin"`, and the literal `role: "copy"` (the Edit-menu role) in `src/bun/index.ts`. + +## Architecture + +Entry flow: `bin/appbun.js` → `dist/cli.js` (compiled from `src/cli.ts`). `src/cli.ts` wires every subcommand via Commander; argv is rewritten so a bare `appbun ` is treated as `appbun create ` (see `shouldDefaultToCreate`). + +`src/lib/` is the library layer the CLI orchestrates — keep CLI thin, put logic here: + +- `generator.ts` — `resolveAppConfig`, `writeProject`, `installDependencies`, `runPackageScript`. Owns the contract between CLI options and the on-disk Electrobun project. +- `templates/` — render functions that emit every file in the generated project (`project.ts` is the entry, plus `shell.ts`, `titlebar.ts`, `dmg.ts`, `release.ts`). The CI smoke test asserts specific output paths; changing template paths usually means updating `.github/workflows/scaffold-smoke.yml`. +- `metadata.ts` / `icons.ts` — fetch site title/description/theme color/icons via `cheerio` + `@resvg/resvg-js` + `png-to-ico` + `icojs`; always provide a fallback path (`createFallbackSiteMetadata`). +- `recipes.ts` — curated app catalog used by `appbun create `, `appbun recipes`, `appbun discover`. Recipes are intentionally boring: stable public URLs, optional `themeColor`/`titlebar`/`width`/`height`/aliases/`concepts`. +- `doctor.ts` (env: bun, node, Xcode, codesign, etc., per `--target`) vs `project-doctor.ts` (validates an already-generated project). `--project` flips to the latter; both can emit JSON for agents. +- `project-package.ts` — runs inside a generated project: install → build → optional DMG → optional sign → optional notarize. Reads Apple env vars (`APPLE_SIGN_IDENTITY`, `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_SPECIFIC_PASSWORD`). +- `local-dev.ts` — `appbun dev` port-sniffs common localhost ports. +- `skill.ts` — installs the bundled Codex skill (`skills/appbun-web-desktop/`) and the Claude Code `CLAUDE.md` template into a target repo (`--install-claude --cwd .`). +- `agent-prompt.ts` — `appbun prompt` emits a paste-ready prompt for any agent. +- `version.ts` — reads `package.json` via `import.meta.url`; this is why `bin/`, `dist/`, and `package.json` must ship together (see `files` in `package.json`). + +`tsconfig.json` excludes `src/__tests__/**` from the build but `bun test` picks them up directly. `dist/` is git-ignored and regenerated by `bun run build` (which runs as `prepack`/`prepublishOnly`). + +## Conventions + +- Bun is preferred at runtime and in scripts, but the CLI must keep working under plain Node + npm (`--package-manager npm`). Don't introduce Bun-only APIs in `src/lib/` unless guarded. +- Built-in recipes (`src/lib/recipes.ts`): stable public URLs, prefer recognizable apps, only set `titlebar`/`width`/`height` when the default is wrong, add aliases only for names people naturally type (e.g. `ytmusic` → `youtube-music`), and tag `concepts` (`ai`, `design`, `docs`, `music`, `work`) so `appbun discover` finds them. +- Prefer improving generated output over adding CLI flags. The product bar is in [docs/pake-grade-goal.md](docs/pake-grade-goal.md); generated apps should feel productized, not scripted. +- Windows/Linux currently use native title bars; titlebar presets (`system`/`unified`/`compact`/`minimal`) are macOS-only. +- The Codex skill at `skills/appbun-web-desktop/` is shipped in the npm package — keep `SKILL.md` and `CLAUDE.md` in sync with real CLI behavior when commands or flags change. diff --git a/dev-docs/2026-05-28-native-menu-shortcuts-plan.md b/dev-docs/2026-05-28-native-menu-shortcuts-plan.md new file mode 100644 index 0000000..89ccff5 --- /dev/null +++ b/dev-docs/2026-05-28-native-menu-shortcuts-plan.md @@ -0,0 +1,708 @@ +# Native macOS Menu & Standard Keyboard Shortcuts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Generated appbun macOS apps install a native `NSMenu` (App / Edit / View / Window) so that `Cmd+C/V/X/A/Z/Q/R/F/M` and the right-click Cut/Copy items work out of the box, restoring expected desktop behavior in any text-input field. + +**Architecture:** Modify the template that emits `src/bun/index.ts` (`generatedBunEntry()` in `src/lib/templates/shell.ts`) so that on macOS it calls `ApplicationMenu.setApplicationMenu([...])` with built-in `role`-based items plus one custom `Reload` action that proxies to the embedded ``. Enable `esModuleInterop` in the generated `tsconfig.json` so the default `import Electrobun` line compiles. Wrap the menu install in `if (isMac)` to leave Windows/Linux untouched. Lock the behavior with a generator unit test + three new `scaffold-smoke.yml` greps. No new CLI flags. + +**Tech Stack:** TypeScript (NodeNext, strict), Bun test runner, Commander-based CLI, Electrobun 1.18.1 `ApplicationMenu` API. + +**Spec source:** [`dev-docs/native-menu-shortcuts.md`](../../../dev-docs/native-menu-shortcuts.md) — resolved 2026-05-28. + +--- + +## File Structure + +Files to touch (all already exist except the new plan file): + +| File | Responsibility | +|---|---| +| `src/lib/templates/shell.ts` | `generatedBunEntry()` template — owns the menu install + Reload handler emitted into `src/bun/index.ts` of every generated project. | +| `src/lib/templates/project.ts` | `generatedTsconfig()` — emits the generated project's `tsconfig.json`. Needs `esModuleInterop: true` for default-import compatibility. | +| `src/__tests__/generator.test.ts` | Generator regression tests. Add assertions on the new menu install + tsconfig flag. | +| `.github/workflows/scaffold-smoke.yml` | Smoke CI. Add three greps protecting the menu contract. | +| `CLAUDE.md` (top-level) | Mention the new grep contract + fix the stale `scaffold.yml` filename references (real file is `scaffold-smoke.yml`). | +| `skills/appbun-web-desktop/SKILL.md` | Add a Quality Bar bullet about macOS shortcuts. | +| `skills/appbun-web-desktop/CLAUDE.md` | Sync the same statement (added under "Verification"). | +| `package.json` | Version bump `0.10.4` → `0.10.5`. | + +The work is decomposed into 9 small tasks. Each task is a single self-contained edit + assertions + commit. + +--- + +## Task Decomposition Overview + +1. Enable `esModuleInterop` in generated `tsconfig.json` (with test). +2. Update `generatedBunEntry()` to install the macOS application menu and wire the `reload-app` handler (with test). +3. Extend `scaffold-smoke.yml` with 3 grep guards. +4. Update top-level `CLAUDE.md` — fix `scaffold.yml` → `scaffold-smoke.yml` and document the new grep contract. +5. Add Quality Bar entry to `skills/appbun-web-desktop/SKILL.md`. +6. Sync `skills/appbun-web-desktop/CLAUDE.md`. +7. Run full local verification (`check` + `test` + `release:check`). +8. Bump version to `0.10.5`. +9. Manual macOS DMG verification (checklist from spec §8.2) — release-blocking but not commit-gated. + +--- + +## Task 1: Enable `esModuleInterop` in the generated `tsconfig.json` + +**Why first:** Task 2's new template code uses `import Electrobun, { … } from "electrobun/bun"` (default import). Without `esModuleInterop`, the consumer project will fail `tsc`. Doing this first keeps every intermediate commit in a state where the generated project still compiles. + +**Files:** +- Modify: `src/lib/templates/project.ts:145-158` (`generatedTsconfig()`) +- Test: `src/__tests__/generator.test.ts` — extend the existing `renderTemplateFiles includes electrobun entry` test (so we don't add a brand-new test that competes for the same fixture). Add one assertion inside that test. + +- [ ] **Step 1: Read the current generator test for context** + +Run: `bun test src --test-name-pattern "renders electrobun entry" 2>&1 | head -20` to confirm the test currently passes; then read `src/__tests__/generator.test.ts:227-273` (the `renderTemplateFiles includes electrobun entry` test) so you know exactly which `files` array is available to assert against. + +- [ ] **Step 2: Add the failing assertion** + +Open `src/__tests__/generator.test.ts`. Inside `test("renderTemplateFiles includes electrobun entry", ...)` (around line 273, before the closing `});` of that test), add this assertion as a new line right after the existing `expect(manifest).toContain('"version": ...)` line: + +```ts + expect(files.find((file) => file.path === "tsconfig.json")?.content).toContain('"esModuleInterop": true'); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `bun test src --test-name-pattern "renderTemplateFiles includes electrobun entry"` +Expected: FAIL with message containing `Expected: ... "esModuleInterop": true ... Received: undefined` (or a `toContain` mismatch on the rendered tsconfig string). + +- [ ] **Step 4: Update `generatedTsconfig()` to emit the flag** + +Open `src/lib/templates/project.ts`. Replace the `generatedTsconfig` function body (currently lines 145-158) with: + +```ts +function generatedTsconfig(): string { + return [ + "{", + ' "compilerOptions": {', + ' "target": "ES2022",', + ' "module": "ESNext",', + ' "moduleResolution": "Bundler",', + ' "strict": true,', + ' "esModuleInterop": true,', + ' "types": ["bun"]', + " }", + "}", + "", + ].join("\n"); +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `bun test src --test-name-pattern "renderTemplateFiles includes electrobun entry"` +Expected: PASS. + +Also run the full suite to be safe: `bun run check && bun test src` +Expected: type check clean, all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/templates/project.ts src/__tests__/generator.test.ts +git commit -m "Enable esModuleInterop in generated tsconfig" +``` + +--- + +## Task 2: Install the macOS application menu in the generated `src/bun/index.ts` + +**Files:** +- Modify: `src/lib/templates/shell.ts:4-39` (`generatedBunEntry()`) +- Test: `src/__tests__/generator.test.ts` — add a new `test(...)` inside the `describe("generator", ...)` block. + +This is the largest task. Each step is small. + +- [ ] **Step 1: Read the current template output to confirm starting state** + +Read `src/lib/templates/shell.ts:1-39`. Confirm the function: +- declares `const isMac = process.platform === "darwin";` (line 16 of the emitted template literal) +- imports only `BrowserWindow` from `electrobun/bun` +- defines `mainWindow` but never installs a menu. + +This matches the spec §6.3 starting state. + +- [ ] **Step 2: Add a failing test that asserts the menu install is present** + +Open `src/__tests__/generator.test.ts`. Add this new test just below the existing `test("renderTemplateFiles includes electrobun entry", ...)` test (so it shares the same describe block, around the line that now ends with `});` after the `tsconfig` assertion you added in Task 1): + +```ts + test("generated bun entry installs the macOS application menu", () => { + const config = resolveAppConfig( + "https://example.com", + { + width: 1400, + height: 900, + packageManager: "bun", + install: false, + dmg: false, + yes: false, + showConfig: false, + quiet: true, + }, + { + title: "Example", + description: "Example app", + themeColor: "#336699", + sourceUrl: "https://example.com", + iconCandidates: [], + }, + ); + + const files = renderTemplateFiles(config, {}); + const bunEntry = files.find((file) => file.path === "src/bun/index.ts")?.content ?? ""; + + // import line includes ApplicationMenu + default Electrobun import + expect(bunEntry).toContain('import Electrobun, { BrowserWindow, ApplicationMenu } from "electrobun/bun"'); + + // platform guard reuses the existing isMac variable, no redeclaration + expect(bunEntry).toContain("if (isMac) {"); + expect((bunEntry.match(/const isMac = /g) ?? []).length).toBe(1); + + // setApplicationMenu call exists + expect(bunEntry).toContain("ApplicationMenu.setApplicationMenu("); + + // Edit menu roles present (spec §6.1). Use the `role: "X"` form so the + // assertion can't pass on a stray substring (e.g. "copy" inside a comment). + expect(bunEntry).toContain('role: "copy"'); + expect(bunEntry).toContain('role: "paste"'); + expect(bunEntry).toContain('role: "cut"'); + expect(bunEntry).toContain('role: "selectAll"'); + expect(bunEntry).toContain('role: "undo"'); + expect(bunEntry).toContain('role: "redo"'); + expect(bunEntry).toContain('role: "pasteAndMatchStyle"'); + expect(bunEntry).toContain('role: "delete"'); + + // App menu roles present + expect(bunEntry).toContain('role: "hide"'); + expect(bunEntry).toContain('role: "hideOthers"'); + expect(bunEntry).toContain('role: "showAll"'); + expect(bunEntry).toContain('role: "quit"'); + + // View menu — custom Reload action + accelerator + toggleFullScreen role + expect(bunEntry).toContain('action: "reload-app"'); + expect(bunEntry).toContain('accelerator: "r"'); + expect(bunEntry).toContain('role: "toggleFullScreen"'); + + // Window menu — correct role name is bringAllToFront (not "front") + expect(bunEntry).toContain('role: "minimize"'); + expect(bunEntry).toContain('role: "zoom"'); + expect(bunEntry).toContain('role: "bringAllToFront"'); + + // Reload handler invokes the child , NOT location.reload(). + // The bare-substring check below catches both the bad-string form + // (`executeJavascript("location.reload()")`) and a bare `location.reload()` call. + expect(bunEntry).toContain("document.getElementById('remote-app')?.reload()"); + expect(bunEntry).not.toContain("location.reload()"); + // The reload JS must be passed through `mainWindow.webview.executeJavascript(...)`. + // Bare-call placement in the Bun process would do nothing — the call must cross + // the webview boundary. Anchor the wrapper presence here. + expect(bunEntry).toContain("mainWindow.webview.executeJavascript("); + + // Duplicate-handler-registration guard (spec §10 D6). The boolean MUST be at + // module scope (declared before `if (isMac)`) — spec §6.3 wording is explicit. + // The state transition `menuHandlerRegistered = true` must also be present — + // omitting it would leave the guard permanently false and re-fire registrations. + expect(bunEntry).toContain("let menuHandlerRegistered = false"); + expect(bunEntry.indexOf("menuHandlerRegistered")).toBeLessThan( + bunEntry.indexOf("if (isMac) {"), + ); + expect(bunEntry).toContain("menuHandlerRegistered = true"); + // The state transition must be inside `if (!menuHandlerRegistered) { ... }`, + // not before it — otherwise the guard is always-false after first execution + // and no registration ever fires (or always re-fires, depending on placement). + expect(bunEntry.indexOf("menuHandlerRegistered = true")).toBeGreaterThan( + bunEntry.indexOf("if (!menuHandlerRegistered)"), + ); + expect(bunEntry).toContain('Electrobun.events.on("application-menu-clicked"'); + }); +``` + +- [ ] **Step 3: Run the new test to verify it fails** + +Run: `bun test src --test-name-pattern "generated bun entry installs the macOS application menu"` +Expected: FAIL — every `expect(...).toContain(...)` for menu-related text will fail because `generatedBunEntry()` hasn't been updated yet. + +- [ ] **Step 4: Update `generatedBunEntry()` to emit the menu install** + +Open `src/lib/templates/shell.ts`. Replace the entire `generatedBunEntry()` function body (currently lines 4-39) with the implementation below. + +Two non-obvious notes before editing: +1. The function returns a template literal whose contents become the generated `src/bun/index.ts`. Backslashes for newlines, dollar signs, and backticks inside that returned string must be escaped (`\\n`, `\${...}`, `` \` ``) so they survive the outer template literal in `shell.ts`. The block below already escapes everything correctly. +2. We keep the existing `isMac` declaration **once**, exactly as today. The new menu block reuses it. Do not redeclare. + +```ts +export function generatedBunEntry(config: ResolvedAppConfig): string { + const preset = getTitlebarPreset(config.titlebar); + const startMessage = `appbun wrapper started for ${config.url}`; + const descriptionMessage = `Description: ${config.description}`; + const styleMask = preset.macUsesUnifiedChrome + ? `{ + UnifiedTitleAndToolbar: true, + FullSizeContentView: true, + }` + : "{}"; + return `import Electrobun, { BrowserWindow, ApplicationMenu } from "electrobun/bun"; + +const isMac = process.platform === "darwin"; +let menuHandlerRegistered = false; + +const mainWindow = new BrowserWindow({ + title: ${JSON.stringify(config.title)}, + url: "views://mainview/index.html", + frame: { + width: ${config.width}, + height: ${config.height}, + x: 120, + y: 120, + }, + titleBarStyle: isMac ? ${JSON.stringify(preset.macTitleBarStyle)} : "default", + styleMask: isMac ? ${styleMask} : {}, + transparent: false, +}); + +mainWindow.webview.on("dom-ready", () => { + console.log(${JSON.stringify(`${config.name} shell loaded`)}) +}); + +if (isMac) { + // Built-in roles bind their own Cmd shortcuts; one custom Reload action + // routes Cmd+R to the embedded rather than reloading the shell. + ApplicationMenu.setApplicationMenu([ + { submenu: [ + { role: "hide" }, { role: "hideOthers" }, { role: "showAll" }, + { type: "separator" }, { role: "quit" }, + ]}, + { label: "Edit", submenu: [ + { role: "undo" }, { role: "redo" }, { type: "separator" }, + { role: "cut" }, { role: "copy" }, { role: "paste" }, + { role: "pasteAndMatchStyle" }, { role: "delete" }, { role: "selectAll" }, + ]}, + { label: "View", submenu: [ + { label: "Reload", action: "reload-app", accelerator: "r" }, + { role: "toggleFullScreen" }, + ]}, + { label: "Window", submenu: [ + { role: "minimize" }, { role: "zoom" }, + { type: "separator" }, + { role: "bringAllToFront" }, + ]}, + ]); + + const handleMenuClick = (e: { data: { action?: string } }) => { + if (e.data.action === "reload-app") { + // mainWindow.webview is the shell; reload the child + // so only the remote page refreshes, not the shell chrome. + // Cast to unknown so the optional-Promise check stays valid even if the + // declared return type is void (avoids TS strict "always false" error). + const result: unknown = mainWindow.webview.executeJavascript( + "document.getElementById('remote-app')?.reload()" + ); + if (result && typeof (result as { catch?: unknown }).catch === "function") { + (result as Promise).catch(() => {}); + } + } + }; + + // Boolean guard prevents duplicate handler registration within one module instance. + // bun dev --watch hot-reload may still accumulate handlers across module reloads; + // tracked as a known limitation (see dev-docs/native-menu-shortcuts.md §10 D6). + if (!menuHandlerRegistered) { + Electrobun.events.on("application-menu-clicked", handleMenuClick); + menuHandlerRegistered = true; + } +} + +console.log(${JSON.stringify(startMessage)}); +console.log(${JSON.stringify(descriptionMessage)}); +`; +} +``` + +- [ ] **Step 5: Run the new test to verify it passes** + +Run: `bun test src --test-name-pattern "generated bun entry installs the macOS application menu"` +Expected: PASS — every assertion matches. + +- [ ] **Step 6: Re-run the full generator test suite to catch regressions** + +Run: `bun run check && bun test src` +Expected: type check clean, every test passes (including the previously updated `renderTemplateFiles includes electrobun entry` test from Task 1, the `system titlebar preset falls back to native chrome` test that asserts `'titleBarStyle: isMac ? "default" : "default"'`, and the `compact titlebar preset lowers toolbar height` test). + +If any titlebar-preset test fails, the most likely cause is that you removed or shifted the `titleBarStyle: isMac ? ${JSON.stringify(...)} : "default"` line. Restore it exactly. + +- [ ] **Step 7: Build and run the CLI against a throwaway target to eye-check the emitted file** + +Run: +```bash +bun run build && node ./bin/appbun.js https://example.com --name Example --out-dir /tmp/appbun-menu-check --quiet +``` +Then: +```bash +grep -n "ApplicationMenu.setApplicationMenu\|reload-app\|menuHandlerRegistered" /tmp/appbun-menu-check/src/bun/index.ts +``` +Expected output: three matches, all in correct positions (import / menu install / handler registration). Clean up: `rm -rf /tmp/appbun-menu-check`. + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/templates/shell.ts src/__tests__/generator.test.ts +git commit -m "Install native macOS menu in generated bun entry" +``` + +--- + +## Task 3: Extend `scaffold-smoke.yml` with three grep guards + +**Files:** +- Modify: `.github/workflows/scaffold-smoke.yml` (current last asserts are around lines 25-28) + +**Trigger scope (intentional):** `scaffold-smoke.yml` runs on `push: main` + `workflow_dispatch` only — i.e., *after* merge. We do **not** add a `pull_request` trigger because the smoke step invokes `node ./bin/appbun.js https://example.com ...`, which makes a live network fetch to `https://example.com` for metadata; turning that into a required PR check would create a flaky merge gate when the external host or CI network has a transient issue. The pre-merge gate is `ci.yml` (which already runs on `pull_request` and executes the Task 2 unit test — covering the same menu contract in-process). If the post-merge smoke fails after a bad change lands, revert and re-land per the rollback note in Task 9 step 4. + +- [ ] **Step 1: Add the three new grep steps** + +Open `.github/workflows/scaffold-smoke.yml`. After the existing `- run: grep -q appbun.generated.json ./tmp/example/README.md` step (currently the last step at line 28), append three new steps so the tail of the `steps:` list reads: + +```yaml + - run: grep -q appbun.generated.json ./tmp/example/README.md + - run: grep -q 'ApplicationMenu.setApplicationMenu' ./tmp/example/src/bun/index.ts + - run: grep -q 'process.platform === "darwin"' ./tmp/example/src/bun/index.ts + - run: grep -q 'role: "copy"' ./tmp/example/src/bun/index.ts +``` + +> Note on quoting: GitHub Actions YAML wraps each `run:` value in a shell invocation. The single-quoted patterns above are passed verbatim to `grep -q`; the inner double-quotes survive because they're inside single quotes. `process.platform === "darwin"` is not literally present in our `generatedBunEntry()` output today — we use `const isMac = process.platform === "darwin"`, so the substring match is satisfied. +> +> The `role: "copy"` grep (instead of just `"copy"`) makes the assertion specific to the Edit-menu Copy role — it cannot pass by coincidence on the word "copy" appearing in a comment or string elsewhere. + +- [ ] **Step 2: Smoke-run the same commands locally** + +Run: +```bash +bun run build && node ./bin/appbun.js https://example.com --name Example --out-dir /tmp/appbun-smoke --quiet +grep -q 'ApplicationMenu.setApplicationMenu' /tmp/appbun-smoke/src/bun/index.ts && echo OK1 +grep -q 'process.platform === "darwin"' /tmp/appbun-smoke/src/bun/index.ts && echo OK2 +grep -q 'role: "copy"' /tmp/appbun-smoke/src/bun/index.ts && echo OK3 +rm -rf /tmp/appbun-smoke +``` +Expected: three lines `OK1`, `OK2`, `OK3`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/scaffold-smoke.yml +git commit -m "Assert native macOS menu lands in generated project (scaffold-smoke)" +``` + +--- + +## Task 4: Update top-level `CLAUDE.md` + +Two changes: (a) fix two stale references to `scaffold.yml` (real file is `scaffold-smoke.yml`); (b) document the new grep contract. + +**Files:** +- Modify: `CLAUDE.md` (top-level; lines 27 and 36 hold the stale filename; line 27 also holds the contract-grep list) + +- [ ] **Step 1: Replace the stale filename + extended grep contract on line 27** + +Find the line that begins `CI (`.github/workflows/ci.yml`)` (currently line 27). Replace it with: + +```markdown +CI (`.github/workflows/ci.yml`) runs `check`, `test`, `build`, and `npm pack --dry-run`. A separate `scaffold-smoke.yml` smoke-tests `node ./bin/appbun.js https://example.com ...` and asserts specific files in the generated project — keep these references intact in templates: `APPLE_SIGN_IDENTITY`, `APPLE_NOTARIZE`, `shell-status`, `appbun.generated.json`, `ApplicationMenu.setApplicationMenu`, `process.platform === "darwin"`, and the literal `role: "copy"` (the Edit-menu role) in `src/bun/index.ts`. +``` + +- [ ] **Step 2: Fix the second stale reference on line 36** + +Find the line that begins `- `templates/` —` and contains the phrase `updating `.github/workflows/scaffold.yml``. Replace `.github/workflows/scaffold.yml` with `.github/workflows/scaffold-smoke.yml` in that line. + +- [ ] **Step 3: Verify no other occurrences of the stale name remain** + +Run: `grep -n "scaffold\.yml" /Users/laeyoung/Documents/personal/appbun/CLAUDE.md` +Expected: no output (an empty result). Any remaining match indicates a missed reference — fix it before continuing. (Use the absolute path so this check is independent of the shell's current working directory.) + +- [ ] **Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "Document menu grep contract and fix scaffold-smoke filename in CLAUDE.md" +``` + +--- + +## Task 5: Add Quality Bar entry to `skills/appbun-web-desktop/SKILL.md` + +**Files:** +- Modify: `skills/appbun-web-desktop/SKILL.md:73-83` (the `## Quality Bar` section) + +- [ ] **Step 1: Insert the new bullet** + +Open `skills/appbun-web-desktop/SKILL.md`. The existing Quality Bar list ends with the bullet about "Temporary smoke-test output is created outside the source repo and deleted when the user only asked for validation." (line 83). Add **one** new bullet directly above the bullet that starts `- The wrapper source is committed...` so the list reads (in this order): + +```markdown +## Quality Bar + +Before considering the desktop wrapper done: + +- The generated app name, package name, icon, window size, and theme color match the product. +- On macOS, standard keyboard shortcuts (`Cmd+C/V/X/A/Z/Shift+Z/Q/R/M`) and the right-click Cut/Copy items work immediately in the built app. +- The wrapper source is committed or clearly isolated from the main web app in a dedicated output directory. +- `bun run build` succeeds inside the generated project, or the remaining blocker is stated with logs. +- `appbun doctor --project` is run in the generated project and warnings are explained. +- Release workflows use native OS runners for platform builds. +- README or release notes explain that the output is inspectable Electrobun code, not a black-box binary wrapper. +- Temporary smoke-test output is created outside the source repo and deleted when the user only asked for validation. +``` + +- [ ] **Step 2: Commit** + +```bash +git add skills/appbun-web-desktop/SKILL.md +git commit -m "Add macOS shortcut expectation to the appbun-web-desktop Quality Bar" +``` + +--- + +## Task 6: Sync `skills/appbun-web-desktop/CLAUDE.md` + +The skill's CLAUDE.md doesn't currently have a Quality Bar section, so we add the same statement under the existing `## Verification` section. + +**Files:** +- Modify: `skills/appbun-web-desktop/CLAUDE.md` (the `## Verification` section) + +- [ ] **Step 1: Insert the shortcut verification bullet** + +Open `skills/appbun-web-desktop/CLAUDE.md`. Find the `## Verification` heading. After the existing code block ending with `npx -y appbun@latest package --install`, add a new paragraph and bullet so the section reads (only the new paragraph + bullet is new — everything else is existing): + +```markdown +## Verification + +After generation: + +```bash +cd ../appbun-output/ +npx -y appbun@latest doctor --project +npx -y appbun@latest package --install +``` + +After installing the built macOS DMG, also confirm desktop fundamentals: + +- In any text input on the launched app, `Cmd+C/V/X/A/Z/Shift+Z` work, the App / Edit / View / Window menus appear in the menu bar, and right-click Cut/Copy are enabled when text is selected. If they are not, the generated `src/bun/index.ts` is likely missing the `ApplicationMenu.setApplicationMenu(...)` call. + +On macOS, build a local DMG: +``` + +(Keep the rest of the file exactly as-is.) + +- [ ] **Step 2: Commit** + +```bash +git add skills/appbun-web-desktop/CLAUDE.md +git commit -m "Mirror macOS shortcut verification in appbun-web-desktop CLAUDE.md" +``` + +--- + +## Task 7: Full local verification + +This task is verification-only — no code edits. It exists as a hard checkpoint before bumping the version. + +- [ ] **Step 1: Run the full check + test pipeline** + +Run: `bun run check && bun test src` +Expected: all green. + +- [ ] **Step 2: Run `release:check` to validate the published file list** + +Run: `bun run release:check` +Expected: build succeeds, `npm pack --dry-run` lists `bin/`, `dist/`, `skills/`, `README.md`, `CONTRIBUTING.md`, `LICENSE`. No new files should sneak into the tarball (the spec changes touch only files already inside `files`). + +- [ ] **Step 3: End-to-end scaffold smoke locally** + +Reproduce what `scaffold-smoke.yml` does: + +```bash +node ./bin/appbun.js https://example.com --name Example --out-dir /tmp/appbun-e2e --quiet +test -f /tmp/appbun-e2e/electrobun.config.ts +test -f /tmp/appbun-e2e/appbun.generated.json +test -f /tmp/appbun-e2e/package.json +test -f /tmp/appbun-e2e/src/mainview/index.ts +test -f /tmp/appbun-e2e/scripts/create-dmg.mjs +grep -q APPLE_SIGN_IDENTITY /tmp/appbun-e2e/scripts/create-dmg.mjs +grep -q APPLE_NOTARIZE /tmp/appbun-e2e/scripts/create-dmg.mjs +grep -q shell-status /tmp/appbun-e2e/src/mainview/index.html +grep -q appbun.generated.json /tmp/appbun-e2e/README.md +grep -q 'ApplicationMenu.setApplicationMenu' /tmp/appbun-e2e/src/bun/index.ts +grep -q 'process.platform === "darwin"' /tmp/appbun-e2e/src/bun/index.ts +grep -q 'role: "copy"' /tmp/appbun-e2e/src/bun/index.ts +rm -rf /tmp/appbun-e2e +``` +Expected: every command exits 0; no output is the success case. + +- [ ] **Step 4: Type-check the generated project to confirm `Electrobun.events.on` resolves** + +The spec (§10 D6) acknowledges that the Electrobun events API is documented only as `on` and the default-export shape is inferred from the example, not verified from the package types. Catch any import/type mismatch *before* the version bump by type-checking the throwaway project from Step 3: + +```bash +node ./bin/appbun.js https://example.com --name Example --out-dir /tmp/appbun-typecheck --install --quiet +cd /tmp/appbun-typecheck +npx --yes typescript@latest tsc --noEmit +cd - && rm -rf /tmp/appbun-typecheck +``` + +Expected: `tsc --noEmit` exits 0. If it errors on the `Electrobun` default import or on `Electrobun.events.on`, swap to the named-import fallback noted in spec §6.3: + +```ts +import { BrowserWindow, ApplicationMenu, events } from "electrobun/bun"; +// … +events.on("application-menu-clicked", handleMenuClick); +``` + +Apply **both** swaps inside the `generatedBunEntry()` template literal in `src/lib/templates/shell.ts`: (a) change the emitted import line from `import Electrobun, { BrowserWindow, ApplicationMenu } from "electrobun/bun"` to `import { BrowserWindow, ApplicationMenu, events } from "electrobun/bun"`, and (b) change the emitted `Electrobun.events.on("application-menu-clicked", handleMenuClick)` call to `events.on("application-menu-clicked", handleMenuClick)`. Without (b), the generated project still references the broken default export and the type-check failure recurs. Then drop the `esModuleInterop: true` from `generatedTsconfig()` in `src/lib/templates/project.ts` (Task 1's emit change is reverted), **and remove the Task 1 assertion** `expect(files.find((file) => file.path === "tsconfig.json")?.content).toContain('"esModuleInterop": true')` from `src/__tests__/generator.test.ts` so it doesn't keep failing. Then update the Task 2 test in `src/__tests__/generator.test.ts` so both menu-import assertions match the named-import form — **replace** `expect(bunEntry).toContain('import Electrobun, { BrowserWindow, ApplicationMenu } from "electrobun/bun"')` with `expect(bunEntry).toContain('import { BrowserWindow, ApplicationMenu, events } from "electrobun/bun"')`, and **replace** `expect(bunEntry).toContain('Electrobun.events.on("application-menu-clicked"')` with `expect(bunEntry).toContain('events.on("application-menu-clicked"')`. Leaving the original default-import assertion in place would silently fail every test run. Re-run the full pipeline from Task 7 step 1. + +- [ ] **Step 5: No commit — verification only** + +If anything in this task fails, do **not** proceed to Task 8. Go back to whichever earlier task produced the regression and fix it there. + +--- + +## Task 8: Bump version to `0.10.5` + +**Files:** +- Modify: `package.json` (the `"version"` field) + +- [ ] **Step 1: Read the current version** + +Run: `grep -n '"version"' package.json | head -1` +Expected: ` "version": "0.10.4",` + +- [ ] **Step 2: Update to `0.10.5`** + +Open `package.json`. Change the line `"version": "0.10.4",` to `"version": "0.10.5",`. + +- [ ] **Step 3: Verify `getAppbunVersion()` reads the new value** + +Run: `bun test src --test-name-pattern "renderTemplateFiles includes electrobun entry"` +Expected: PASS — the test asserts `"version": "${getAppbunVersion()}"` is embedded in the generated manifest, so a version mismatch would fail here. + +- [ ] **Step 4: Commit** + +```bash +git add package.json +git commit -m "Release v0.10.5" +``` + +--- + +## Task 9: Manual macOS DMG verification (release-blocking, not commit-gated) + +This is the spec §8.2 checklist. It must pass on a real macOS machine before publishing the GitHub Release that triggers `publish.yml`. It is **not** part of the merged-PR gate — it gates the release tag, per spec §9. + +- [ ] **Step 1: Build the DMG from a fresh scaffold** + +First, make sure the CLI's own `dist/` is fresh — Task 7 step 2 was the last build, and Task 8 only edited `package.json`, but rebuild anyway to guarantee the version-bumped CLI is what's running: + +```bash +bun run build +node ./bin/appbun.js https://duckduckgo.com -o /tmp/appbun-menu-dmg --install +cd /tmp/appbun-menu-dmg && bun run build:dmg +open build/*.dmg +``` + +**Apple credentials note:** `bun run build:dmg` invokes `scripts/create-dmg.mjs`. The generated script only requires `APPLE_SIGN_IDENTITY` when you explicitly run `package --sign` or `--notarize`; an unsigned DMG (Gatekeeper-restricted on first launch, fine for local verification) is produced when no Apple env vars are set. If `build:dmg` errors with a `codesign` failure, re-run as `APPLE_SIGN_IDENTITY="" bun run build:dmg` to force the unsigned path, or use `bun run build` (no DMG) and launch the app from `build//.app` directly. + +Install the app from the DMG and launch it. + +- [ ] **Step 2: Verify every item in the spec §8.2 manual checklist** + +For each item, mark pass/fail in the PR description: + +- Right-click → Cut/Copy enabled when text is selected +- `Cmd+C` copies (cross-check with `pbpaste`) +- `Cmd+V` pastes, `Cmd+X` cuts +- `Cmd+A` selects all +- `Cmd+Z` undoes, `Cmd+Shift+Z` redoes +- `Cmd+Q` quits the app +- `Cmd+M` minimizes, `Cmd+Ctrl+F` toggles fullscreen +- `Cmd+R` reloads **only** the embedded `` — shell chrome/menu bar should not flicker. Only the remote page refreshes. +- Toolbar `#reload-app` button still works (`about:blank` → current src semantics, distinct from Cmd+R, per spec §10 D2) +- Korean (or any IME) input still works in text inputs +- The menu bar actually shows App / Edit / View / Window. If shortcuts work but the menu bar is empty, `setApplicationMenu` silently failed and item 8 is a false positive. +- App menu (first item) contains Hide / Hide Others / Show All / Quit +- Edit menu contains all 8 items (Undo through Select All including Paste and Match Style) +- Known limitation check (do NOT block on this): in `bun dev --watch`, after 5 hot reloads, one Cmd+R should fire ≥1 reload — N>1 is the documented limitation (spec §10 D6). In the production DMG it must be exactly 1. + +- [ ] **Step 3: Verify on one or two recipe apps** + +Quick sanity on an unauthenticated recipe: + +```bash +node ./bin/appbun.js wikipedia -o /tmp/appbun-menu-wiki --install +cd /tmp/appbun-menu-wiki && bun run build:dmg && open build/*.dmg +``` + +Confirm Cmd+C and Cmd+Q work in the installed Wikipedia app. Clean up `/tmp/appbun-menu-dmg` and `/tmp/appbun-menu-wiki` after verification. + +- [ ] **Step 4: Open the PR and request review** + +PR title suggestion: `Install native macOS menu in generated apps (v0.10.5)` + +PR body must include: +- A link to `dev-docs/native-menu-shortcuts.md` +- The §8.2 checklist with pass/fail marks +- A Windows follow-up note: "Windows partial support (single-character accelerators per spec §10 D5) is tracked as a separate post-0.10.5 PR; this change leaves Windows/Linux behavior unchanged." +- Rollback note (matches spec §9.1): if a regression is reported post-publish, revert the **five** commits from Tasks 1, 2, 3, 4, and 8. **Do not revert Tasks 5 or 6** — the skills/Quality-Bar updates are documentation expressing the new product expectation, and spec §9.1 explicitly excludes `skills/appbun-web-desktop/SKILL.md` and `skills/appbun-web-desktop/CLAUDE.md` from rollback. Within Task 4, keep the `scaffold.yml → scaffold-smoke.yml` filename fix (it is a standalone correctness fix unrelated to the menu code) by reverting only the grep-contract paragraph if needed. See spec §9.1 for the full revert recipe and `npm deprecate` syntax. +- Note: the Release that triggers `publish.yml` should be created **after** merge by publishing the `v0.10.5` GitHub Release (a bare tag push will not fire the workflow, per spec §9 step 3). `publish.yml` also accepts `workflow_dispatch` as a manual fallback. Release body text is in spec §7 (last row). + +- [ ] **Step 5: Publish the GitHub Release to fire `publish.yml`** + +After PR merge, on the GitHub UI: **Releases → Draft a new release → Tag: `v0.10.5` → Publish release** with the release body verbatim from spec §7's last row: + +> Generated apps now ship a native macOS application menu, restoring Cmd+C/V/X/A/Z/Q and other standard shortcuts. + +`publish.yml` fires on `release: published` and runs `check` + `test` + `build` + `npm publish` itself — any of those gates failing aborts the publish. If a gate fails or the trigger does not fire for any reason: fix the issue, then re-run via `workflow_dispatch` from the Actions tab (or delete + recreate the GitHub Release to re-fire `release: published`). + +--- + +## Self-Review (executed by the plan author) + +**Spec coverage check:** + +| Spec section | Plan task | +|---|---| +| §2 Root cause (no NSMenu) | Addressed by Task 2 — menu install + roles | +| §3 Goal 1 (works out of box) | Task 2 (template emits it unconditionally on darwin) | +| §3 Goal 2 (no new CLI flags) | No CLI changes anywhere in the plan | +| §3 Goal 3 (don't break Win/Linux) | Task 2 wraps in `if (isMac)`; no Win/Linux paths touched | +| §3 Goal 4 (CI protected) | Task 3 (grep guards) + Task 2 (unit test) | +| §6.1 menu structure | Task 2 step 4 matches the table exactly (App, Edit, View, Window) | +| §6.2 platform guard | Task 2 step 4 uses `if (isMac) { ApplicationMenu.setApplicationMenu(...) }` | +| §7 file changes | Tasks 1, 2, 3, 4, 5, 6, 8 cover every row of the §7 table | +| §8.1 automated tests | Tasks 1, 2 (`bun test` assertions); Task 7 step 1 (`check`+`test`), step 2 (`release:check`), step 3 (scaffold-smoke parity), step 4 (generated-project type-check) | +| §8.2 manual checklist | Task 9 | +| §9 rollout | Task 8 + Task 9 PR/release notes | +| §10 D1 (no `reload` role) | Task 2 uses `{ label: "Reload", action: "reload-app", accelerator: "r" }` | +| §10 D2 (child webview reload) | Task 2 handler invokes `document.getElementById('remote-app')?.reload()`; test asserts the wrong-form `location.reload()` is absent | +| §10 D3 (no `about` role) | Task 2 menu structure has no About item | +| §10 D4 (empty label = app menu) | Task 2 step 4: first submenu entry has no `label`, matching the documented pattern | +| §10 D5 (Linux unsupported / Windows partial) | `if (isMac)` guard in Task 2; Windows partial support tracked as follow-up in Task 9 step 4 PR body | +| §10 D6 (handler dedup) | Task 2 emits `menuHandlerRegistered` boolean guard + inline comment | +| §10 bringAllToFront vs front | Task 2 emits `role: "bringAllToFront"`; positive `expect(bunEntry).toContain('role: "bringAllToFront"')` assertion locks the correct name (no separate negative assertion — `bringAllToFront` does not contain the substring `role: "front"`). | + +No gaps. + +**Placeholder scan:** None. Every step has either exact code or an exact command + expected output. + +**Type consistency:** The Reload action string `"reload-app"` is identical in the menu definition and the handler `if (e.data.action === "reload-app")`. The boolean guard variable name `menuHandlerRegistered` is identical at declaration and at the `if (!menuHandlerRegistered)` check. The DOM id `"remote-app"` matches what `src/mainview/index.ts` already sets at `shell.ts:359` (`remoteApp.setAttribute("id", "remote-app");`). + +--- + +Plan complete and saved to `dev-docs/2026-05-28-native-menu-shortcuts-plan.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? diff --git a/dev-docs/native-menu-shortcuts.md b/dev-docs/native-menu-shortcuts.md new file mode 100644 index 0000000..26841df --- /dev/null +++ b/dev-docs/native-menu-shortcuts.md @@ -0,0 +1,255 @@ +# Spec: Native Application Menu & Standard Keyboard Shortcuts + +| | | +|---|---| +| **Status** | Ready (open questions resolved 2026-05-28) | +| **Date** | 2026-05-28 | +| **Owner** | @laeyoung | +| **Target release** | appbun `0.10.5` (patch, current is `0.10.4`) | +| **Scope** | Generator template only — no new CLI flags | + +## 1. Background + +`appbun`이 생성한 macOS DMG 앱을 실제로 사용해 보면, 텍스트 입력 필드에서 다음 표준 단축키가 동작하지 않는다: + +- `Cmd+C` / `Cmd+V` / `Cmd+X` (Copy / Paste / Cut) +- `Cmd+A` (Select All) +- `Cmd+Z` / `Cmd+Shift+Z` (Undo / Redo) +- `Cmd+Q` (Quit) + +우클릭 컨텍스트 메뉴를 열어도 Cut/Copy가 회색 처리되어 있다 (텍스트 선택이 되어 있어도). 이는 데스크탑 앱으로서 치명적인 사용성 결함이며 `docs/pake-grade-goal.md`의 "instant, inspectable, and **reliable**" 제품 바를 위반한다. + +## 2. Root Cause + +생성된 `src/bun/index.ts` (템플릿 위치: `src/lib/templates/shell.ts` → `generatedBunEntry()`)는 `BrowserWindow`만 생성하고 애플리케이션 메뉴를 전혀 설치하지 않는다. + +macOS에서 `Cmd+C` 등의 표준 단축키가 포커스된 `WKWebView`까지 도달하려면, `NSMenu`의 Edit 메뉴 항목이 표준 셀렉터(`copy:` / `paste:` / `cut:` / `selectAll:` / `undo:` / `redo:`)로 First Responder 체인에 연결돼 있어야 한다. 메뉴 자체가 없으니 키 이벤트가 라우팅되지 않고, WebKit의 컨텍스트 메뉴도 셀렉터에 호응하는 First Responder가 없다고 판단해 항목을 비활성화한다. + +Electrobun 1.18.1은 이 문제를 위한 정식 API `ApplicationMenu.setApplicationMenu(...)`를 제공한다. `role`을 지정한 메뉴 항목은 OS가 표준 단축키를 자동 바인딩한다. 따라서 **빌트인 role만 등록해도 표준 단축키 전체가 해결**된다. + +## 3. Goals + +1. `appbun create ...`로 생성된 모든 macOS 앱이 빌드 직후부터 표준 클립보드/편집/창 단축키를 갖춘다. +2. CLI 플래그를 추가하지 않는다 — 생성물 품질을 올리는 방향 (CLAUDE.md 원칙). +3. Windows/Linux 빌드를 깨뜨리지 않는다. +4. 변경이 CI(`ci.yml` + `scaffold-smoke.yml`)에서 보호된다. + +## 4. Non-Goals + +- 커스텀 키보드 단축키를 사용자가 설정할 수 있게 하는 기능. +- Recipe 단위로 메뉴를 커스터마이즈하는 기능. +- 윈도우/리눅스용 네이티브 메뉴의 풀 커스터마이즈 (1차에서는 기본 동작 유지). +- 트레이/메뉴바 아이콘. + +## 5. Reproduction + +```bash +node ./bin/appbun.js https://example.com -o /tmp/appbun-repro --install +cd /tmp/appbun-repro && bun run build:dmg +open build/*.dmg +``` + +설치 후 입력 가능한 페이지(예: https://duckduckgo.com)를 열고 텍스트를 선택, `Cmd+C` 시도 — 클립보드에 복사되지 않음. 우클릭하면 Cut/Copy 항목이 비활성화 상태. + +## 6. Proposed Solution + +### 6.1 메뉴 구조 + +생성된 `src/bun/index.ts`에 다음 메뉴를 설치한다. + +| 메뉴 | 항목 | 비고 | +|---|---|---| +| **App** (label 비움 → macOS가 앱 이름으로 채움) | `hide`, `hideOthers`, `showAll`, separator, `quit` | `Cmd+H`, `Cmd+Opt+H`, `Cmd+Q` 자동. `about`은 Electrobun 지원 role 아님 → 제외 (§10 D3). | +| **Edit** | `undo`, `redo`, separator, `cut`, `copy`, `paste`, `pasteAndMatchStyle`, `delete`, `selectAll` | `Cmd+Z`, `Cmd+Shift+Z`, `Cmd+X/C/V`, `Cmd+Shift+Opt+V`, `Cmd+A` 자동 | +| **View** | `Reload` (커스텀 action, accelerator `r`), `toggleFullScreen` | `Cmd+R`, `Cmd+Ctrl+F`. `minimize`/`zoom`은 macOS HIG에 따라 Window 메뉴에만 둬서 중복 바인딩을 피한다. | +| **Window** | `minimize`, `zoom`, separator, `bringAllToFront` | 표준 macOS Window 메뉴. `front`이 아닌 `bringAllToFront`가 올바른 role 이름. | + +> `Cmd+R`은 Electrobun에 `reload` role이 없으므로(§10 D1) `{ label: "Reload", action: "reload-app", accelerator: "r" }`로 등록하고 `Electrobun.events.on("application-menu-clicked", ...)`에서 처리. 핸들러 동작은 §10 D2 참고 — **`mainWindow.webview`는 shell(`views://mainview/index.html`)을 가리키며, 실제 원격 페이지는 그 shell 안의 `` 자식**이다. 그래서 핸들러는 `mainWindow.webview.executeJavascript("document.getElementById('remote-app')?.reload()")`로 *자식 webview 태그의 reload 메서드*를 호출한다(공식 문서에 ``의 `reload` 메서드 명시). + +**Trade-off — Cmd+R 인터셉트:** 네이티브 메뉴 accelerator는 페이지보다 먼저 키 이벤트를 가져간다. Linear, Notion 등 일부 웹앱이 Cmd+R을 내부 단축키로 쓰는 경우(예: Linear "Create new") 그 바인딩이 silent하게 동작 안 한다. 일반 사용자 기대치(브라우저 리로드)가 더 보편적이라고 판단해 1차 PR에서는 인터셉트를 수용. 후속에서 recipe별 opt-out 또는 메뉴에서 Reload 제거 옵션 검토. + +### 6.2 플랫폼 가드 + +Electrobun 공식 문서에 따르면 `ApplicationMenu`는 **macOS만 완전 지원**, Windows는 단일 문자 accelerator만 지원, **Linux는 미지원**(§10 D5). 1차 PR에서는 `if (process.platform === "darwin")` 가드 안에서만 `ApplicationMenu.setApplicationMenu`를 호출한다. Windows 확장은 별도 후속 PR. + +### 6.3 코드 예시 (참고용, 실제 구현은 §7에서 명세) + +기존 `generatedBunEntry`는 이미 `const isMac = process.platform === "darwin"` (현 shell.ts:16)를 선언하고 있으므로 **새 코드는 그 변수를 재사용**한다. 재선언하면 TS `Cannot redeclare` 에러. + +**TS 컴파일 호환성:** 생성된 `tsconfig.json`(`project.ts:145-158`의 `generatedTsconfig()`)에 현재 `esModuleInterop`이 없다. Electrobun의 default export(`Electrobun`)를 사용하려면 §7에서 `generatedTsconfig()`에 `"esModuleInterop": true`를 추가해야 한다. (또는 default import 대신 Electrobun이 named export로 노출하는 events 객체를 발견하면 default import를 회피 — 1차 PR에서는 공식 문서 예제와 동일하게 default import + esModuleInterop 추가 채택.) + +```ts +// import 라인에 ApplicationMenu와 Electrobun(default) 추가 +import Electrobun, { BrowserWindow, ApplicationMenu } from "electrobun/bun"; + +const isMac = process.platform === "darwin"; // 기존 라인 — 재사용 +let menuHandlerRegistered = false; // 모듈 스코프 boolean 가드 (§10 D6) +const mainWindow = new BrowserWindow({ /* 기존 옵션 */ }); + +if (isMac) { + ApplicationMenu.setApplicationMenu([ + { submenu: [ + { role: "hide" }, { role: "hideOthers" }, { role: "showAll" }, + { type: "separator" }, { role: "quit" }, + ]}, + { label: "Edit", submenu: [ + { role: "undo" }, { role: "redo" }, { type: "separator" }, + { role: "cut" }, { role: "copy" }, { role: "paste" }, + { role: "pasteAndMatchStyle" }, { role: "delete" }, { role: "selectAll" }, + ]}, + { label: "View", submenu: [ + { label: "Reload", action: "reload-app", accelerator: "r" }, + { role: "toggleFullScreen" }, + ]}, + { label: "Window", submenu: [ + { role: "minimize" }, { role: "zoom" }, + { type: "separator" }, + { role: "bringAllToFront" }, + ]}, + ]); + + // 핸들러 타입은 Electrobun이 application-menu-clicked 이벤트 타입을 export하면 + // 그걸 사용하고, 없으면 아래 인라인 shape로 폴백. + const handleMenuClick = (e: { data: { action?: string } }) => { + if (e.data.action === "reload-app") { + // mainWindow.webview는 shell. 실제 원격 페이지는 그 안의 + // . 자식 webview 태그의 reload 호출. + mainWindow.webview.executeJavascript( + "document.getElementById('remote-app')?.reload()" + ); + } + }; + + // 동일 모듈 인스턴스 수명 내 중복 등록 방지(§10 D6 부분 완화). + // bun dev --watch 핫리로드 시 모듈 재실행으로 인한 누적은 알려진 한계. + if (!menuHandlerRegistered) { + Electrobun.events.on("application-menu-clicked", handleMenuClick); + menuHandlerRegistered = true; + } +} +``` + +## 7. File-Level Changes + +| 파일 | 변경 내용 | +|---|---| +| `src/lib/templates/shell.ts` | `generatedBunEntry()`에서: ① 템플릿 리터럴 내부 import 라인을 `import { BrowserWindow } from "electrobun/bun"` → `import Electrobun, { BrowserWindow, ApplicationMenu } from "electrobun/bun"`으로 교체. ② 기존 `isMac` 변수를 재사용해 `if (isMac)` 가드 안에 `ApplicationMenu.setApplicationMenu(...)` 호출 + `reload-app` 핸들러 추가. ③ 모듈 스코프 boolean 가드(§10 D6)로 핸들러 중복 등록 방지. ④ `executeJavascript` 결과가 Promise인지 구현 시 확인하여 필요하면 `.catch(() => {})` 첨부. | +| `src/lib/templates/project.ts` (`generatedTsconfig`) | `compilerOptions`에 `"esModuleInterop": true` 추가 (default import `import Electrobun ...` 호환). | +| `src/__tests__/generator.test.ts` | 생성된 `src/bun/index.ts`에 `ApplicationMenu.setApplicationMenu` 호출 + `process.platform === "darwin"` 가드 + Edit role(`"copy"`, `"paste"`, `"cut"`, `"selectAll"`) 문자열이 포함되는지 어설션 추가. 생성된 `tsconfig.json`에 `esModuleInterop: true`가 들어있는지도 어설션. | +| `.github/workflows/scaffold-smoke.yml` | 3개 grep 추가: `ApplicationMenu.setApplicationMenu`(호출 존재), `'process.platform === "darwin"'`(가드 존재), `'"copy"'`(Edit role 존재) — 부분 회귀 방지. | +| `CLAUDE.md` (top-level) | (a) scaffold 계약 설명에 신규 grep 3종 명시. (b) **워크플로 파일명 정정**: 현재 27행 / 36행이 `scaffold.yml`로 적혀 있으나 실제 파일은 `.github/workflows/scaffold-smoke.yml`이다. 두 곳 모두 `scaffold-smoke.yml`로 정정. | +| `skills/appbun-web-desktop/SKILL.md` | "Quality bar"에 "표준 macOS 단축키(Cmd+C/V/X/A/Z/Q)가 즉시 동작" 항목 추가. | +| `skills/appbun-web-desktop/CLAUDE.md` | 동일 문구 동기화. | +| `package.json` | `version` → `0.10.5` | +| GitHub release body | "Generated apps now ship a native macOS application menu, restoring Cmd+C/V/X/A/Z/Q and other standard shortcuts." (CHANGELOG.md 파일은 repo에 없음 — release note는 GitHub release로 게시.) | + +## 8. Test Plan + +### 8.1 자동 테스트 +- `bun run check` — 타입 통과 (생성된 코드는 사용자 프로젝트에서 컴파일되므로, appbun 자체의 타입체크에는 영향 없음) +- `bun test src` — 새로 추가된 어설션 포함 통과 +- `bun run release:check` — pack 검증 통과 +- `scaffold-smoke.yml` 그린 + +### 8.2 수동 검증 (release 전 필수) +재현 경로(§5)와 동일하게 빌드 후 실제 DMG를 설치하고: + +- [ ] 우클릭 → Cut/Copy 활성화 +- [ ] 텍스트 선택 후 `Cmd+C` → 클립보드 복사 확인 (`pbpaste`로 더블체크) +- [ ] `Cmd+V` 붙여넣기, `Cmd+X` 잘라내기 +- [ ] `Cmd+A` 전체 선택, `Cmd+Z` 되돌리기, `Cmd+Shift+Z` 다시 실행 +- [ ] `Cmd+Q`로 앱 종료 +- [ ] `Cmd+M` 최소화, `Cmd+Ctrl+F` 풀스크린 +- [ ] `Cmd+R` → shell이 아니라 **shell 안의 `` 자식 webview만 그 자리에서 리로드** (§10 D2 — `location.reload()`는 잘못된 접근, `document.getElementById('remote-app')?.reload()`가 정공법). 시각적으로 toolbar/메뉴바는 깜박이지 않아야 하며 원격 페이지만 새로고침. +- [ ] 툴바 `#reload-app` 버튼 → 기존 시맨틱(`src`를 `about:blank` → 현재 src 어트리뷰트로 재설정)으로 동작. Cmd+R과 의미가 다름을 §10 D2에서 명시. +- [ ] 한글 입력 IME 작동에 회귀 없음 +- [ ] **메뉴바가 실제로 노출되는지 확인** — 앱 메뉴(첫 항목)가 앱 이름으로, Edit/View/Window 메뉴가 메뉴바에 표시. 단축키만 동작하고 메뉴바가 없으면 `setApplicationMenu` 호출이 silent fail한 것이며 WebKit 기본 키바인딩으로 인한 false positive 가능. +- [ ] App 메뉴를 클릭해서 Hide/Hide Others/Show All/Quit 항목이 보이는지 +- [ ] Edit 메뉴를 클릭해서 Undo~Select All 8개 항목이 보이는지 +- [ ] **알려진 한계 확인:** `bun dev --watch`로 5회 핫리로드 후 Cmd+R 1회 → reload가 N회 트리거되는지 카운트(§10 D6은 watch 모드 누적을 1차 PR에서 해결하지 못함 — N>1이어도 acceptance를 막지 않으나 follow-up 트래킹 대상). 프로덕션(빌드된 DMG)에서는 reload가 1회만 발생해야 함. + +대표 recipes로도 1회 검증. **인증 불필요한 사이트 우선**(`appbun create https://duckduckgo.com` 또는 `appbun create wikipedia`)으로 텍스트 입력/단축키 기본 동작 확인. 인증 있는 앱(ChatGPT, Linear, Gmail)은 로그인 후 입력창에서 추가 회귀 확인 — 계정이 있는 경우 한정. + +## 9. Rollout + +1. `dev-docs/native-menu-shortcuts.md` (이 문서) 머지. +2. 위 §7 파일 변경을 한 PR로 묶어서 머지. +3. **퍼블리시 트리거**: `.github/workflows/publish.yml`이 `release: published` 이벤트(또는 `workflow_dispatch`)로 동작한다. GitHub에서 `v0.10.5` 태그로 Release를 *Publish*해야 npm publish가 실행된다. 직접 `npm publish` 또는 단순 tag push만으로는 발화하지 않는다. Release body에 §7 마지막 행의 노트를 적는다. +4. 퍼블리시 후 사용자 보고된 케이스(우클릭/단축키)가 재현 종료되는지 확인. +5. Windows/Linux 기본 메뉴 활성화 여부는 별도 이슈로 트래킹. + +### 9.1 Rollback + +`0.10.5` 퍼블리시 후 Windows/Linux 빌드 회귀 또는 macOS에서 예기치 못한 메뉴 동작이 보고되면: + +1. `npm deprecate appbun@0.10.5 "regression in generated app menu — use 0.10.4"`로 즉시 deprecate. + - **선결조건:** 로컬 npm 토큰이 `appbun` 패키지의 owner여야 한다. 사고 발생 *전에* `npm owner ls appbun`으로 권한 확인. publish.yml이 자동 토큰을 쓰는 경우, 메인테이너는 자신의 owner 자격으로 별도 `npm login`이 필요할 수 있음. +2. 다음 파일들을 한꺼번에 revert (계약 일치성 유지를 위해 부분 revert는 피함): + - `src/lib/templates/shell.ts` — 메뉴 코드 자체 (필수) + - `src/lib/templates/project.ts` — `esModuleInterop` 추가 (필수, default import가 사라지면 컴파일 에러) + - `src/__tests__/generator.test.ts` — 신규 어설션 (필수, 어설션이 남으면 테스트 실패) + - `.github/workflows/scaffold-smoke.yml` — 신규 grep 3종 (필수, 계약 일치) + - `CLAUDE.md` (top-level) — 신규 grep 계약 설명 (필수). 단 `scaffold.yml → scaffold-smoke.yml` 파일명 정정은 **유지**(별개 정합성 수정). + - `package.json` — `0.10.5` → `0.10.4` (또는 `0.10.6`로 forward fix 시 유지). 판단은 사후 사고 경위에 따라. + - **유지(revert 제외):** `skills/appbun-web-desktop/SKILL.md`, `skills/appbun-web-desktop/CLAUDE.md` quality bar 문구 — 문서 표현이며 코드 회귀와 무관. +3. 회귀를 재현하는 테스트를 추가한 뒤 `0.10.6` 또는 `0.10.7`로 재퍼블리시. + +## 10. Decisions (resolved 2026-05-28) + +근거 출처: Electrobun 공식 문서 `docs/src/content/docs/electrobun/apis/application-menu.mdx`, `browser-view.mdx`, `llms.txt` (context7 인덱스 `/blackboardsh/electrobun`). + +### D1. `reload` role은 존재하지 않는다 +공식 지원 role 목록(인용 시점 2026-05-28 기준, 본 문서가 사용하는 role 한정으로 충분 — 전체 목록은 공식 문서 참고. `showHelp` 등 본 PR에서 미사용 role도 존재): `quit`, `hide`, `hideOthers`, `showAll`, `undo`, `redo`, `cut`, `copy`, `paste`, `pasteAndMatchStyle`, `delete`, `selectAll`, `startSpeaking`, `stopSpeaking`, `enterFullScreen`, `exitFullScreen`, `toggleFullScreen`, `minimize`, `zoom`, `bringAllToFront`, `close`, `cycleThroughWindows`. **`reload`는 없음.** + +**결정:** Reload는 `{ label: "Reload", action: "reload-app", accelerator: "r" }`로 커스텀 등록. + +### D2. Reload 핸들러: shell 안의 `` 태그 `.reload()`를 invoke +**중요한 layering 사실:** `mainWindow.webview`는 Bun이 띄운 shell BrowserView(`views://mainview/index.html`)이며, **이 shell이 그 안에 `` 자식 DOM 요소를 만들어 원격 페이지를 표시**한다(`shell.ts:357-371` `remoteApp = document.createElement("electrobun-webview")`). + +따라서: +- 잘못된 접근: `mainWindow.webview.executeJavascript("location.reload()")` — shell의 `location`은 `views://mainview/index.html`이라 **shell 자체가 리로드**되어 원격 페이지가 깜박이며 다시 마운트된다(현 toolbar 버튼의 시맨틱과 비슷한 부작용). +- 올바른 접근: `mainWindow.webview.executeJavascript("document.getElementById('remote-app')?.reload()")` — 공식 문서가 `` 태그(렌더러 측 DOM 요소)의 `reload` 메서드를 명시한다. 이 메서드를 호출하면 **자식 webview만 그 자리에서 reload**되어 표준 브라우저 Cmd+R 시맨틱이 된다. + +Bun-side `BrowserView`의 공식 메서드 리스트(`loadURL`, `loadHTML`, `executeJavascript`, `setPageZoom`, `getPageZoom`, `setNavigationRules`, `findInPage`, `stopFindInPage`, `openDevTools`, `closeDevTools`, `toggleDevTools`)에는 `reload()`가 없으므로 Bun 측에서 직접 호출할 수 없고 — `executeJavascript`로 자식 webview의 메서드를 우회 호출하는 것이 정공법이다. + +**executeJavascript 반환값 처리:** Electrobun의 `executeJavascript` 반환 형태(void vs Promise)는 공식 문서에 명시가 없다. 구현 단계에서 확인 후 Promise라면 `.catch(() => {})`를 붙여 unhandled rejection을 방지(원격 페이지가 사라진 상태에서도 안전하게 처리). + +**기존 toolbar `#reload-app` 버튼과의 관계:** 기존 버튼은 `src`를 `about:blank` → 현재 src 어트리뷰트(`APP_CONFIG.url` 폴백)로 복원한다(`shell.ts:377-382`). 신규 Cmd+R은 자식 webview의 `reload`로 *현재 페이지를 그 자리에서* 리로드. 두 경로는 의미가 다르며 1차 PR에서는 의도적으로 공존한다. 후속에서 두 경로를 통합하는 follow-up은 별건. + +### D3. `role: "about"`은 존재하지 않는다 +D1의 role 목록에 `about` 없음. + +**결정:** 1차 PR에서 About 항목을 메뉴에서 **제외**한다. macOS는 첫 메뉴를 앱 이름으로 표시하므로 별도 About 항목이 없어도 사용자 보고된 단축키 문제는 영향 없음. 후속에서 커스텀 action으로 about 다이얼로그를 띄우는 것 고려 가능 (별도 이슈). + +### D4. 빈 label은 문서화된 패턴이다 +공식 예제(`llms.txt`)가 첫 메뉴를 `{ submenu: [...] }`(label 생략)로 작성하며 주석으로 "First item on macOS becomes the app menu (app name, Quit, etc.)" 명시. + +**결정:** 첫 메뉴는 label 생략. 추가 검증 불필요. + +### D5. 플랫폼 지원 — Linux 미지원, Windows 부분 지원 +공식 문서 명시: "macOS has full support. Windows supports simple single-character accelerators. Application menus are not currently supported on Linux." Linux에서 호출 자체가 안전하지 않을 수 있다고 해석한다. + +**결정:** `if (process.platform === "darwin")` 가드를 1차 PR의 정식 동작으로 채택. Windows 확장은 별도 후속 PR(`process.platform === "darwin" || process.platform === "win32"`로 가드 확장 + Windows에서 미동작하는 role 제거)에서 다룬다. + +### D6. 이벤트 핸들러 누적 위험 — 부분 완화만 가능, 알려진 한계로 인정 +`Electrobun.events.on("application-menu-clicked", handler)`는 모듈 로드 시 등록된다. `bun dev --watch`가 모듈을 통째로 재실행하면 모듈 스코프 변수도 함께 초기화되므로, 단순 boolean 가드는 **재실행 사이 누적을 막지 못한다** — 네이티브 이벤트 emitter가 프로세스 수명 동안 핸들러를 유지하면 watch 모드에서 매 리로드마다 핸들러가 1개씩 더 붙는다. + +Electrobun의 events API에는 `on`만 공식 문서화돼 있다. `off`/`removeListener`/unsubscribe 반환값 모두 **공식 문서에서 발견되지 않는다** — 따라서 1차 PR에서는 완전 dedup이 불가능하다. + +**결정:** 다음과 같이 처리한다. +1. 모듈 스코프 boolean 가드(`let menuHandlerRegistered = false; if (!menuHandlerRegistered) { Electrobun.events.on(...); menuHandlerRegistered = true; }`)는 **동일 모듈 인스턴스 수명 내** 중복 등록만 막는 부분 완화로 채택. 코드 주석으로 한계를 명시. +2. `bun dev --watch` 핫리로드 시 누적은 **알려진 한계로 인정**한다. 프로덕션(빌드된 DMG) 사용자에게는 영향 없음 — watch 모드는 개발 워크플로에 한정. +3. 구현 단계에서 `Electrobun.events.on`의 반환값을 확인하여 unsubscribe 함수가 있으면 다음 PR에서 그 패턴으로 교체. + +**검증 조정:** §8.2의 hot-reload 항목은 PASS 조건이 아니라 **현재 한계 확인**으로 재정의. 5회 리로드 후 Cmd+R 1회에 reload가 N회 트리거되면 한계가 재현된 것이며 별도 follow-up 이슈로 트래킹. + +### 부수 정정 +초안 §6.1의 Window 메뉴에 적었던 `front`는 잘못된 이름 — 올바른 role은 `bringAllToFront`. 본문 §6.1/§6.3에 반영 완료. 또한 초안의 View 메뉴에서 `minimize`/`zoom`은 Window 메뉴와 중복되어 macOS HIG 위반 → View 메뉴에서 제거. + +## 11. References + +- 우클릭 메뉴 스크린샷 (Cut/Copy 비활성화 — 사용자 보고 2026-05-28) +- Electrobun 공식 문서: ApplicationMenu, Creating UI 가이드 (context7 `/blackboardsh/electrobun` 인덱스 기준) +- `docs/pake-grade-goal.md` — 제품 바 +- `CLAUDE.md` — "CLI 플래그를 늘리기보다 생성물 품질을 올린다" 원칙 +- 영향받는 코드: `src/lib/templates/shell.ts`의 `generatedBunEntry()` 함수 (현재 라인 4-39; §7 적용 후에는 라인 범위가 약 30줄 늘어남 — 함수명을 기준으로 참조). diff --git a/package.json b/package.json index e0943a4..1e99311 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appbun", - "version": "0.10.4", + "version": "0.10.5", "description": "Turn any webpage into a desktop app with one command using Electrobun.", "license": "MIT", "type": "module", diff --git a/skills/appbun-web-desktop/CLAUDE.md b/skills/appbun-web-desktop/CLAUDE.md index 31371b8..c33107d 100644 --- a/skills/appbun-web-desktop/CLAUDE.md +++ b/skills/appbun-web-desktop/CLAUDE.md @@ -41,6 +41,10 @@ npx -y appbun@latest doctor --project npx -y appbun@latest package --install ``` +After installing the built macOS DMG, also confirm desktop fundamentals: + +- In any text input on the launched app, `Cmd+C/V/X/A/Z/Shift+Z` work, the App / Edit / View / Window menus appear in the menu bar, and right-click Cut/Copy are enabled when text is selected. If they are not, the generated `src/bun/index.ts` is likely missing the `ApplicationMenu.setApplicationMenu(...)` call. + On macOS, build a local DMG: ```bash diff --git a/skills/appbun-web-desktop/SKILL.md b/skills/appbun-web-desktop/SKILL.md index 24b31c4..67bd81b 100644 --- a/skills/appbun-web-desktop/SKILL.md +++ b/skills/appbun-web-desktop/SKILL.md @@ -75,6 +75,7 @@ Use `--copy` only when clipboard access is acceptable. Before considering the desktop wrapper done: - The generated app name, package name, icon, window size, and theme color match the product. +- On macOS, standard keyboard shortcuts (`Cmd+C/V/X/A/Z/Shift+Z/Q/R/M`) and the right-click Cut/Copy items work immediately in the built app. - The wrapper source is committed or clearly isolated from the main web app in a dedicated output directory. - `bun run build` succeeds inside the generated project, or the remaining blocker is stated with logs. - `appbun doctor --project` is run in the generated project and warnings are explained. diff --git a/src/__tests__/generator.test.ts b/src/__tests__/generator.test.ts index ca9f5dd..fd2a196 100644 --- a/src/__tests__/generator.test.ts +++ b/src/__tests__/generator.test.ts @@ -270,6 +270,76 @@ describe("generator", () => { const manifest = files.find((file) => file.path === "appbun.generated.json")?.content ?? ""; expect(manifest).toContain('"generator"'); expect(manifest).toContain(`"version": "${getAppbunVersion()}"`); + expect(files.find((file) => file.path === "tsconfig.json")?.content).toContain('"esModuleInterop": true'); + }); + + test("generated bun entry installs the macOS application menu", () => { + const config = resolveAppConfig( + "https://example.com", + { + width: 1400, + height: 900, + packageManager: "bun", + install: false, + dmg: false, + yes: false, + showConfig: false, + quiet: true, + }, + { + title: "Example", + description: "Example app", + themeColor: "#336699", + sourceUrl: "https://example.com", + iconCandidates: [], + }, + ); + + const files = renderTemplateFiles(config, {}); + const bunEntry = files.find((file) => file.path === "src/bun/index.ts")?.content ?? ""; + + expect(bunEntry).toContain('import Electrobun, { BrowserWindow, ApplicationMenu } from "electrobun/bun"'); + + expect(bunEntry).toContain("if (isMac) {"); + expect((bunEntry.match(/const isMac = /g) ?? []).length).toBe(1); + + expect(bunEntry).toContain("ApplicationMenu.setApplicationMenu("); + + expect(bunEntry).toContain('role: "copy"'); + expect(bunEntry).toContain('role: "paste"'); + expect(bunEntry).toContain('role: "cut"'); + expect(bunEntry).toContain('role: "selectAll"'); + expect(bunEntry).toContain('role: "undo"'); + expect(bunEntry).toContain('role: "redo"'); + expect(bunEntry).toContain('role: "pasteAndMatchStyle"'); + expect(bunEntry).toContain('role: "delete"'); + + expect(bunEntry).toContain('role: "hide"'); + expect(bunEntry).toContain('role: "hideOthers"'); + expect(bunEntry).toContain('role: "showAll"'); + expect(bunEntry).toContain('role: "quit"'); + + expect(bunEntry).toContain('action: "reload-app"'); + expect(bunEntry).toContain('accelerator: "r"'); + expect(bunEntry).toContain('role: "toggleFullScreen"'); + + expect(bunEntry).toContain('role: "minimize"'); + expect(bunEntry).toContain('role: "zoom"'); + expect(bunEntry).toContain('role: "bringAllToFront"'); + + expect(bunEntry).toContain("document.getElementById('remote-app')?.reload()"); + expect(bunEntry).not.toContain("location.reload()"); + expect(bunEntry).toContain("mainWindow.webview.executeJavascript("); + + expect(bunEntry).toContain("let menuHandlerRegistered = false"); + expect(bunEntry.indexOf("menuHandlerRegistered")).toBeLessThan( + bunEntry.indexOf("if (isMac) {"), + ); + expect(bunEntry).toContain("menuHandlerRegistered = true"); + expect(bunEntry.indexOf("menuHandlerRegistered = true")).toBeGreaterThan( + bunEntry.indexOf("if (!menuHandlerRegistered)"), + ); + expect(bunEntry).toContain('Electrobun.events.on("application-menu-clicked"'); }); test("system titlebar preset falls back to native chrome", () => { diff --git a/src/lib/templates/project.ts b/src/lib/templates/project.ts index 86e5cd9..0655ebc 100644 --- a/src/lib/templates/project.ts +++ b/src/lib/templates/project.ts @@ -150,6 +150,7 @@ function generatedTsconfig(): string { ' "module": "ESNext",', ' "moduleResolution": "Bundler",', ' "strict": true,', + ' "esModuleInterop": true,', ' "types": ["bun"]', " }", "}", diff --git a/src/lib/templates/shell.ts b/src/lib/templates/shell.ts index 8692974..31cfa00 100644 --- a/src/lib/templates/shell.ts +++ b/src/lib/templates/shell.ts @@ -11,9 +11,10 @@ export function generatedBunEntry(config: ResolvedAppConfig): string { FullSizeContentView: true, }` : "{}"; - return `import { BrowserWindow } from "electrobun/bun"; + return `import Electrobun, { BrowserWindow, ApplicationMenu } from "electrobun/bun"; const isMac = process.platform === "darwin"; +let menuHandlerRegistered = false; const mainWindow = new BrowserWindow({ title: ${JSON.stringify(config.title)}, @@ -33,6 +34,45 @@ mainWindow.webview.on("dom-ready", () => { console.log(${JSON.stringify(`${config.name} shell loaded`)}) }); +if (isMac) { + ApplicationMenu.setApplicationMenu([ + { submenu: [ + { role: "hide" }, { role: "hideOthers" }, { role: "showAll" }, + { type: "separator" }, { role: "quit" }, + ]}, + { label: "Edit", submenu: [ + { role: "undo" }, { role: "redo" }, { type: "separator" }, + { role: "cut" }, { role: "copy" }, { role: "paste" }, + { role: "pasteAndMatchStyle" }, { role: "delete" }, { role: "selectAll" }, + ]}, + { label: "View", submenu: [ + { label: "Reload", action: "reload-app", accelerator: "r" }, + { role: "toggleFullScreen" }, + ]}, + { label: "Window", submenu: [ + { role: "minimize" }, { role: "zoom" }, + { type: "separator" }, + { role: "bringAllToFront" }, + ]}, + ]); + + const handleMenuClick = (e: { data: { action?: string } }) => { + if (e.data.action === "reload-app") { + const result: unknown = mainWindow.webview.executeJavascript( + "document.getElementById('remote-app')?.reload()" + ); + if (result && typeof (result as { catch?: unknown }).catch === "function") { + (result as Promise).catch(() => {}); + } + } + }; + + if (!menuHandlerRegistered) { + Electrobun.events.on("application-menu-clicked", handleMenuClick); + menuHandlerRegistered = true; + } +} + console.log(${JSON.stringify(startMessage)}); console.log(${JSON.stringify(descriptionMessage)}); `;