Compare commits

..
Author SHA1 Message Date
014648807f cmms-embed: VITE_LIBS_SOURCE=static (not off) — install the window.kicadLibs bridge
Some checks failed
wasm-build.yml / cmms-embed: VITE_LIBS_SOURCE=static (not off) — install the window.kicadLibs bridge (push) Failing after 0s
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
release / publish-wasm (push) Blocked by required conditions
release / deploy-demo (push) Blocked by required conditions
release / deploy-editor (push) Blocked by required conditions
release / Build all tools + KiCad e2e (Ubicloud) (push) Waiting to run
wasm-build.yml / cmms-embed: VITE_LIBS_SOURCE=static (not off) — install the window.kicadLibs bridge (release) Failing after 0s
off leaves libsSource null, so boot.ts never calls installLibsProvider and
window.kicadLibs is never installed. A C++ libs-bridge call on schematic open
(park site K1, sch_io_pcbjam_lib) then hits undefined -> throws inside a
suspending import -> SuspendError -> abort. static installs the bridge (2
built-in example symbols; answers not-found gracefully otherwise), no backend.

Standalone-only change; dist/ rebuild, no wasm rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:13:09 +10:00
1014881551 cmms-embed: add SOURCE.txt (provenance + GPLv3 corresponding-source pointer)
Some checks failed
wasm-build.yml / cmms-embed: add SOURCE.txt (provenance + GPLv3 corresponding-source pointer) (push) Failing after 0s
wasm-build.yml / cmms-embed: add SOURCE.txt (provenance + GPLv3 corresponding-source pointer) (release) Failing after 0s
release / meta (push) Has been cancelled
release / build (push) Has been cancelled
release / publish-wasm (push) Has been cancelled
release / deploy-demo (push) Has been cancelled
release / deploy-editor (push) Has been cancelled
release / Build all tools + KiCad e2e (Ubicloud) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:52:42 +10:00
84dd40702c cmms-embed: sub-path mount support (/vendor/pcbjam/)
Serve the standalone editor under a CMMS sub-path with no change to
@pcbjam/shared or the wasm/C++ side. No-op for a root deploy (BASE_URL='/').

- main.tsx: <BrowserRouter basename={import.meta.env.BASE_URL}>
- lib/base-path.ts (new): stripBase() for raw location.pathname reads,
  hardNav() for the cross-document window.location.assign() navs (the
  process-global wasm runtime means a tool switch / File->Quit is a full
  page load, and @pcbjam/shared's projectPath() is origin-absolute)
- config.ts currentScope(): stripBase() before splitting for the scope segment
- WasmTool.tsx quit-path: stripBase() for the projects/libs segment test
- tool-navigation.ts (x2), NewFileDialog.tsx, ProjectView.tsx, quit-hook.ts:
  window.location.assign(projectPath(...)) -> hardNav(...)
- .env.production: VITE_API_BASE_URL=/pcb-project-api.php, PROJECT_SOURCE=remote,
  DOC_SOURCE=api, LIBS_SOURCE=off, WASM_ROOT=/vendor/pcbjam/wasm,
  YJS_PROVIDER=broadcastchannel; APP_URL/USER/SCOPE unset

Build: vite build --base=/vendor/pcbjam/

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:49:31 +10:00
28c545ff6e build: host networking (no Docker bridge/NAT on the build host)
Some checks failed
wasm-build.yml / build: host networking (no Docker bridge/NAT on the build host) (push) Failing after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:02:02 +10:00
11 changed files with 205 additions and 8 deletions

81
SOURCE.txt Normal file
View file

@ -0,0 +1,81 @@
PCBJam — self-hosted fork (CMMS embed)
======================================
This repository is a self-hosted fork of PCBJam (browser-based KiCad compiled to
WebAssembly), maintained for embedding the editor in the tas-tech.net CMMS
(TT-DB) at /vendor/pcbjam/.
Upstream
--------
https://github.com/emergence-engineering/pcbjam
tracked here as git remote `upstream`.
Forked at upstream commit:
766e2f409fa8b098555226479742ec694709ea48 (branch main, 2026-09-01)
Branches
--------
main pristine mirror of upstream @ 766e2f4 (no local changes)
build/host-networking docker/docker-compose.yml: host networking on the build
host (its dockerd runs bridge:none + iptables:false for
a VPN killswitch, so the image build + compile container
use the host network namespace). 1 commit (28c545f).
cmms-embed build/host-networking + sub-path mount support so the
standalone editor serves under /vendor/pcbjam/ with no
change to KiCad/wxWidgets or @pcbjam/shared. See the
commit message for the file-by-file breakdown. A pure
no-op when built without `--base` (root deploy).
Submodules
----------
Pinned by the superproject gitlinks (nothing in scripts/ overrides them —
`KICAD_COMMIT` in scripts/common/versions.sh is unused/informational):
kicad 56678639eea1b3c0358f384fa47ef7fab78bd09b
upstream https://github.com/PCBJam/kicad-source-mirror (branch wasm-port)
wxwidgets 4e6cc5a441e46ac07b242caa3adacfd8e7c7ec50
upstream https://github.com/PCBJam/wxWidgets (branch wasm-port)
web/pcbjam-shared a4984c626d847bd07ce66148d6ec96fde8ee8e42
upstream https://github.com/PCBJam/pcbjam-shared (branch main)
.gitmodules still points at PCBJam's GitHub forks — these are NOT mirrored into
Forgejo. A checkout needs `git submodule update --init --recursive` against GitHub.
Toolchain / dependency pins (scripts/common/versions.sh)
-------------------------------------------------------
Emscripten SDK 6.0.6 (installed from emsdk inside docker/Dockerfile;
base image ubuntu:22.04)
KiCad 8.99
OpenCASCADE 7.8.0
wxWidgets 3.3.1
Boost 1.84.0
Protobuf 3.21.12
Python 3.11.5
ngspice 46
FreeType 2.13.2
HarfBuzz 8.3.0
Cairo 1.18.0
Pixman 0.42.2
Zstd 1.5.5
GLM 0.9.9.8
(every download in versions.sh also carries a SHA256 pin)
Build
-----
KiCad WASM: ./docker/build.sh kicad_editor -j 4 --build-deps (debug)
./docker/build.sh kicad_editor -j 4 --full --release (ship)
Editor UI: cd web && pnpm install --frozen-lockfile
pnpm --filter @pcbjam/standalone exec vite build --base=/vendor/pcbjam/
(env: web/standalone/.env.production on the cmms-embed branch)
License
-------
KiCad and wxWidgets are GPLv3. PCBJam's own code carries its upstream licenses
(see LICENSE and the per-package headers; @pcbjam/shared is MIT). This fork is
distributed under the same terms.
The corresponding source for any deployed WASM build IS this repository at the
commit the build was made from, together with the submodule revisions its
gitlinks pin (listed above). Repository:
https://repo.tas-tech.net/AI_Assistant/pcbjam

View file

@ -3,6 +3,9 @@ services:
build: build:
context: .. context: ..
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
# Build host runs dockerd with bridge:none + iptables:false (PIA killswitch,
# no Docker NAT). Image-build RUN steps (emsdk download) use the host netns.
network: host
args: args:
# Single source of truth: scripts/common/versions.sh, exported into the env by the scripts # Single source of truth: scripts/common/versions.sh, exported into the env by the scripts
# that drive compose (docker/build.sh, docker/shell.sh). No default -> the Dockerfile fails # that drive compose (docker/build.sh, docker/shell.sh). No default -> the Dockerfile fails
@ -10,6 +13,10 @@ services:
EMSCRIPTEN_VERSION: ${EMSCRIPTEN_VERSION:?source scripts/common/versions.sh before docker compose} EMSCRIPTEN_VERSION: ${EMSCRIPTEN_VERSION:?source scripts/common/versions.sh before docker compose}
# Container name is auto-generated with project prefix (set in build.sh) # Container name is auto-generated with project prefix (set in build.sh)
# The compile container shares the host network namespace so it inherits the
# PIA killswitch directly — the build host's dockerd has no bridge/NAT.
network_mode: host
# Resource limits. Defaults are sized for a dev Mac (Docker Desktop VM). # Resource limits. Defaults are sized for a dev Mac (Docker Desktop VM).
# CI overrides via env: the 32-core Hetzner runner sets KICAD_DOCKER_CPUS=$(nproc) # CI overrides via env: the 32-core Hetzner runner sets KICAD_DOCKER_CPUS=$(nproc)
# and KICAD_DOCKER_MEM=110G — with the 10-CPU default, run 27226030304 compiled # and KICAD_DOCKER_MEM=110G — with the 10-CPU default, run 27226030304 compiled

View file

@ -0,0 +1,33 @@
# CMMS embed (cmms-embed branch). Consumed by `vite build` (mode=production).
# Pair with `vite build --base=/vendor/pcbjam/`. All values are public (they ship
# in the bundle) — no secrets here.
# The PHP adapter. Relative so it resolves against the CMMS origin. The adapter
# handles PATH_INFO, so /api/scopes/... and /api/me land as
# /pcb-project-api.php/api/scopes/... and /pcb-project-api.php/api/me.
VITE_API_BASE_URL=/pcb-project-api.php
# Projects come from the REST adapter above; file bytes fetched + saves PUT back.
VITE_PROJECT_SOURCE=remote
VITE_DOC_SOURCE=api
# No library backend for v1. `static` (not `off`): `off` leaves `libsSource` null so boot.ts
# never calls installLibsProvider → `window.kicadLibs` is never installed → any C++ path that
# resolves a symbol/footprint through the libs bridge on schematic open (park site K1,
# sch_io_pcbjam_lib) calls a method on undefined → throws inside a suspending import →
# SuspendError → abort. `static` installs the bridge backed by 2 built-in example symbols; it
# answers "not found" gracefully for everything else. No backend, no manifest, self-contained.
VITE_LIBS_SOURCE=static
# Flat WASM layout, served static by Apache under the mount (no per-tool subdir,
# VITE_WASM_MANIFEST intentionally unset). Files: kicad_editor.{js,wasm}, wx.js,
# wx-dom.js, images.tar.gz.
VITE_WASM_ROOT=/vendor/pcbjam/wasm
# Cross-tab only; no collab server.
VITE_YJS_PROVIDER=broadcastchannel
# VITE_APP_URL deliberately UNSET — with it set, every non-editor route would
# redirect away from the mount (lib/redirect.ts).
# VITE_USER / VITE_SCOPE deliberately UNSET — identity comes from
# /pcb-project-api.php/api/me, scope from the URL path segment after the mount.

View file

@ -7,6 +7,7 @@ import {
} from "@pcbjam/shared"; } from "@pcbjam/shared";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { uploadFileBytes } from "@/lib/api"; import { uploadFileBytes } from "@/lib/api";
import { hardNav } from "@/lib/base-path";
import { currentScope } from "@/lib/config"; import { currentScope } from "@/lib/config";
import { localProjectStore } from "@/lib/project-source"; import { localProjectStore } from "@/lib/project-source";
import { useLocalProjects } from "@/lib/api"; import { useLocalProjects } from "@/lib/api";
@ -124,7 +125,7 @@ export function NewFileDialog({
// A home-created project is browser-local (@local scope); an in-project new // A home-created project is browser-local (@local scope); an in-project new
// file keeps the current scope. The tool is inferred from the file's ext. // file keeps the current scope. The tool is inferred from the file's ext.
const scope = homeMode ? LOCAL_SCOPE : currentScope(); const scope = homeMode ? LOCAL_SCOPE : currentScope();
window.location.assign(projectPath(scope, slug, finalName)); hardNav(projectPath(scope, slug, finalName));
} catch (e) { } catch (e) {
setBusy(false); setBusy(false);
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));

