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