Skip to content

Development Setup

Claude edited this page Oct 27, 2026 · 8 revisions

Development Setup

English · Español

How to build, run and check Proxy Builder from source: prerequisites, the pnpm policy, every package.json script, the repository layout, the ts-rs bindings flow, WebKitGTK notes, git hooks and formatter/linter rules.

For what the checks do in CI see Testing and CI; for coding rules see Conventions.

Prerequisites

Tool Version Source of truth
Node.js 22 or later package.json engines.node; CI uses Node 22
pnpm 12 or later; the exact version is pinned in packageManager (pnpm@12.14.0) package.json; npm is not supported
Rust (stable) 1.92 or later (rust-version in src-tauri/Cargo.toml, crates/proxy-core/Cargo.toml and crates/mesa/Cargo.toml) CI uses the latest stable with clippy and rustfmt
Node.js for the room server (optional) 22.13 or later (it uses node:sqlite) server/package.json engines.node; only needed to run the server or its tests (see below)
Tauri system libraries (Linux) WebKitGTK 4.1 and friends see below
Tauri prerequisites (Windows, macOS) the standard Tauri 2 ones (WebView2, MSVC build tools / Xcode command line tools) Inferred from Tauri 2 defaults; the repo does not pin them

Linux packages (Debian/Ubuntu), from README.md:

sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev \
  libssl-dev libayatana-appindicator3-dev librsvg2-dev

CI (.github/workflows/ci.yml) installs libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf on ubuntu-22.04 (cached, see Testing and CI). The app crate links GTK/WebKit, so cargo test/clippy for the whole workspace need those libraries even though you never open a window; the pure crates alone (cargo test -p proxy-core, cargo test -p mesa) do not.

Note: although libssl-dev is in the README list (Tauri's usual set), the dependency tree must contain no OpenSSL: reqwest is built with default-features = false and rustls, and CI fails if cargo tree -i openssl finds anything (see Testing and CI).

Quick start

pnpm install
pnpm tauri dev

pnpm tauri dev runs beforeDevCommand (pnpm dev, i.e. Vite on port 1420, strictPort: true: it fails instead of picking another port) and compiles the Rust app. The first Rust build takes several minutes. Vite is told not to watch src-tauri/ (Cargo handles that); with TAURI_DEV_HOST set it listens on that host with HMR on port 1421 (for mobile/remote dev; see vite.config.ts).

pnpm tauri build produces installers for the current platform (bundle.targets: "all" in src-tauri/tauri.conf.json); signing for the updater needs the private key in the environment, see Releases and Auto-Updates.

The pnpm policy

Configuration for pnpm 11+ lives in pnpm-workspace.yaml, not in package.json:

Key Value Why
minimumReleaseAge 1440 (minutes) Only install package versions published at least a day ago. This is pnpm 11's default; it is written down so every machine (cloud sandbox, maintainer's laptop) resolves the same way. A lockfile generated without the delay once contained versions published hours earlier and was rejected by the maintainer's own pnpm install.
allowBuilds esbuild: true The only dependency allowed to run an install script.

Rules that follow from this:

  • Regenerate pnpm-lock.yaml with the pnpm version in packageManager. A lockfile produced by pnpm 10 skips the age rule and can pull brand-new versions.
  • CI installs with pnpm install --frozen-lockfile (both ci.yml and release.yml).
  • Biome ignores pnpm-lock.yaml and lint-staged excludes it from JSON formatting.

Scripts

All from package.json:

