diff --git a/_docs/dev_setup.md b/_docs/dev_setup.md index 0819ac0d64..c3f0c536f5 100644 --- a/_docs/dev_setup.md +++ b/_docs/dev_setup.md @@ -9,21 +9,62 @@ To set up the ArtCraft development environment, install the following: 1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html). 2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.) -3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.) +3. The frontend's Nx and Vite dependencies are installed locally with npm; a global Nx installation is not required for the combined Unix launcher. 4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.) -An easy way to get started with running the app in development is to run the two commands (in separate terminals): - **Mac and Linux Development** ```bash -# Run the frontend dev server -./script/artcraft/unix_frontend_dev.sh +# Start the frontend and Rust/Tauri together in one terminal +./script/artcraft/unix_dev.sh -# Run the Tauri Rust application -./script/artcraft/unix_rust_dev.sh +# Optionally start the free-port search at another port +ARTCRAFT_DEV_PORT=6200 ./script/artcraft/unix_dev.sh ``` +Use the combined launcher instead of starting the two legacy dev scripts. It +works from any working directory when invoked by its path and installs frontend +dependencies if the local Vite installation is missing. Node.js 20+ is required. + +The launcher binds Vite to `127.0.0.1`, starting at port **5193** and trying +successive ports until one is available. It leaves existing listeners alone. +Only after the real frontend socket is bound does it start Tauri with that exact +`devUrl`. HTTP and the HMR websocket share the selected port; Rust communicates +through native Tauri IPC and needs no separate HTTP port. Configuration overrides +are passed in memory, without changing the checked-in Tauri config. + +JavaScript, TypeScript, React, and CSS changes use Vite hot reload; compatible +React component edits use Fast Refresh. Changes that cannot be hot-replaced +reload the page. Rust changes use `cargo tauri dev`'s automatic **rebuild and app +restart**, including dependent workspace crates. SQLx metadata and SQLite +migrations are also watched. Rust reload is a process restart, so it does not +preserve unsaved in-memory app state. See [Tauri's watch behavior](https://v2.tauri.app/develop/#reacting-to-source-code-changes). + +After startup, a Vite configuration restart must reuse the chosen port or report +an error; it cannot silently switch to another port while Tauri uses an old URL. +Ctrl-C stops this session's frontend, Rust watcher/compiler, and desktop app. +If the Rust watcher exits, the launcher closes Vite and propagates the exit code. +It never finds or terminates another app by name or port. + +The launcher retains the existing Unix defaults for `VITE_ENVIRONMENT_TYPE`, +`SQLX_OFFLINE`, `RUSTFLAGS`, and the Linux WebKit workarounds. Explicit environment +overrides are preserved. It uses the normal Artcraft app data and Cargo target +directory; port separation does not create an isolated account/data profile, and +concurrent Rust builds can wait on Cargo's build lock. + +To verify the launcher without compiling Rust or opening a desktop window: + +```bash +node --test frontend/scripts/unix-dev.test.mjs +``` + +The tests use real Vite listeners and HMR, with a test-owned fake Cargo process +tree. They cover occupied ports, simultaneous launches, React edits, shutdown, +Rust-process failure, and watcher arguments. To verify native Rust reload, run +the launcher, wait for the desktop window, make a Rust source edit, and check for +a rebuild/restart in the same terminal. Frontend edits should update the webview +without restarting the Rust process. + **Windows Development** ```powershell diff --git a/crates/desktop/artcraft/.taurignore b/crates/desktop/artcraft/.taurignore index ad17b2ca1c..24bf35a55f 100644 --- a/crates/desktop/artcraft/.taurignore +++ b/crates/desktop/artcraft/.taurignore @@ -1,4 +1,5 @@ -# Don't restart the dev server on Rust changes -crates/ -Cargo.toml -Cargo.lock \ No newline at end of file +# Rust sources and Cargo manifests must remain watched for `cargo tauri dev`. +# Vite owns frontend reloads; build artifacts must not trigger Rust rebuilds. +target/ +node_modules/ +dist/ diff --git a/frontend/apps/artcraft/vite.config.ts b/frontend/apps/artcraft/vite.config.ts index c7e65b3e87..3dcdd756a3 100644 --- a/frontend/apps/artcraft/vite.config.ts +++ b/frontend/apps/artcraft/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig, type Plugin } from "vite"; +import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; import path from "path"; import { resolve } from "node:path"; @@ -58,6 +59,7 @@ export default defineConfig({ }, }, plugins: [ + react(), sparkWasmDataUrlFix(), tsconfigPaths({ // Resolve workspace packages to source for every importer, including shared diff --git a/frontend/scripts/unix-dev.mjs b/frontend/scripts/unix-dev.mjs new file mode 100644 index 0000000000..f06c880829 --- /dev/null +++ b/frontend/scripts/unix-dev.mjs @@ -0,0 +1,149 @@ +// Invoked by script/artcraft/unix_dev.sh. Adapted from ArtcraftX's launcher. +// Living under frontend lets Node resolve the local Vite installation. +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; + +const rootDir = fileURLToPath(new URL("../../", import.meta.url)); +const frontendDir = path.join(rootDir, "frontend"); +const appDir = path.join(frontendDir, "apps/artcraft"); +const rustDir = path.join(rootDir, "crates/desktop/artcraft"); +const host = "127.0.0.1"; +const startingPort = Number(process.env.ARTCRAFT_DEV_PORT ?? "5193"); + +let vite; +let rust; +let stopping = false; + +async function main() { + if (!Number.isInteger(startingPort) || startingPort < 1024 || startingPort > 65535) { + throw new Error("ARTCRAFT_DEV_PORT must be an integer between 1024 and 65535."); + } + + process.env.VITE_ENVIRONMENT_TYPE ??= "production"; + // Match the app build's cwd, including Tailwind config/content resolution. + process.chdir(appDir); + vite = await createServer({ + configFile: path.join(appDir, "vite.config.ts"), + clearScreen: false, + server: { + host, + port: startingPort, + // Vite retries occupied ports while binding the real server. There is no + // port probe/release/rebind race, and no existing process is terminated. + strictPort: false, + open: false, + hmr: true, + }, + }); + if (stopping) return; + await vite.listen(); + const { port } = vite.httpServer.address(); + const devUrl = `http://${host}:${port}`; + + // Only initial startup may choose another port. On a Vite config restart, + // rebind this port or fail visibly so Tauri never follows a silent port hop. + for (const options of [vite.config.server, vite.config.inlineConfig.server]) { + options.port = port; + options.strictPort = true; + } + + const configPath = path.join(rustDir, "tauri-dev-hot-reload.conf.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + if (stopping) return; + const csp = config.app.security.csp; + const overrides = { + build: { devUrl, beforeDevCommand: "" }, + app: { + security: { + devCsp: { + ...csp, + "connect-src": [...csp["connect-src"], devUrl, `ws://${host}:${port}`], + }, + }, + }, + }; + + console.log(`\n[artcraft] Frontend + HMR: ${devUrl}`); + console.log("[artcraft] Rust/Tauri: native IPC; automatic rebuild and app restart enabled."); + console.log("[artcraft] Press Ctrl-C to stop both.\n"); + + rust = spawn("cargo", [ + "tauri", "dev", + "--no-dev-server", + // Tauri watches the desktop crate and its workspace dependencies itself. + // SQLx's external metadata and migrations also affect the Rust build. + "--additional-watch-folders", path.join(rootDir, ".sqlx"), + "--additional-watch-folders", path.join(rootDir, "_database/sql/artcraft_migrations"), + "--config", configPath, + "--config", JSON.stringify(overrides), + ], { + cwd: rootDir, + // All cargo/rustc/app descendants inherit this private Unix process group. + detached: true, + stdio: ["ignore", "inherit", "inherit"], + env: { + ...process.env, + TAURI_FRONTEND_PATH: frontendDir, + TAURI_APP_PATH: rustDir, + SQLX_OFFLINE: process.env.SQLX_OFFLINE ?? "true", + RUSTFLAGS: process.env.RUSTFLAGS ?? "-Awarnings", + WEBKIT_DISABLE_DMABUF_RENDERER: process.env.WEBKIT_DISABLE_DMABUF_RENDERER ?? "1", + WEBKIT_DISABLE_COMPOSITING_MODE: process.env.WEBKIT_DISABLE_COMPOSITING_MODE ?? "1", + }, + }); + rust.on("error", fail); + rust.on("exit", (code, signal) => { + if (!stopping) { + console.log(`[artcraft] Rust/Tauri exited (${signal ?? code}).`); + void stop(code ?? 1); + } + }); +} + +async function stop(exitCode) { + if (stopping) return; + stopping = true; + console.log("\n[artcraft] Stopping frontend and Rust/Tauri..."); + + const deadline = setTimeout(() => { + signalRustGroup("SIGKILL"); + process.exit(exitCode); + }, 5000); + signalRustGroup("SIGTERM"); + await Promise.allSettled([ + vite?.close(), + (async () => { + while (signalRustGroup(0)) await delay(50); + })(), + ]); + clearTimeout(deadline); + process.exit(exitCode); +} + +function signalRustGroup(signal) { + if (!rust?.pid) return false; + try { + process.kill(-rust.pid, signal); + return true; + } catch (error) { + if (error.code !== "ESRCH") throw error; + return false; + } +} + +function fail(error) { + console.error(`[artcraft] ${error.stack ?? error}`); + void stop(1); +} + +process.on("SIGINT", () => void stop(130)); +process.on("SIGTERM", () => void stop(143)); +process.on("SIGHUP", () => void stop(129)); +process.on("uncaughtException", fail); +process.on("unhandledRejection", fail); + +main().catch(fail); diff --git a/frontend/scripts/unix-dev.test.mjs b/frontend/scripts/unix-dev.test.mjs new file mode 100644 index 0000000000..5ce7749037 --- /dev/null +++ b/frontend/scripts/unix-dev.test.mjs @@ -0,0 +1,188 @@ +// Integration tests: real Vite sockets + a fake cargo process tree, no GUI/build. +// Run: node --test frontend/scripts/unix-dev.test.mjs +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { test } from "node:test"; +import WebSocket from "ws"; +import { fileURLToPath } from "node:url"; + +const rootDir = fileURLToPath(new URL("../../", import.meta.url)); +const launcher = path.join(rootDir, "script/artcraft/unix_dev.sh"); + +test("skips occupied ports, pairs Tauri with real Vite/HMR, and cleans up on Ctrl-C", { timeout: 40000 }, async (t) => { + const occupied = await occupyPort(t); + const startingPort = occupied.address().port; + const run = await fixture(t, { ARTCRAFT_DEV_PORT: String(startingPort) }); + const record = await run.record(); + const config = overrides(record); + const url = config.build.devUrl; + assert.ok(Number(new URL(url).port) > startingPort); + assert.equal(new URL(url).hostname, "127.0.0.1"); + assert.equal(config.build.beforeDevCommand, ""); + assert.ok(!record.args.includes("--no-watch")); + assert.ok(!record.args.includes("--no-dev-server-wait")); + assert.equal(record.env.TAURI_APP_PATH, path.join(rootDir, "crates/desktop/artcraft")); + assert.equal(record.env.TAURI_FRONTEND_PATH, path.join(rootDir, "frontend")); + assert.ok(config.app.security.devCsp["connect-src"].includes(url.replace("http:", "ws:"))); + assert.match(await (await fetch(url)).text(), /\/src\/index\.tsx/); + assert.match(await (await fetch(`${url}/@vite/client`)).text(), /WebSocket/); + + run.proc.kill("SIGINT"); + assert.deepEqual(await run.exited, [130, null], run.output()); + await until(() => !alive(record.pid) && !alive(record.childPid)); + await assert.rejects(fetch(url)); + assert.equal(occupied.listening, true, "must not terminate the existing listener"); +}); + +test("Tauri failure propagates and stops Vite and leftover descendants", { timeout: 40000 }, async (t) => { + const run = await fixture(t, { TEST_EXIT_CODE: "17" }); + const record = await run.record(); + assert.deepEqual(await run.exited, [17, null], run.output()); + await until(() => !alive(record.childPid)); + await assert.rejects(fetch(overrides(record).build.devUrl)); +}); + +test("SIGTERM shuts down the complete process tree", { timeout: 40000 }, async (t) => { + const run = await fixture(t); + const record = await run.record(); + run.proc.kill("SIGTERM"); + assert.deepEqual(await run.exited, [143, null], run.output()); + await until(() => !alive(record.pid) && !alive(record.childPid)); + await assert.rejects(fetch(overrides(record).build.devUrl)); +}); + +test("invalid ports fail before starting Tauri", { timeout: 40000 }, async (t) => { + const run = await fixture(t, { ARTCRAFT_DEV_PORT: "65536" }); + assert.deepEqual(await run.exited, [1, null]); + assert.match(run.output(), /ARTCRAFT_DEV_PORT must be an integer/); +}); + +test("concurrent launchers use different ports without replacing either frontend", { timeout: 40000 }, async (t) => { + const occupied = await occupyPort(t); + const env = { ARTCRAFT_DEV_PORT: String(occupied.address().port) }; + const first = await fixture(t, env); + const second = await fixture(t, env); + const [a, b] = await Promise.all([first.record(), second.record()]); + const firstUrl = overrides(a).build.devUrl; + const secondUrl = overrides(b).build.devUrl; + assert.notEqual(firstUrl, secondUrl); + assert.equal((await fetch(firstUrl)).status, 200); + assert.equal((await fetch(secondUrl)).status, 200); + first.proc.kill("SIGINT"); + await first.exited; + assert.equal((await fetch(secondUrl)).status, 200); + assert.ok(alive(b.pid)); +}); + +test("a React source edit sends HMR on the selected port without restarting Rust", { timeout: 40000 }, async (t) => { + const run = await fixture(t); + const record = await run.record(); + const url = overrides(record).build.devUrl; + const probeDir = await mkdtemp(path.join(rootDir, "frontend/apps/artcraft/app/src/dev-probe-")); + t.after(() => rm(probeDir, { recursive: true, force: true })); + const probe = path.join(probeDir, "Probe.tsx"); + const moduleUrl = `/src/${path.basename(probeDir)}/Probe.tsx`; + await writeFile(probe, "export default function Probe() { return
before
; }\n"); + const code = await (await fetch(`${url}${moduleUrl}`)).text(); + assert.match(code, /\$RefreshReg\$/); + + const client = await (await fetch(`${url}/@vite/client`)).text(); + const token = client.match(/const wsToken = "([^"]+)"/)[1]; + const ws = new WebSocket(`${url.replace("http:", "ws:")}/?token=${token}`, "vite-hmr"); + t.after(() => ws.terminate()); + const messages = []; + ws.on("message", (data) => messages.push(JSON.parse(data.toString()))); + await once(ws, "open"); + await writeFile(probe, "export default function Probe() { return
after
; }\n"); + await until(() => messages.some((m) => m.type === "update" && m.updates.some((u) => u.path === moduleUrl))); + assert.match(await (await fetch(`${url}${moduleUrl}`)).text(), /after/); + assert.ok(alive(record.pid), "JS edits must leave the Rust watcher running"); + assert.equal((await run.record()).pid, record.pid); +}); + +test("Rust watcher arguments include SQLx inputs and preserve caller overrides", { timeout: 40000 }, async (t) => { + const run = await fixture(t, { SQLX_OFFLINE: "false", RUSTFLAGS: "-C debuginfo=1" }); + const record = await run.record(); + assert.equal(record.env.SQLX_OFFLINE, "false"); + assert.equal(record.env.RUSTFLAGS, "-C debuginfo=1"); + assert.ok(record.args.includes(path.join(rootDir, ".sqlx"))); + assert.ok(record.args.includes(path.join(rootDir, "_database/sql/artcraft_migrations"))); + const ignores = await readFile(path.join(rootDir, "crates/desktop/artcraft/.taurignore"), "utf8"); + assert.doesNotMatch(ignores, /^(crates\/|Cargo\.toml|Cargo\.lock)$/m); +}); + +async function until(check) { + for (let attempt = 0; attempt < 200; attempt++) { + const result = await check(); + if (result) return result; + await delay(50); + } + throw new Error("Timed out waiting for dev launcher"); +} + +async function fixture(t, env = {}) { + const dir = await mkdtemp(path.join(os.tmpdir(), "artcraft-dev-test-")); + t.after(() => rm(dir, { recursive: true, force: true })); + await writeFile(path.join(dir, "cargo"), `#!/usr/bin/env node +const fs = require("node:fs"); +const { spawn } = require("node:child_process"); +if (process.argv.includes("--version")) process.exit(0); +const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); +fs.writeFileSync(process.env.TEST_RECORD, JSON.stringify({ + args: process.argv.slice(2), cwd: process.cwd(), + env: Object.fromEntries(["TAURI_APP_PATH", "TAURI_FRONTEND_PATH", "SQLX_OFFLINE", "RUSTFLAGS"].map(key => [key, process.env[key]])), + pid: process.pid, childPid: child.pid, +})); +if (process.env.TEST_EXIT_CODE) setTimeout(() => process.exit(Number(process.env.TEST_EXIT_CODE)), 200); +setInterval(() => {}, 1000); +`, { mode: 0o755 }); + + const recordFile = path.join(dir, "record.json"); + const proc = spawn(launcher, [], { + // Prove the launcher is independent of the invoking working directory. + cwd: dir, + env: { ...process.env, ...env, PATH: `${dir}:${process.env.PATH}`, TEST_RECORD: recordFile }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + proc.stdout.on("data", (chunk) => { output += chunk; }); + proc.stderr.on("data", (chunk) => { output += chunk; }); + const exited = once(proc, "exit"); + t.after(async () => { + if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGTERM"); + await exited; + }); + return { + proc, exited, output: () => output, + async record() { + return until(async () => { + if (proc.exitCode !== null) throw new Error(output); + try { return JSON.parse(await readFile(recordFile, "utf8")); } + catch (error) { if (error.code !== "ENOENT") throw error; } + }); + }, + }; +} + +async function occupyPort(t) { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + return server; +} + +function alive(pid) { + try { process.kill(pid, 0); return true; } + catch (error) { if (error.code === "ESRCH") return false; throw error; } +} + +function overrides(record) { + return JSON.parse(record.args.at(-1)); +} diff --git a/script/artcraft/unix_dev.sh b/script/artcraft/unix_dev.sh new file mode 100755 index 0000000000..074f7128e4 --- /dev/null +++ b/script/artcraft/unix_dev.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Start the desktop frontend and Rust/Tauri watcher together on macOS or Linux. +set -euo pipefail + +root_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +frontend_path="${root_dir}/frontend" + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + cat <<'HELP' +Usage: ./script/artcraft/unix_dev.sh + +Starts Vite with React/CSS hot reload and Tauri with Rust rebuild/restart. +Finds and binds a free loopback port starting at ARTCRAFT_DEV_PORT (default: 5193), +then passes that exact URL to Tauri. HMR shares the frontend port. +Rust uses native Tauri IPC and does not need a second HTTP port. +Ctrl-C stops this launcher's frontend, Rust watcher, and desktop app. + +Requires Node.js 20+, npm, Rust, cargo-tauri 2, and Tauri platform prerequisites. +Installs frontend dependencies only when the local Vite installation is missing. +Run this command instead of running the two legacy dev scripts together. +HELP + exit 0 +fi + +if [[ $# -ne 0 ]]; then + echo "Unknown argument: $1 (see --help)" >&2 + exit 1 +fi + +source "${root_dir}/script/common/frontend_preflight.sh" +frontend_preflight "${frontend_path}" + +if ! command -v cargo >/dev/null || ! cargo tauri --version >/dev/null 2>&1; then + echo 'ERROR: Install Rust and the Tauri CLI: cargo install tauri-cli --version "^2" --locked' >&2 + exit 1 +fi + +cd "${frontend_path}" +if [[ ! -f node_modules/vite/package.json ]]; then + # Do not use the legacy install helper: its failure recovery deletes caches. + npm install +fi + +exec node "${frontend_path}/scripts/unix-dev.mjs"