diff --git a/docker/build.sh b/docker/build.sh index a9be723..9a8d703 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -79,9 +79,11 @@ case "$APP_NAME" in ;; esac -# Use branch name as Docker Compose project name for isolated containers/volumes +# Use branch name as Docker Compose project name for isolated containers/volumes. +# Honor a pre-set COMPOSE_PROJECT_NAME so a build can target an existing volume +# (e.g. reuse another branch's already-provisioned deps). BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD | tr '/' '-' | tr '[:upper:]' '[:lower:]') -export COMPOSE_PROJECT_NAME="kicad-wasm-${BRANCH_NAME}" +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-kicad-wasm-${BRANCH_NAME}}" echo "Using Docker project: ${COMPOSE_PROJECT_NAME}" echo "Building app: ${APP_NAME}" diff --git a/features/web-init/0001-web-app-spec.md b/features/web-init/0001-web-app-spec.md new file mode 100644 index 0000000..f21497e --- /dev/null +++ b/features/web-init/0001-web-app-spec.md @@ -0,0 +1,323 @@ +# 0001 — KiCad-WASM Web App Spec + +Status: **Refined spec, ready for `implement plan`** +Branch: `feature/web-init` +Scope of this iteration: **create a project, open a project, upload files, open a file in a WASM tool via URL.** + +This document is the agreed design after a clarification pass. Decisions that were +explicitly chosen by the user are marked **[decided]**. Items intentionally pushed to a +later iteration are marked **[later]**. Sensible defaults that were *not* explicitly +discussed are marked **[default]** and are safe to change during planning. + +--- + +## 1. Goal + +A single web application that lets a user create/open KiCad projects, upload files into +them, and open a file in the matching WASM tool (pcbnew / eeschema / calculator) by +visiting a URL such as: + +``` +/p/project5/pcbnew/nyak.kicad_pcb +``` + +The WASM apps already exist as `.js` + `.wasm` pairs (see `output/` and +`tests/apps/kicad/`). They boot into an Emscripten harness and read files from MEMFS. +This web app wraps that: it manages projects + files server-side, and on a tool URL it +boots the right WASM app and feeds it the project's files via `FS.writeFile`, then drives +File→Open on the target file. + +Non-goals this iteration: editing/saving back, collaboration, auth/login. + +--- + +## 2. Key decisions (summary table) + +| Area | Decision | +|---|---| +| Frontend | React + TypeScript + Vite + shadcn/ui **[decided]** | +| Backend | Fastify + ts-rest + Zod **[decided]** | +| Shared types | `packages/contract` (ts-rest contract + Zod) imported by FE client & BE router **[decided]** | +| Realtime | None now; pick stack that allows Hocuspocus/WSS later, no WS endpoints yet **[decided]** | +| URL semantics | `/p/:project/:tool/*filepath` — `tool` selects the WASM app, `*filepath` is auto-opened **[decided]** | +| Auth/tenancy | No auth now, but data model namespaced by an owner id for later multi-user **[decided]** | +| Metadata store | Postgres now, accessed via Drizzle (drizzle-zod shares schemas) **[decided]** | +| File blob storage | Pluggable `FileStorage` interface; local-disk impl now, S3 later **[decided]** | +| Open behavior | Sync **whole project tree** into MEMFS, then auto-open target **[decided]**; lazy/partial load **[later]** | +| Upload | Individual files (multi), folder (preserve structure), and `.zip` of a project **[decided]** | +| WASM artifact delivery | Served from a **configurable base location** (URL/dir): local Fastify static from `output/` in dev, public S3 URL in prod **[decided]** | +| Save-back / sync | Read-only open now; write interface defined but unused. Save-back + lazy load land together **[later]** | +| Monorepo | pnpm + turbo workspace under `web/` **[decided]** | + +--- + +## 3. Repository / monorepo layout **[decided: under `web/`]** + +``` +web/ +├── package.json # pnpm workspace root +├── pnpm-workspace.yaml +├── turbo.json +├── .env.example +├── docker-compose.yml # local Postgres (and later: minio for S3 parity) +├── apps/ +│ ├── frontend/ # Vite + React + TS + shadcn +│ └── server/ # Fastify + ts-rest + Drizzle +└── packages/ + ├── contract/ # ts-rest contract + Zod schemas (shared) + ├── storage/ # FileStorage interface + local-disk impl (+ S3 later) + └── config/ # shared tsconfig / eslint / env parsing [default] +``` + +Rationale: isolates the JS/TS app from the C++/WASM build repo at root (`kicad/`, +`wxwidgets/`, `scripts/`, `docker/`). The web app consumes WASM artifacts produced by the +existing build, it does not build them. + +Root `.gitignore` should ignore `web/**/node_modules`, `web/**/dist`, build caches. + +--- + +## 4. Domain model + +### 4.1 Entities (Postgres, via Drizzle) **[decided: Postgres + Drizzle]** + +``` +owner -- namespace for "no auth now, multi-user later" + id uuid pk + slug text unique -- e.g. "default" now; becomes real users later + created_at timestamptz + +project + id uuid pk + owner_id uuid fk -> owner.id + slug text -- URL segment, unique within owner (e.g. "project5") + name text -- human display name + created_at timestamptz + updated_at timestamptz + unique(owner_id, slug) + +project_file -- index of files; bytes live in FileStorage + id uuid pk + project_id uuid fk -> project.id + path text -- POSIX-relative within project, e.g. "pcbnew/nyak.kicad_pcb" + size bigint + content_type text + storage_key text -- opaque key handed to FileStorage + created_at timestamptz + updated_at timestamptz + unique(project_id, path) +``` + +- **Owner namespace [decided]**: every project belongs to an `owner`. This iteration uses a + single seeded owner (`slug = "default"`); the URL omits owner (`/p/:project/...`) and the + server resolves it to the default owner. Adding real auth later = populate `owner` per + user and prefix routes, **no schema migration needed**. +- `project_file.path` is the canonical project-relative path. The `storage_key` decouples + the logical path from however the blob backend names things (so renames/S3 layout are free). + +drizzle-zod derives Zod schemas from these tables; those Zod schemas feed the ts-rest +contract so DB ↔ API ↔ client share one source of truth. + +### 4.2 What "project" means at the byte level + +A project is a directory tree of files (`.kicad_pro`, `.kicad_pcb`, `.kicad_sch`, +`fp-lib-table`, `sym-lib-table`, footprint/symbol lib dirs, etc.). `project_file` rows +enumerate the tree; bytes live behind `FileStorage`. + +--- + +## 5. Storage abstraction **[decided: pluggable, local now, S3 later]** + +`packages/storage` exposes a single interface. The whole iteration is **read-heavy**; write +methods exist so save-back **[later]** needs no redesign. + +```ts +export interface FileStorage { + // read path + exists(key: string): Promise; + read(key: string): Promise; + createReadStream(key: string): NodeJS.ReadableStream; // for large files + stat(key: string): Promise<{ size: number; contentType?: string }>; + list(prefix: string): Promise; // keys under a prefix + + // write path (used now only by upload; save-back is [later]) + write(key: string, data: Uint8Array | NodeJS.ReadableStream, opts?: { contentType?: string }): Promise; + delete(key: string): Promise; +} +``` + +Implementations: +- `LocalDiskStorage` **[now]** — rooted at a configurable dir (`STORAGE_ROOT`), `key` maps + to a path under it. Streams to/from disk. +- `S3Storage` **[later]** — same interface over an S3-compatible bucket. `docker-compose` + can run MinIO for local S3 parity when we get there. + +Storage key scheme **[default]**: `owners//projects//`. +Opaque to callers — only `FileStorage` interprets it. + +--- + +## 6. WASM artifact delivery **[decided: configurable base location]** + +The big artifacts (`pcbnew.wasm` ~350 MB, `eeschema.wasm` ~180 MB, `calculator.wasm`, +their `.js` glue, `wx.js`, `images.tar.gz`) are **app binaries, not user data** — kept +separate from `FileStorage`. + +- The frontend resolves every artifact URL from a single configurable base: + `WASM_ASSET_BASE_URL` **[decided requirement]**. + - **dev**: points at the Fastify server, which serves the artifacts statically from a + configurable dir (default `../../output` relative to the server, i.e. repo `output/`). + - **prod**: points at a public S3/CDN URL. No code change — just env. +- An artifact URL is composed as `${WASM_ASSET_BASE_URL}/${tool}.js` (and the glue then + fetches the sibling `.wasm` / `worker.js` / `images.tar.gz` from the same base). The + Emscripten `locateFile` hook must be wired to this base so `.wasm`/`.worker.js` resolve + correctly regardless of origin. +- **Cross-origin caveat [important]**: KiCad WASM uses threads (`.worker.js` present), + which needs `SharedArrayBuffer` → the **document** must be served with + `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. + When artifacts come from a different origin (S3/CDN), they must be served with + `Cross-Origin-Resource-Policy: cross-origin` (or CORS) so they load under COEP. The + Fastify static route sets COOP/COEP/CORP in dev; the prod bucket/CDN must set CORP/CORS. + Verify against the existing harness behavior in `tests/apps/kicad/`. + +Artifacts are **not committed to git** (they're build outputs). The build pipeline +(`docker/build.sh` → `output/`) remains the source. + +--- + +## 7. URL & routing **[decided]** + +Frontend routes (client-side router) **[default: react-router]**: + +| Route | View | +|---|---| +| `/` | Project list + "Create project" | +| `/p/:project` | Project detail: file tree, upload, "open in tool" actions | +| `/p/:project/:tool/*filepath` | **Tool view**: boots `:tool` WASM app, auto-opens `*filepath` | + +- `:tool` ∈ `{ pcbnew, eeschema, calculator }` — selects the WASM app **[decided]**. + (`calculator` takes no file; opening it ignores `*filepath`.) +- `*filepath` is the project-relative path of the file to auto-open + (e.g. `pcbnew/nyak.kicad_pcb`). It must match a `project_file.path` row. +- `:project` is the project **slug** within the default owner. +- Owner is implicit (default owner) now; route gains an owner segment when auth lands + **[later]** — `/p/:project/...` is forward-compatible. + +Tool→file-extension mapping (for validation / "open with" UI) **[default]**: +`.kicad_pcb → pcbnew`, `.kicad_sch → eeschema`. Mismatches surface a warning but the +explicit `:tool` segment wins (per the decided semantics). + +--- + +## 8. API (ts-rest contract in `packages/contract`) **[decided: ts-rest + Zod]** + +All endpoints under `/api`. Contract is the single typed source; Fastify router +implements it, frontend uses the generated ts-rest react-query client **[default]**. + +``` +GET /api/projects -> Project[] +POST /api/projects { name, slug? } -> Project # create [scope] +GET /api/projects/:project -> Project + file tree # open [scope] +DELETE /api/projects/:project -> 204 # [default, nice-to-have] + +GET /api/projects/:project/files -> ProjectFile[] +GET /api/projects/:project/files/*path -> file bytes (streamed) # used to fill MEMFS +POST /api/projects/:project/files (multipart) -> ProjectFile[] # upload [scope] +POST /api/projects/:project/files/zip (multipart zip) -> ProjectFile[] # upload-zip [scope] +# write/rename/delete of individual files: interface ready, [later] for save-back +``` + +Upload handling **[decided: files + folder + zip]**: +- **Individual files (multi)**: multipart; each part carries its target project-relative + path. Streamed to `FileStorage`, one `project_file` row each. +- **Folder (preserve structure)**: frontend uses `webkitdirectory`; relative paths derived + from `file.webkitRelativePath` and sent as the per-file path. Server preserves the tree. +- **Zip**: server unpacks (streaming unzip) into the project tree, creating `project_file` + rows per entry. Reject path traversal (`../`) and absolute paths. + +Validation: Zod schemas (shared) validate bodies/params; Fastify JSON-schema serialization +for responses. Errors via a consistent ts-rest error shape **[default]**. + +--- + +## 9. Open-a-file flow (the core of the iteration) + +Visiting `/p/:project/:tool/*filepath`: + +1. Frontend fetches the project's file tree: `GET /api/projects/:project/files`. +2. Frontend loads the WASM glue for `:tool` from `WASM_ASSET_BASE_URL` and instantiates the + Emscripten module into a canvas-bearing harness (reuse the proven shell from + `tests/apps/kicad/pcbnew.html` — `createCanvas`, `images.tar.gz` prefetch+write, + `locateFile`, status/progress UI), ported into a React component. +3. **Sync whole project tree into MEMFS** **[decided]**: for every `project_file`, fetch its + bytes (`GET .../files/*path`) and `FS.mkdirTree` + `FS.writeFile` at the project root + inside MEMFS (mirroring `tests/kicad/utils/fs-inject.ts`). Files land at the path KiCad + expects (e.g. under the default projects dir, confirmed by `load-pcb-probe.spec.ts`: + `/home/kicad/documents/kicad/9.99/projects/...`). The exact MEMFS mount point for an + arbitrary user project is an **open implementation detail** — see §11. +4. Drive File→Open on `*filepath` using the existing element-tracker / menu-driving + helpers (`tests/e2e/utils/element-tracker.ts`, `tests/kicad/load-pcb.spec.ts`). This UI + automation already works for the demo boards and is the reference implementation. +5. Render. Read-only — no write-back **[decided]**. + +> **[later]** Lazy/partial loading: instead of syncing the whole tree up front, intercept +> MEMFS reads and fetch siblings on demand. This lands **together with save-back/sync**, as +> a single coherent iteration (both need MEMFS↔storage plumbing). Not now. + +--- + +## 10. Frontend detail **[shadcn decided; rest default]** + +- Vite + React + TS, shadcn/ui components, Tailwind. +- Pages: project list (cards + create dialog), project detail (file tree + upload + dropzone + per-file "open in pcbnew/eeschema" buttons), tool view (full-viewport WASM + canvas + status overlay). +- Data layer: ts-rest react-query client generated from the contract. +- Upload UX: drag-drop dropzone supporting multi-file, folder (`webkitdirectory`), and + `.zip`; progress per file; streamed to backend. +- The WASM tool view is a dedicated component that owns the Emscripten lifecycle and tears + it down on unmount (WebGL context, MEMFS) to allow switching tools/projects. + +--- + +## 11. Open questions for `implement plan` (not blocking this spec) + +1. **MEMFS mount point for arbitrary projects.** Demos rely on KiCad's default projects + path. For a user project we must decide where in MEMFS the tree is written and whether + pcbnew/eeschema need it under their expected projects dir, or whether File→Open can + target an arbitrary MEMFS path. Resolve by probing (extend `load-pcb-probe`). +2. **Driving File→Open generically.** Current helpers are tuned to the demo dialog flow + (filelist bbox click + filename input + Enter). Confirm it generalizes to arbitrary + paths, or expose a cleaner embind "open file" entry point in the WASM layer. +3. **eeschema/calculator open flows.** Mirror the pcbnew flow; verify eeschema's File→Open + and that calculator (no file) just boots. +4. **Large-tree sync performance.** Whole-tree sync of a big project over many HTTP + requests may be slow; consider a single tar/zip stream endpoint to fill MEMFS in one + shot (still "sync whole tree", just one request). Decide in planning. +5. **COOP/COEP in prod** with cross-origin S3 artifacts — validate header matrix. +6. **Project slug generation/collision** rules; reserved tool names as slugs. + +--- + +## 12. Out of scope this iteration (explicit) + +- Auth / login / real multi-user (data model is ready; UI/enforcement is **[later]**). +- WebSocket / realtime / Hocuspocus collaboration (stack chosen to allow it; none built). +- Saving or syncing edits back to storage (**[later]**, paired with lazy load). +- S3 storage implementation (interface ready; **[later]**). +- Editing project files in the browser outside the WASM tools. + +--- + +## 13. Definition of done (this iteration) + +- `pnpm install && docker-compose up -d && pnpm dev` in `web/` brings up Postgres, + Fastify (`/api` + WASM static), and the Vite frontend. +- Create a project from the UI; it appears in the list and in Postgres. +- Open the project; upload files via multi-select, folder, and zip — files appear in the + tree and in `FileStorage` + `project_file`. +- Navigate to `/p//pcbnew/.kicad_pcb`; the board renders read-only, equivalent + to the existing `tests/kicad/load-pcb` result, sourcing files from project storage. +- Same for an eeschema `.kicad_sch` file. +- Swapping `WASM_ASSET_BASE_URL` between local `output/` and a remote URL requires no code + change. \ No newline at end of file diff --git a/tests/apps/kicad/eeschema.html b/tests/apps/kicad/eeschema.html index 399878d..28ec800 100644 --- a/tests/apps/kicad/eeschema.html +++ b/tests/apps/kicad/eeschema.html @@ -137,10 +137,41 @@ } }; + // KiCad's standalone entry (single_top.cpp) runs STARTWIZARD on launch: a + // modal first-run "Setup" wizard shown whenever the settings dir lacks a + // kicad_common.json or valid global library tables. In this ephemeral MEMFS + // that is EVERY load, and the wizard's modal event loop crashes Asyncify + // (func is not a function). Seed a minimal default config before main() so + // all three providers report NeedsUserInput()==false — equivalent to the + // wizard's "use defaults" path — and it never opens. Settings dir matches + // PATHS::GetUserSettingsPath() for this build. + var seedKicadConfig = function() { + var cfgDir = '/home/kicad/.config/kicad/kicad/9.99'; + FS.mkdirTree(cfgDir); + + var writeIfAbsent = function(path, contents) { + try { FS.stat(path); return; } catch (e) { /* absent — seed it */ } + FS.writeFile(path, contents); + console.log('[KICAD] Seeded ' + path); + }; + + // SETTINGS provider: settings dir is "valid" once kicad_common.json exists. + // PRIVACY provider: both prompts must be flagged do-not-show-again. + writeIfAbsent(cfgDir + '/kicad_common.json', JSON.stringify({ + do_not_show_again: { update_check_prompt: true, data_collection_prompt: true } + }, null, 2)); + + // LIBRARIES provider: needs valid global symbol/footprint/design-block + // tables. Empty (zero-row) tables parse fine and satisfy GlobalTablesValid(). + writeIfAbsent(cfgDir + '/sym-lib-table', '(sym_lib_table\n (version 7)\n)\n'); + writeIfAbsent(cfgDir + '/fp-lib-table', '(fp_lib_table\n (version 7)\n)\n'); + writeIfAbsent(cfgDir + '/design-block-lib-table', '(design_block_lib_table\n (version 7)\n)\n'); + }; + var Module = { thisProgram: '/usr/bin/eeschema', // Fake absolute path for argv[0] (KiCad DEBUG check) - preRun: [createCanvas, writeResources], + preRun: [createCanvas, writeResources, seedKicadConfig], postRun: [], print: function(text) { diff --git a/tests/scripts/setup-kicad-wasm.sh b/tests/scripts/setup-kicad-wasm.sh index 8ed9a9b..75d7241 100755 --- a/tests/scripts/setup-kicad-wasm.sh +++ b/tests/scripts/setup-kicad-wasm.sh @@ -15,6 +15,21 @@ OUTPUT_DIR="$PROJECT_ROOT/output" mkdir -p "$KICAD_TEST" +# Smart copy: skip when the destination already exists and is byte-identical to +# the source (cmp -s is portable across macOS/Linux). Makes this a real sync — +# re-running does not rewrite unchanged multi-hundred-MB .wasm files. +smart_cp() { + local src="$1" destdir="$2" + [ -f "$src" ] || return 0 + local dst="$destdir/$(basename "$src")" + if [ -f "$dst" ] && cmp -s "$src" "$dst"; then + echo " = $(basename "$src") (up-to-date)" + return 0 + fi + cp "$src" "$dst" + echo " + $(basename "$src")" +} + # Map an app name to its inner CMake build subdirectory. Most apps share their # subdir name with the app name; pcb_calculator emits OUTPUT_NAME=calculator # but lives under the pcb_calculator/ subtree of the build dir, and pl_editor's @@ -36,12 +51,12 @@ copy_app() { subdir=$(kicad_subdir_for "$app") if [ -f "$OUTPUT_DIR/${app}.js" ] && [ -f "$OUTPUT_DIR/${app}.wasm" ]; then - echo "Copying ${app} WASM files from output directory..." - cp "$OUTPUT_DIR/${app}.js" "$KICAD_TEST/" - cp "$OUTPUT_DIR/${app}.wasm" "$KICAD_TEST/" - cp "$OUTPUT_DIR/${app}.wasm.map" "$KICAD_TEST/" 2>/dev/null || true - cp "$OUTPUT_DIR/${app}.worker.js" "$KICAD_TEST/" 2>/dev/null || true - cp "$OUTPUT_DIR/images.tar.gz" "$KICAD_TEST/" 2>/dev/null || true + echo "Syncing ${app} WASM files from output directory..." + smart_cp "$OUTPUT_DIR/${app}.js" "$KICAD_TEST" + smart_cp "$OUTPUT_DIR/${app}.wasm" "$KICAD_TEST" + smart_cp "$OUTPUT_DIR/${app}.wasm.map" "$KICAD_TEST" + smart_cp "$OUTPUT_DIR/${app}.worker.js" "$KICAD_TEST" + smart_cp "$OUTPUT_DIR/images.tar.gz" "$KICAD_TEST" return 0 fi @@ -76,17 +91,17 @@ if [ "$found_any" -eq 0 ]; then fi # wxWidgets WASM JavaScript glue code (defines JS functions called from WASM) -echo "Copying wxWidgets WASM glue code..." +echo "Syncing wxWidgets WASM glue code..." if [ -f "$OUTPUT_DIR/wx.js" ]; then - cp "$OUTPUT_DIR/wx.js" "$KICAD_TEST/" + smart_cp "$OUTPUT_DIR/wx.js" "$KICAD_TEST" else if docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \ kicad-wasm-builder:/workspace/build-wasm/wxwidgets/build/wasm/wx.js "$KICAD_TEST/" 2>/dev/null; then : else - cp "$PROJECT_ROOT/wxwidgets/build/wasm/wx.js" "$KICAD_TEST/" + smart_cp "$PROJECT_ROOT/wxwidgets/build/wasm/wx.js" "$KICAD_TEST" fi fi -echo "KiCad WASM files copied to $KICAD_TEST" +echo "KiCad WASM files synced to $KICAD_TEST" ls -lh "$KICAD_TEST" diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp new file mode 100644 index 0000000..116caa4 --- /dev/null +++ b/wasm/bindings/eeschema_embind.cpp @@ -0,0 +1,43 @@ +/* + * Embind bindings for KiCad eeschema WASM. + * + * Picked up automatically by scripts/kicad/build-kicad-target.sh when building + * the eeschema app (it compiles wasm/bindings/_embind.cpp if present). + */ + +#ifdef __EMSCRIPTEN__ +#include +#include +#include +#include +#include +#include +#include + +using namespace emscripten; + +// Programmatically open a project file (schematic) in the running editor frame, +// without UI automation. Mirrors single_top.cpp's MacOpenFile path: the editor +// frame is the app's top window and is a KIWAY_PLAYER. Returns the result of +// OpenProjectFiles, or false if no frame is available — letting the JS caller +// fall back to driving File→Open. +bool kicadOpenFile( std::string path ) +{ + KIWAY_PLAYER* frame = + wxTheApp ? static_cast( wxTheApp->GetTopWindow() ) : nullptr; + + if( !frame ) + return false; + + if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() ) + blocking->Close( true ); + + return frame->OpenProjectFiles( + std::vector( 1, wxString::FromUTF8( path.c_str() ) ) ); +} + +EMSCRIPTEN_BINDINGS(eeschema) { + // Programmatic file open (preferred over UI automation from the web app). + function("kicadOpenFile", &kicadOpenFile); +} +#endif diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 2778b94..d23535f 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -14,10 +14,35 @@ #include #include #include +#include +#include #include +#include +#include +#include using namespace emscripten; +// Programmatically open a project file (board/schematic) in the running editor +// frame, without UI automation. Mirrors single_top.cpp's MacOpenFile path: +// the editor frame is the app's top window and is a KIWAY_PLAYER. Returns the +// result of OpenProjectFiles, or false if no frame is available — letting the +// JS caller fall back to driving File→Open. +bool kicadOpenFile( std::string path ) +{ + KIWAY_PLAYER* frame = + wxTheApp ? static_cast( wxTheApp->GetTopWindow() ) : nullptr; + + if( !frame ) + return false; + + if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() ) + blocking->Close( true ); + + return frame->OpenProjectFiles( + std::vector( 1, wxString::FromUTF8( path.c_str() ) ) ); +} + // Wrapper to return footprints as vector for JS iteration std::vector Board_GetFootprints(BOARD* board) { if (!board) return {}; @@ -82,5 +107,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) { function("Footprint_GetValue", &Footprint_GetValue, allow_raw_pointers()); function("Pad_GetNumber", &Pad_GetNumber, allow_raw_pointers()); function("Pad_GetPinFunction", &Pad_GetPinFunction, allow_raw_pointers()); + + // Programmatic file open (preferred over UI automation from the web app). + function("kicadOpenFile", &kicadOpenFile); } #endif diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..9940e95 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,29 @@ +# --------------------------------------------------------------------------- +# Copy to web/.env and adjust as needed. docker-compose and the server both +# read this file. +# --------------------------------------------------------------------------- + +# --- Postgres (docker-compose) --- +POSTGRES_USER=kicad +POSTGRES_PASSWORD=kicad +POSTGRES_DB=kicad_web +# Non-default host port to avoid colliding with a local Postgres on 5432. +POSTGRES_PORT=54329 + +# --- Server --- +PORT=3050 +# Connection string MUST match the POSTGRES_* values + port above. +DATABASE_URL=postgres://kicad:kicad@localhost:54329/kicad_web +# Where uploaded project file bytes are stored by the local-disk FileStorage. +STORAGE_ROOT=./.data/storage +# Owner namespace used while there is no auth (seeded on first migrate). +DEFAULT_OWNER_SLUG=default +# Browser origin allowed to call the API (the Vite dev server, non-default port). +CORS_ORIGIN=http://localhost:3048 + +# --- Frontend (Vite, must be VITE_-prefixed) --- +VITE_API_BASE_URL=http://localhost:3050 +# SAME-ORIGIN: artifacts are smart-copied into public/wasm on `dev` and served by +# Vite at /wasm. Required because KiCad WASM pthread workers can't be cross-origin. +# For prod, set to a CDN URL whose origin also satisfies the worker/COEP rules. +VITE_WASM_ASSET_BASE_URL=/wasm diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..0b2e5dc --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.turbo/ +.data/ +*.log +.env +.env.local +# WASM artifacts: public/wasm is a symlink to the synced artifact dir +# (tests/apps/kicad), created on `dev` — never committed. +**/public/wasm +**/public/wasm/ diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..1a2f7e7 --- /dev/null +++ b/web/README.md @@ -0,0 +1,111 @@ +# KiCad Web + +Single web app to create/open KiCad projects, upload files, and open them in the +WASM tools (pcbnew / eeschema / calculator) by URL: + +``` +/p/// e.g. /p/project5/pcbnew/nyak.kicad_pcb +``` + +Design + decisions: [`../features/web-init/0001-web-app-spec.md`](../features/web-init/0001-web-app-spec.md). + +## Stack + +- **Monorepo**: pnpm + turbo +- **Frontend**: Vite + React + TypeScript + shadcn/ui (`apps/frontend`) +- **Backend**: Fastify + ts-rest + Zod (`apps/server`) +- **DB**: Postgres + Drizzle (project/file metadata) +- **Storage**: pluggable `FileStorage` (local disk now, S3 later) (`packages/storage`) +- **Shared types**: ts-rest contract + Zod (`packages/contract`) + +``` +web/ +├── apps/ +│ ├── frontend/ # Vite React app +│ └── server/ # Fastify API + WASM static + Drizzle +└── packages/ + ├── contract/ # ts-rest contract + Zod schemas (FE + BE share this) + └── storage/ # FileStorage interface + LocalDiskStorage +``` + +## Quick start + +```bash +cd web +cp .env.example .env # Postgres host port defaults to 54329 (non-default) +pnpm install + +pnpm db:up # start Postgres (docker compose) +pnpm db:migrate # apply migrations + seed the default owner + +pnpm dev # turbo: server :3050 + frontend :3048 +``` + +Open http://localhost:3048 — create a project, upload files (multi / folder / +.zip), then open a `.kicad_pcb` / `.kicad_sch` in its tool. + +## WASM artifacts + +The runtime artifacts (`.js/.wasm`, `wx.js`, `images.tar.gz`, plus the +`.html` harness pages) are build outputs, **not** committed here. The +complete set is synced into `tests/apps/kicad/` by +`tests/scripts/setup-kicad-wasm.sh` from repo-root `output/` (+ `wx.js` from +`wxwidgets/`; `output/` alone lacks `wx.js`). That script is a real **sync** — +it skips files already byte-identical at the destination, so re-running it does +not rewrite the multi-hundred-MB `.wasm`. + +**They must be served same-origin as the app.** Under the document's COEP/ +cross-origin-isolation (set by the Vite dev server), KiCad WASM refuses to load +its glue/wasm from a different origin. So the app serves them from its own +origin with **no extra copy**: `pnpm dev` runs `scripts/link-wasm.mjs`, which +**symlinks** `apps/frontend/public/wasm → tests/apps/kicad`. Vite then serves +them at `/wasm` (same origin). `VITE_WASM_ASSET_BASE_URL` defaults to `/wasm`. + +- Point the symlink elsewhere with + `WASM_SRC_DIR=/path pnpm --filter @kicad-web/frontend link-wasm`. +- If the tool won't load, the target dir is probably empty — run + `tests/scripts/setup-kicad-wasm.sh` to populate `tests/apps/kicad/`. + +The tool view (`WasmTool.tsx`) loads the **actual harness** (`/wasm/.html`, +the same page the e2e tests use) in a same-origin iframe, then injects the +project tree into its MEMFS and drives File→Open — reusing the proven loader +rather than re-implementing the Emscripten bootstrap. + +**prod**: point `VITE_WASM_ASSET_BASE_URL` at a CDN URL — but that origin must +itself satisfy the same-origin / COEP constraints (e.g. served under the app's +own origin/path). + +## Scripts + +| Command | What | +|---|---| +| `pnpm dev` | server + frontend (turbo) | +| `pnpm db:up` / `pnpm db:down` | start/stop Postgres | +| `pnpm db:generate` | generate Drizzle migration SQL from schema | +| `pnpm db:migrate` | apply migrations + seed default owner | +| `pnpm db:seed` | (re)seed the default owner | +| `pnpm typecheck` | typecheck all packages | +| `pnpm build` | build all packages | + +## API (shared via `packages/contract`) + +JSON (ts-rest): `GET/POST /api/projects`, `GET/DELETE /api/projects/:project`, +`GET /api/projects/:project/files`. + +Binary (raw Fastify, response shapes still shared via Zod): +`POST /api/projects/:project/files` (multi-file + folder), +`POST /api/projects/:project/files/zip`, +`GET /api/projects/:project/files/*` (stream bytes). + +## Status / next iteration + +Working end-to-end: create / open / upload (files, folder, zip) / file +download / WASM static serving / project list & detail UI / URL routing. + +Booting a tool syncs the **whole** project tree into MEMFS, then opens the +target file. The open step (`apps/frontend/src/wasm/open-flow.ts`) prefers a +programmatic hook (`Module.kicadOpenFile`) and falls back to EXPERIMENTAL UI +automation ported from the e2e tests — this needs in-browser validation against +built artifacts, and exposing a real embind open-entry-point is the intended +follow-up (spec §11.2). Lazy/partial MEMFS loading and save-back land together +in a later iteration (spec §§9, 12). diff --git a/web/apps/frontend/index.html b/web/apps/frontend/index.html new file mode 100644 index 0000000..a40367d --- /dev/null +++ b/web/apps/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + KiCad Web + + +
+ + + diff --git a/web/apps/frontend/package.json b/web/apps/frontend/package.json new file mode 100644 index 0000000..6111775 --- /dev/null +++ b/web/apps/frontend/package.json @@ -0,0 +1,40 @@ +{ + "name": "@kicad-web/frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node scripts/link-wasm.mjs && vite", + "link-wasm": "node scripts/link-wasm.mjs", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@kicad-web/contract": "workspace:*", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-slot": "^1.1.1", + "@tanstack/react-query": "^5.62.11", + "@ts-rest/core": "^3.52.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/web/apps/frontend/postcss.config.js b/web/apps/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/apps/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/apps/frontend/scripts/link-wasm.mjs b/web/apps/frontend/scripts/link-wasm.mjs new file mode 100644 index 0000000..6724a06 --- /dev/null +++ b/web/apps/frontend/scripts/link-wasm.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Make the WASM runtime artifacts available SAME-ORIGIN to the app without a +// third copy: symlink apps/frontend/public/wasm -> the synced artifact dir +// (tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh). Vite then +// serves them at /wasm from the app's own origin — required because KiCad WASM +// (Asyncify build, COEP/cross-origin-isolated document) refuses to load its +// glue/wasm from a different origin. +// +// No bytes are duplicated; the link points straight at the synced dir. +// Override the target with WASM_SRC_DIR (absolute, or relative to cwd). + +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const publicDir = path.resolve(scriptDir, "../public"); +const linkPath = path.join(publicDir, "wasm"); +const repoRoot = path.resolve(scriptDir, "../../../.."); +const setupScript = path.join(repoRoot, "tests/scripts/setup-kicad-wasm.sh"); + +const targetAbs = process.env.WASM_SRC_DIR + ? path.resolve(process.cwd(), process.env.WASM_SRC_DIR) + : path.join(repoRoot, "tests/apps/kicad"); + +// Sync freshly-built artifacts from output/ into tests/apps/kicad before +// linking, so `npm run dev` always serves the latest build instead of whatever +// was synced last. Skipped when WASM_SRC_DIR overrides the target: the setup +// script only writes to tests/apps/kicad, so running it would be a no-op there +// (and would mislead by syncing a dir we're not even linking to). +// Non-fatal: a missing/empty build must not block the dev server from starting. +// The setup script exits non-zero when no artifacts exist anywhere; we warn and +// fall through to the symlink, which already degrades gracefully (see below). +function syncArtifacts() { + if (process.env.WASM_SRC_DIR) { + console.log("[link-wasm] WASM_SRC_DIR set — skipping output/ sync"); + return; + } + const res = spawnSync("bash", [setupScript], { stdio: "inherit" }); + if (res.error) { + console.warn( + `[link-wasm] could not run setup-kicad-wasm.sh: ${res.error.message}`, + ); + } else if (res.status !== 0) { + console.warn( + `[link-wasm] setup-kicad-wasm.sh exited ${res.status} — ` + + `serving whatever is already in ${path.relative(repoRoot, targetAbs)}`, + ); + } +} +// Relative link so it stays valid if the repo moves. +const targetRel = path.relative(publicDir, targetAbs); + +async function statOrNull(p, { follow = true } = {}) { + try { + return follow ? await fs.stat(p) : await fs.lstat(p); + } catch { + return null; + } +} + +async function main() { + syncArtifacts(); + + await fs.mkdir(publicDir, { recursive: true }); + + const link = await statOrNull(linkPath, { follow: false }); + if (link?.isSymbolicLink()) { + const current = await fs.readlink(linkPath); + if (path.resolve(publicDir, current) === targetAbs) { + // already correct + } else { + await fs.rm(linkPath); + await fs.symlink(targetRel, linkPath, "dir"); + } + } else if (link) { + // A real dir/file (e.g. an earlier copy) — remove and link. + await fs.rm(linkPath, { recursive: true, force: true }); + await fs.symlink(targetRel, linkPath, "dir"); + } else { + await fs.symlink(targetRel, linkPath, "dir"); + } + + const target = await statOrNull(targetAbs); + if (!target?.isDirectory()) { + console.warn( + `[link-wasm] target not found: ${targetAbs}\n` + + `[link-wasm] run tests/scripts/setup-kicad-wasm.sh (or set WASM_SRC_DIR). ` + + `App will run but tools won't load.`, + ); + return; + } + const hasWx = await statOrNull(path.join(targetAbs, "wx.js")); + console.log( + `[link-wasm] public/wasm -> ${path.relative(repoRoot, targetAbs)}` + + (hasWx ? "" : " (warning: wx.js missing in target)"), + ); +} + +main().catch((err) => { + console.error("[link-wasm]", err); + process.exit(1); +}); diff --git a/web/apps/frontend/src/App.tsx b/web/apps/frontend/src/App.tsx new file mode 100644 index 0000000..8e6a5e0 --- /dev/null +++ b/web/apps/frontend/src/App.tsx @@ -0,0 +1,14 @@ +import { Route, Routes } from "react-router-dom"; +import { ProjectsPage } from "@/pages/ProjectsPage"; +import { ProjectDetailPage } from "@/pages/ProjectDetailPage"; +import { ToolPage } from "@/pages/ToolPage"; + +export default function App() { + return ( + + } /> + } /> + } /> + + ); +} diff --git a/web/apps/frontend/src/components/UploadDropzone.tsx b/web/apps/frontend/src/components/UploadDropzone.tsx new file mode 100644 index 0000000..026f40b --- /dev/null +++ b/web/apps/frontend/src/components/UploadDropzone.tsx @@ -0,0 +1,108 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Files, FolderUp, Loader2, Package } from "lucide-react"; +import { uploadFiles, uploadZip, type UploadItem } from "@/lib/api"; +import { Button } from "@/components/ui/button"; + +export function UploadDropzone({ slug }: { slug: string }) { + const qc = useQueryClient(); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + const filesRef = React.useRef(null); + const folderRef = React.useRef(null); + const zipRef = React.useRef(null); + + // `webkitdirectory` isn't in the React input typings; set it imperatively. + React.useEffect(() => { + if (folderRef.current) { + folderRef.current.setAttribute("webkitdirectory", ""); + folderRef.current.setAttribute("directory", ""); + } + }, []); + + const refresh = () => qc.invalidateQueries({ queryKey: ["project", slug] }); + + const run = async (fn: () => Promise) => { + setBusy(true); + setError(null); + try { + await fn(); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const onFiles = (e: React.ChangeEvent) => { + const list = e.target.files; + if (!list || list.length === 0) return; + const items: UploadItem[] = Array.from(list).map((file) => ({ + // folder picker → webkitRelativePath; multi-file picker → name + path: + (file as File & { webkitRelativePath?: string }).webkitRelativePath || + file.name, + file, + })); + void run(() => uploadFiles(slug, items)); + e.target.value = ""; + }; + + const onZip = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + void run(() => uploadZip(slug, file)); + e.target.value = ""; + }; + + return ( +
+
+ + + + {busy && ( + + uploading… + + )} +
+ {error &&

{error}

} + + + + +
+ ); +} diff --git a/web/apps/frontend/src/components/WasmTool.tsx b/web/apps/frontend/src/components/WasmTool.tsx new file mode 100644 index 0000000..8609d89 --- /dev/null +++ b/web/apps/frontend/src/components/WasmTool.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import type { ProjectFile, Tool } from "@kicad-web/contract"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { fetchFileBytes } from "@/lib/api"; +import { WASM_ASSET_BASE_URL } from "@/lib/config"; +import { driveProjectIntoTool, hookIframeConsole } from "@/wasm/kicad-runner"; + +/** + * Boots a KiCad tool by loading the proven harness HTML (/wasm/.html, the + * same file the e2e tests use) in a same-origin iframe, then injects the project + * tree into its MEMFS and drives File→Open. Same-origin is required: KiCad WASM + * refuses to load its glue/wasm from a different origin under COEP. + */ +export function WasmTool({ + tool, + slug, + files, + targetPath, +}: { + tool: Tool; + slug: string; + files: ProjectFile[]; + targetPath?: string; +}) { + const iframeRef = React.useRef(null); + const startedRef = React.useRef(false); + const [status, setStatus] = React.useState("Loading tool…"); + const [logs, setLogs] = React.useState([]); + const [showLog, setShowLog] = React.useState(false); + + const base = WASM_ASSET_BASE_URL.replace(/\/$/, ""); + const src = `${base}/${tool}.html`; + + const onLoad = () => { + if (startedRef.current) return; + startedRef.current = true; + const win = iframeRef.current?.contentWindow as + | ToolWindow + | null + | undefined; + if (!win) { + setStatus("Error: iframe has no window"); + return; + } + const append = (msg: string) => + setLogs((prev) => [...prev.slice(-800), msg]); + hookIframeConsole(win, append); + + void driveProjectIntoTool(win, { + tool, + slug, + files, + targetPath, + fetchBytes: (relPath) => fetchFileBytes(slug, relPath), + log: append, + onStatus: setStatus, + }).catch((err) => { + append(`[fatal] ${String(err)}`); + setStatus(`Error: ${String(err)}`); + }); + }; + + return ( +
+