Script Runs Use
pnpm dev vite Frontend dev server only (Tauri calls it for you)
pnpm build tsc && vite build Type-check then bundle into dist/ (frontendDist)
pnpm preview vite preview Serve the built bundle
pnpm tauri tauri Tauri CLI (pnpm tauri dev, pnpm tauri build, pnpm tauri icon)
pnpm bindings cargo test --manifest-path src-tauri/Cargo.toml --workspace export_bindings Regenerate src/bindings/*.ts from Rust types (see below)
pnpm check check:tipos, test, check:biome, check:title, idiomas, check:knip in that order Everything cheap on the frontend
pnpm check:tipos tsc --noEmit Type check
pnpm test vitest run Frontend and script unit tests
pnpm check:biome biome check . Lint + format check
pnpm check:title node scripts/sin-title-nativo.mjs Fails on native title= attributes
pnpm idiomas node scripts/idiomas.mjs Fails if any interface language is missing a translation or has mismatched placeholders/plurals; node scripts/idiomas.mjs --detalle also lists the warnings (see Testing and CI)
pnpm check:knip knip Unused files and dependencies
pnpm check:clippy cargo clippy --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings Rust lints; warnings are errors
pnpm check:rust-fmt cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check Rust format check
pnpm test:rust cargo test --manifest-path src-tauri/Cargo.toml --workspace All Rust tests (app, proxy-core, mesa)
pnpm format biome format --write . Reformat frontend files
pnpm release node scripts/release.mjs Cut a release (see Releases and Auto-Updates)
pnpm prepare husky Installs git hooks on pnpm install

Repository layout

flowchart LR
    root["repo root"] --> src["src/ (React + TypeScript)"]
    root --> st["src-tauri/ (Rust workspace)"]
    root --> sc["scripts/ (Node tooling)"]
    root --> gh[".github/workflows"]
    root --> dc["docs/"]
    root --> sv["server/ (room server, separate repo, git-ignored)"]
    st --> app["src/ (app crate: proxy_builder_lib)"]
    st --> core["crates/proxy-core (pure crate)"]
    st --> mesa["crates/mesa (pure game engine)"]
    st --> as["assets/ (house frame pack, fonts, JSON manifests)"]
Loading
Path Responsibility
src/features/<area>/ One folder per UI area: decklist, preview, export, variants, card-editor, library, stats, set-browser, settings, shell, disenar, tutorial, mesa (game table), salas (rooms and playgroups) (see Frontend Architecture)
src/stores/ Zustand stores: deck-store, plan-store, ui-store, trabajo-store, actualizacion-store, edicion-store, originales-store, and for the game table and online play mesa-store, vivo-store, chat-store, repeticion-store, red-store, salas-store (see State Stores)
src/lib/ ipc.ts (the only door to Rust), trabajos.ts, image-url.ts, dialogos.ts, errores.ts, themes helpers
src/components/ Shared components and the ui/ primitives (shadcn-style, Radix)
src/i18n/ Interface dictionaries, one folder per language code with one file per area, plus the translator and the lazy loader (see Internationalization)
src/bindings/ Generated TypeScript types from Rust (do not edit; excluded from Biome and Knip)
src/themes.css, src/index.css, src/styles/, src/fonts.css Theme tokens and global CSS (excluded from Biome)
src-tauri/src/ App crate: lib.rs (bootstrap + command registration only), commands/<area>.rs, store/, scryfall/, fuentes/, diseno/, mesa/ (game table app layer), red/ (online play: iroh node, protocol, identity, profiles, room server client), enlace.rs (proxybuilder:// links), ocr.rs, actualizar.rs, temas.rs, error.rs, state.rs, jobs.rs, protocol.rs, registro.rs (see Rust App Layer)
src-tauri/crates/proxy-core/ Pure core: decklist, layout, imaging, pdf, stats, diseno, model.rs, paper.rs, units.rs. No Tauri, no network, no database. Has the golden tests in tests/
src-tauri/crates/mesa/ Pure game engine: the game, its actions, randomness and what each player sees. No Tauri, no network. Tests in tests/ (see Testing and CI)
server/ The room server, a separate repository cloned here and ignored by the app's git (see below)
src-tauri/assets/ Bundled assets and manifests, e.g. letras-libres.json, packs-descargables.json, letras-cjk.json
src-tauri/capabilities/default.json Tauri permissions of the main window (including core:window:allow-destroy, needed by the close guard); no fs permission, all disk access goes through Rust commands
scripts/ idiomas.mjs, sin-title-nativo.mjs, release.mjs, release-notas.mjs, tipos-impresos.mjs (regenerates crates/proxy-core/assets/tipos-impresos.json from Scryfall, the printed type lines per language; run by hand when a set adds types, needs network and takes about ten minutes per language because of Scryfall's rate limit), emoji-noto.mjs (copies the Noto Color Emoji images the UI uses into src/assets/emoji/; run by hand, needs network or --desde <clone>, and inkscape on the PATH only when an SVG is over 64 KB and must be converted to PNG), __tests__/, icono.py and reverso.py (generate the app icon and the generic card back with Pillow; run by hand)
docs/ packs-de-marcos.md (frame-pack format) and presentacion/ (README screenshots; en/ for the English ones)
.github/workflows/ ci.yml, release.yml

src-tauri/Cargo.toml defines the workspace (members = [".", "crates/proxy-core", "crates/mesa"]) and the profiles. The app library is named proxy_builder_lib (the _lib suffix avoids a name clash with the binary on Windows).

The bindings flow (ts-rs)

Types that cross the IPC boundary are Rust structs/enums annotated #[derive(TS)] #[ts(export)]. ts-rs generates a test per exported type (named export_bindings_<Type>); running those tests writes the .ts files.

flowchart LR
    R["Rust type with derive TS and ts(export)"] -->|"pnpm bindings (cargo test ... export_bindings)"| B["src/bindings/Type.ts"]
    B --> I["src/lib/ipc.ts (Commands interface)"]
    I --> S["stores / components via call()"]
Loading
  • The destination comes from .cargo/config.toml at the repo root: TS_RS_EXPORT_DIR = { value = "src/bindings", relative = true }. It lives at the root, not in src-tauri/.cargo/, because Cargo reads config relative to the directory it is launched from and the pnpm scripts launch it from the root; in src-tauri/.cargo/ the types ended up in src-tauri/bindings/.
  • pnpm bindings must be re-run after changing any exported Rust type; commit the result. pnpm test:rust also runs those export tests (so they rewrite the files as a side effect), but nothing in CI diffs the result against what is committed. Inferred: a stale committed binding is only caught indirectly, when tsc in pnpm check fails against it.
  • src/bindings/ is never edited by hand and is ignored by Biome and Knip.
  • src/lib/ipc.ts then declares every command in the Commands interface (args and result types) and every event in Events. See IPC and Error Handling.

Running and testing the window in the cloud or headless

  • WebKitGTK is the webview on Linux; scroll and animation behaviour must be verified there, not only in Chrome.
  • On a machine without a GPU, start the app under a virtual display: Xvfb :99 and run with WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Without these variables the entrance animations of dialogs do not advance in a GPU-less display, so dialogs stay invisible yet open and swallow clicks. Real desktops do not need them.
  • The window starts hidden with a dark background (tauri.conf.json: visible: false, backgroundColor); the app shows it when the page has loaded (on_page_load in src-tauri/src/lib.rs). The window-state plugin does not persist visibility (StateFlags::all() & !StateFlags::VISIBLE).
  • Updates only work in an installed build: soportada() in src-tauri/src/actualizar.rs is false under debug_assertions (so never in pnpm tauri dev).

Several instances and online play

Debug builds read three variables; a release build ignores them (each check is behind cfg!(debug_assertions)):

Variable Read in Effect
PB_PERFIL=<name> src-tauri/src/red/perfil.rs, lib.rs A separate instance on the same machine. The name must be letters, digits, - or _, up to 32 characters, and is lowercased; anything else is ignored. The profile gets its own data, cache and log folders (perfiles/<name> inside each app folder), so its own library and iroh identity (identidad/clave lives in the data folder); its own webview data folder (webview in its cache, so its own localStorage: language, preferences, tutorials seen); the profile in the window title; and no window-state plugin (the instances would overwrite each other's position). The single-instance plugin is also off with a profile, so several windows can be open at once; a proxybuilder:// link then is not handed to a running copy. Copy library.db* and blobs/ into the profile's data folder to have decks there.
PB_RELAY src-tauri/src/red/nodo.rs (relay_de_entorno) none (case-insensitive): no relays, only direct addresses (LAN and tests). A relay URL: only that relay (to try your own). Any other value logs a warning and falls back to the default. Without it: the production setting, which for now is n0's public relays (RELE_PROPIO is None until there is a domain; once set, the own relay and n0's behind it).
PB_SERVIDOR src-tauri/src/red/servidor/mod.rs (url) The room server URL, e.g. ws://127.0.0.1:8787/v1/ws; empty counts as unset. Without it the production URL is used, which is None for now, so without PB_SERVIDOR the rooms screen says there is no server.

Other PB_* variables are for tests only and are listed in Testing and CI, except PB_PACKS_LOCAL (debug builds load downloadable frame packs from a local folder, see Frame Packs).

Running the room server locally

The room server is a separate repository cloned at server/ (ignored by the app's git; Node 22.13 or later):

cd server
pnpm install
pnpm dev        # tsx watch src/index.ts; GET /healthz answers ok

It reads its own variables (server/src/index.ts): PB_PUERTO (port, default 8787), PB_ESCUCHA (listen address, default 127.0.0.1, nginx goes in front in a deployment), PB_DB (SQLite file, default datos/servidor.db; :memory: for tests) and PB_RELAY_TOKEN (the token iroh-relay sends in its callout). Its other scripts: pnpm check (tsc, vitest, biome), pnpm test, pnpm protocolo:comprobar (the contract test only), pnpm build and pnpm start. With server/node_modules present, the app's pnpm test:rust also starts the server for a real test; see Testing and CI.

Several instances

A typical online test on one machine: the server running as above, the main instance with PB_SERVIDOR=ws://127.0.0.1:8787/v1/ws pnpm tauri dev (add PB_RELAY=none to stay on local addresses), and more instances started from the debug binary with the same variables plus a profile, e.g. PB_PERFIL=b src-tauri/target/debug/proxy-builder and PB_PERFIL=c …. On a headless machine run them all on the same Xvfb display with the WebKit variables above. Inferred: the extra instances load the frontend from Vite's dev server (devUrl, port 1420), so pnpm tauri dev (or pnpm dev) has to be running for them to show anything. See Online Play.

Git hooks and automatic fixes

pnpm install runs husky (prepare script) which wires .husky/pre-commit:

  1. pnpm exec lint-staged using lint-staged.config.mjs. It fixes only staged files:
    • *.{ts,tsx,js,jsx,mjs,cjs} run biome check --write --no-errors-on-unmatched;
    • .json/.html (except the pnpm lockfile) run biome format --write;
    • src-tauri/**/*.rs run rustfmt --edition 2021.
  2. If any .ts/.tsx file is staged: tsc --noEmit, pnpm idiomas, pnpm check:title.
  3. If any .rs file is staged and cargo is on the PATH: pnpm check:clippy (with -D warnings). The hook prepends ~/.cargo/bin to PATH because GUI git clients do not inherit the interactive shell's PATH.