View file

@ -20,6 +20,7 @@ import {
yjsProviderConfig, yjsProviderConfig,
type DocSource, type DocSource,
} from "@/lib/config"; } from "@/lib/config";
import { stripBase } from "@/lib/base-path";
import { redirectTargetFor } from "@/lib/redirect"; import { redirectTargetFor } from "@/lib/redirect";
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity"; import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
import { useThemeValue } from "@/lib/theme"; import { useThemeValue } from "@/lib/theme";
@ -629,7 +630,10 @@ export function WasmTool({
// non-editor surfaces: on a backed deploy (APP_URL set) they belong to the // non-editor surfaces: on a backed deploy (APP_URL set) they belong to the
// management app, so quit goes straight there (one hop instead of letting // management app, so quit goes straight there (one hop instead of letting
// App.tsx's 0006 redirect bounce it). // App.tsx's 0006 redirect bounce it).
const segments = win.location.pathname.split("/").filter(Boolean); // stripBase: segments are relative to the mount, so segments[1] is
// "projects"/"libs" even under /vendor/pcbjam/. exitPath stays app-absolute
// (no mount prefix) — installQuitHook's hardNav() re-adds it.
const segments = stripBase(win.location.pathname).split("/").filter(Boolean);
const exitPath = const exitPath =
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug); segments[1] === "libs" ? "/" : projectPath(currentScope(), slug);
const removeQuitHook = installQuitHook(win, { const removeQuitHook = installQuitHook(win, {

View file

@ -6,6 +6,8 @@
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches // the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
// off as soon as any unload/navigation is under way. // off as soon as any unload/navigation is under way.
import { hardNav } from "@/lib/base-path";
let activeQuitHook: (() => void) | undefined; let activeQuitHook: (() => void) | undefined;
let quitHandled = false; let quitHandled = false;
@ -93,7 +95,9 @@ export function installQuitHook(
// wasm stack has unwound. // wasm stack has unwound.
setTimeout(() => { setTimeout(() => {
opts.log(`[quit] editor closed — going to ${opts.exitUrl}`); opts.log(`[quit] editor closed — going to ${opts.exitUrl}`);
win.location.assign(opts.exitUrl); // hardNav: re-add the CMMS mount prefix to the app-absolute exit path
// (a full APP_URL redirect target passes through untouched).
hardNav(opts.exitUrl, win);
}, 0); }, 0);
}; };

View file

@ -7,6 +7,7 @@ import {
toolSchema, toolSchema,
type Tool, type Tool,
} from "@pcbjam/shared"; } from "@pcbjam/shared";
import { hardNav } from "@/lib/base-path";
import { currentScope } from "@/lib/config"; import { currentScope } from "@/lib/config";
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file"; import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
import { memfsProjectDir } from "@/wasm/constants"; import { memfsProjectDir } from "@/wasm/constants";
@ -175,7 +176,7 @@ export function installToolNavigationHook(
await createFile(relPath, bytes); await createFile(relPath, bytes);
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`); opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
markDeliberateNavigation(); markDeliberateNavigation();
win.location.assign(url); hardNav(url, win);
} catch (e) { } catch (e) {
pendingCreate = null; pendingCreate = null;
opts.log( opts.log(
@ -196,7 +197,7 @@ export function installToolNavigationHook(
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`); opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
markDeliberateNavigation(); markDeliberateNavigation();
win.location.assign(url); hardNav(url, win);
return true; return true;
}; };

View file

@ -0,0 +1,57 @@
/**
* Sub-path mount support (cmms-embed fork).
*
* The CMMS serves this SPA under `/vendor/pcbjam/`, built with
* `vite build --base=/vendor/pcbjam/` so `import.meta.env.BASE_URL` is that
* prefix (always a leading + trailing slash; "/" for a root deploy).
*
* `<BrowserRouter basename={import.meta.env.BASE_URL}>` already makes
* `<Link>` / `navigate()` / `useLocation()` mount-relative. This module covers
* the two things the router can't:
* 1. code that reads `window.location.pathname` raw (currentScope, WasmTool's
* quit-path), and
* 2. the hard cross-document `window.location.assign()` navigations the
* WASM runtime is process-global, so a tool switch / File→Quit / new file
* is a full page load, not a router transition, and the app paths handed
* to it (`@pcbjam/shared` `projectPath()` etc.) are origin-absolute.
*
* Nothing here touches `@pcbjam/shared` it also compiles for the backend,
* where `import.meta` does not exist.
*/
/** `import.meta.env.BASE_URL` without its trailing slash ("" for a root deploy). */
export const BASE_PREFIX = import.meta.env.BASE_URL.replace(/\/$/, "");
/**
* `pathname` with the mount prefix removed; the result always starts with "/".
* A pathname outside the mount (or a root deploy) is returned unchanged.
*/
export function stripBase(pathname: string): string {
if (
BASE_PREFIX &&
(pathname === BASE_PREFIX || pathname.startsWith(`${BASE_PREFIX}/`))
) {
const rest = pathname.slice(BASE_PREFIX.length);
return rest === "" ? "/" : rest;
}
return pathname;
}
/**
* `window.location.assign()` to an app-absolute path (e.g. from
* `projectPath()`), re-adding the mount prefix. A full URL (has a scheme or is
* protocol-relative) passes through untouched so an `APP_URL` redirect target
* is never corrupted.
*
* `win` is injectable for the same reason the tool-navigation / quit hooks take
* a window: test seams and the wx-port's frame window.
*/
export function hardNav(
appPath: string,
win: { location: { assign(url: string): void } } = window,
): void {
const url = /^([a-z][a-z0-9+.-]*:)?\/\//i.test(appPath)
? appPath
: BASE_PREFIX + appPath;
win.location.assign(url);
}

View file

@ -133,6 +133,7 @@ export const LOCAL_PROJECTS_ENABLED =
import.meta.env.VITE_LOCAL_PROJECTS === "idb"; import.meta.env.VITE_LOCAL_PROJECTS === "idb";
import { colorForUser, type PresenceUser } from "@pcbjam/shared"; import { colorForUser, type PresenceUser } from "@pcbjam/shared";
import { stripBase } from "@/lib/base-path";
import { sessionIdentity } from "@/lib/session-identity"; import { sessionIdentity } from "@/lib/session-identity";
import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { cdnLibsSource } from "@/wasm/libs/cdn-source"; import { cdnLibsSource } from "@/wasm/libs/cdn-source";
@ -282,7 +283,11 @@ export const PRESENCE_TUNER_ENABLED = import.meta.env.VITE_PRESENCE_TUNER === "1
*/ */
export function currentScope(): string { export function currentScope(): string {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const seg = window.location.pathname.split("/").filter(Boolean)[0]; // stripBase: under the CMMS sub-path mount the scope is the first segment
// AFTER /vendor/pcbjam/, not "vendor". No-op for a root deploy.
const seg = stripBase(window.location.pathname)
.split("/")
.filter(Boolean)[0];
if (seg && seg !== "projects" && seg !== "libs") { if (seg && seg !== "projects" && seg !== "libs") {
return decodeURIComponent(seg); return decodeURIComponent(seg);
} }

View file

@ -38,7 +38,10 @@ const queryClient = new QueryClient({
// instantiate exactly once per navigation. // instantiate exactly once per navigation.
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> {/* basename = the vite `base` (import.meta.env.BASE_URL) so the CMMS
sub-path mount (/vendor/pcbjam/) is transparent to <Link>/navigate();
the hard location.assign() navs use hardNav() (lib/base-path.ts). */}
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</QueryClientProvider>, </QueryClientProvider>,

View file

@ -17,6 +17,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api"; import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
import { hardNav } from "@/lib/base-path";
import { downloadBytes } from "@/lib/download"; import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source"; import { localProjectStore } from "@/lib/project-source";
import { zipFiles } from "@/lib/zip"; import { zipFiles } from "@/lib/zip";
@ -108,7 +109,7 @@ export function ProjectView() {
// scoped with a full reload; document tools create a templated file first. // scoped with a full reload; document tools create a templated file first.
const launchTool = (tool: Tool) => { const launchTool = (tool: Tool) => {
if (FILELESS_TOOLS.has(tool)) { if (FILELESS_TOOLS.has(tool)) {
window.location.assign(projectToolPath(scope, slug, tool)); hardNav(projectToolPath(scope, slug, tool));
} else { } else {
setNewFileTool(tool); setNewFileTool(tool);
} }