Gotchas:

  • Use bare rustfmt --edition 2021 on staged files, never cargo fmt in lint-staged: cargo fmt reformats the whole crate and would drag untouched files into the commit. The --edition 2021 flag is mandatory: without it rustfmt assumes edition 2015 and silently leaves the file unchanged.
  • A new clippy warning is fixed, or allowed locally with #[allow(clippy::...)] plus a comment explaining why; --no-verify is not the answer.
  • The hook is deliberately cheap (the slow work, vitest, knip, cargo test, is left to pnpm check and CI).

Formatting and lint rules

Scope Rule Where
TS/TSX/JS/JSON/CSS/HTML Tabs (indent width 2, line width 100), double quotes, semicolons, trailing commas all (JSON: none), arrow parens always biome.json, .editorconfig
Rust Four spaces (rustfmt default) .editorconfig [*.rs]
Markdown/YAML Two spaces .editorconfig
Biome rules recommended plus a list of explicit relaxations (e.g. noNonNullAssertion, useExhaustiveDependencies, many a11y rules off) biome.json
Biome ignores node_modules, dist, dist-ssr, tmp, src-tauri/target, src-tauri/gen, pnpm-lock.yaml, public, src/bindings, saved test data (src-tauri/crates/proxy-core/tests/compatibilidad, src-tauri/crates/mesa/tests/catalogo, src/stores/__tests__/fixtures), src/index.css, src/themes.css, src/fonts.css, src/styles, and a couple of local tool folders; plus everything in .gitignore (vcs.useIgnoreFile), which is how server/ stays out biome.json files.includes, vcs
Rust lints Clippy with -D warnings; #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] in src-tauri/src/lib.rs, crates/proxy-core/src/lib.rs and crates/mesa/src/lib.rs Tests may use unwrap; test files add #![allow(clippy::unwrap_used, clippy::expect_used)]
TypeScript strict, noUnusedLocals, noUnusedParameters, alias @/* to src/* tsconfig.json

Rust dev profile specifics (src-tauri/Cargo.toml): debug = "line-tables-only" with split-debuginfo = "packed" to keep target/ small (unpacked debuginfo once grew a sibling project's target past 100 GB; run cargo clean after touching it), and dependencies are compiled with opt-level = 2 even in dev because image processing at 600 PPP unoptimised takes seconds and would make the live preview unusable under tauri dev. The mesa crate gets opt-level = 2 in dev too: every action copies and compares the whole game, and its property tests play thousands.

Where to look when something does not start

Symptom Check
pnpm install rejects the lockfile Regenerate with the packageManager pnpm version; see policy above
Port 1420 busy strictPort makes Vite fail; free the port
Types in src/bindings stale or missing pnpm bindings; confirm .cargo/config.toml is at the repo root
cargo cannot find GTK/WebKit Install the Linux packages above
Dialogs invisible on a headless display WebKit env variables above
Rooms say there is no server Debug build with PB_SERVIDOR set and the server running (see above)
Log of what the app did proxy-builder.log in the app log dir (src-tauri/src/registro.rs)

Automated contributors

The maintainer reviews changes in their own working tree: automated sessions leave edits uncommitted and do not push (a push triggers CI and failure emails). Before handing a patch over run the same gates as CI: pnpm bindings, pnpm check, pnpm check:rust-fmt, pnpm check:clippy (latest stable toolchain), pnpm test:rust, with biome check --write and cargo fmt applied first (lint-staged would do it at commit time). The project also expects the code-graph index to be refreshed at the end of each phase of work.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally