Compare commits

...

8 Commits

Author SHA1 Message Date
Brandon Thomas 98c2a48940
Merge pull request #5 from storytold/faster-dev-script
faster dev script that handles port allocation
2026-09-26 17:45:49 -07:00
Brandon Thomas 0f8c9da758 faster dev script that handles port allocation 2026-09-26 17:43:22 -07:00
Brandon Thomas 4d132a77e0
Merge pull request #4 from storytold/bt-readme2
update readme
2026-09-20 08:56:46 -04:00
Brandon Thomas 0d6ffe16ad update readme 2026-09-20 08:55:34 -04:00
Brandon Thomas bf5b60aa61
Merge pull request #3 from storytold/bt-readme
readme update
2026-09-20 08:53:53 -04:00
Brandon Thomas a425110062 rearrange 2026-09-20 08:53:33 -04:00
Brandon Thomas a9a6471f56 readme update 2026-09-20 08:52:17 -04:00
Brandon Thomas efb14ea1aa
Merge pull request #2 from storytold/first-version
First version
2026-09-20 08:38:27 -04:00
6 changed files with 382 additions and 13 deletions

View File

@ -1,5 +1,55 @@
ArtCraft-X
==========
<h1 align="center">ArtCraft-X</h1>
A minimalistic desktop app for creatives.
<p align="center">
<img src="docs/images/artcraftx-screenshot.png" width="1100" alt="ArtCraft-X's image generation workspace, with model selection, reference images, and a prompt editor.">
</p>
<p align="center">A minimal desktop app for AI creation.</p>
<p align="center">
Images · Video · Audio · 3D Meshes · Worlds
</p>
<p align="center">
<a href="https://getartcraft.com/">ArtCraft Website</a> ·
<a href="https://github.com/storytold/artcraft">ArtCraft Repository</a>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Discord members online" src="https://img.shields.io/discord/1359579021108842617?style=for-the-badge&amp;label=Discord&amp;color=5865F2&amp;logo=discord&amp;logoColor=white"></a>
<a href="https://www.youtube.com/@OfficialArtCraftStudios"><img alt="YouTube" src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&amp;logo=youtube&amp;logoColor=white"></a>
<a href="https://x.com/get_artcraft"><img alt="X" src="https://img.shields.io/badge/X-181717?style=for-the-badge&amp;logo=x&amp;logoColor=white"></a>
<a href="https://www.linkedin.com/company/artcraft-ai"><img alt="LinkedIn" src="https://img.shields.io/badge/LinkedIn-0A66C2?style=for-the-badge"></a>
</p>
<p align="center">
<strong>Early Experimental Release</strong><br>
Builds are Forthcoming
</p>
## Development (macOS / Linux)
With Node.js 20+, npm, Rust, and the Tauri 2 CLI installed, run:
```bash
./script/unix_dev.sh
```
This starts the frontend and desktop app together. It installs frontend
dependencies when missing, then binds the first available loopback port starting
at **5183** before launching Tauri with that exact URL. Existing processes are
left running. To start the port search elsewhere:
```bash
ARTCRAFTX_DEV_PORT=5200 ./script/unix_dev.sh
```
JavaScript, TypeScript, and CSS edits reload through Vite, including shared
frontend libraries. Rust edits in the app or its workspace dependencies trigger
a rebuild and desktop app restart; in-memory app state resets on a Rust restart.
The Rust backend uses native Tauri IPC, so there is no second backend HTTP port
to allocate. HTTP and the HMR WebSocket share the selected frontend port.
Press **Ctrl-C** to stop the frontend, Rust watcher, and app. Use this combined
launcher instead of running the two `script/artcraftx/unix_*_dev.sh` scripts.
The launcher resolves paths relative to itself and can be called from any directory.

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

View File

@ -26,22 +26,21 @@ npm install
## Running the apps in dev mode
From the **repository root**, the launcher scripts (these preflight your
environment, install dependencies, and free the dev port first):
From the **repository root**, start the frontend and Tauri desktop app together:
```bash
./script/website/unix_frontend_dev.sh # artcraft-website (marketing site), port 4200
./script/website/unix_frontend_webapp_dev.sh # artcraft-webapp (user dashboard), port 4201
./script/artcraft/unix_frontend_dev.sh # artcraft (Tauri app frontend), port 5173
./script/unix_dev.sh
```
Or directly from this directory:
The launcher finds and binds a free port starting at 5183, then gives Tauri the
same URL. Set `ARTCRAFTX_DEV_PORT` to change the starting port. Vite handles
frontend live reload; Tauri watches Rust workspace dependencies and rebuilds and
restarts the app on edits. Ctrl-C stops both. See the repository README for details.
For just the frontend, directly from this directory:
```bash
nx dev artcraft-website
nx dev artcraft-webapp
nx dev artcraft # NB: the Tauri app also needs the Rust dev server running
nx dev editor2d
npx nx dev artcraft
```
## Building

View File

@ -0,0 +1,147 @@
// Invoked by script/unix_dev.sh. Keep this under frontend so Node resolves the
// workspace's installed Vite, without requiring a global Nx or another install.
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/artcraftx");
const host = "127.0.0.1";
const startingPort = Number(process.env.ARTCRAFTX_DEV_PORT ?? "5183");
let vite;
let rust;
let stopping = false;
// cargo-tauri, cargo, rustc, and the app inherit this dedicated Unix process
// group. Never find or kill processes by port/name: ArtCraft may be running too.
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;
}
}
async function stop(exitCode) {
if (stopping) return;
stopping = true;
console.log("\n[artcraftx] Stopping frontend and Rust/Tauri...");
// Bound shutdown even when a compiler, webview, or Vite plugin gets stuck.
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 fail(error) {
console.error(`[artcraftx] ${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);
async function main() {
if (!Number.isInteger(startingPort) || startingPort < 1024 || startingPort > 65535) {
throw new Error("ARTCRAFTX_DEV_PORT must be an integer between 1024 and 65535.");
}
process.env.VITE_ENVIRONMENT_TYPE ??= "production";
process.chdir(appDir);
vite = await createServer({
configFile: path.join(appDir, "vite.config.ts"),
clearScreen: false,
server: {
host,
port: startingPort,
// Vite retries EADDRINUSE on successive ports while actually binding the
// server. There is no probe/release/rebind window for a competing app.
strictPort: false,
open: false,
hmr: true,
},
});
if (stopping) return;
await vite.listen();
const { port } = vite.httpServer.address();
const devUrl = `http://${host}:${port}`;
// On Vite config edits, restart on this same port or fail visibly. Silently
// moving again would leave Tauri pointing at the old (possibly foreign) app.
for (const serverOptions of [vite.config.server, vite.config.inlineConfig.server]) {
serverOptions.port = port;
serverOptions.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[artcraftx] Frontend + HMR: ${devUrl}`);
console.log("[artcraftx] Rust/Tauri: native IPC; automatic rebuild and app restart enabled.");
console.log("[artcraftx] Press Ctrl-C to stop both.\n");
rust = spawn("cargo", [
"tauri", "dev",
"--no-dev-server",
"--config", configPath,
"--config", JSON.stringify(overrides),
], {
cwd: rootDir,
// Start a separate process group, but keep all output in this terminal.
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(`[artcraftx] Rust/Tauri exited (${signal ?? code}).`);
void stop(code ?? 1);
}
});
}
main().catch(fail);

View File

@ -0,0 +1,132 @@
// 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 { fileURLToPath } from "node:url";
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
const launcher = path.join(rootDir, "script/unix_dev.sh");
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(), "artcraftx-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), env: process.env, cwd: process.cwd(),
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));
}
test("skips occupied ports, pairs Tauri with real Vite/HMR, and cleans up on Ctrl-C", { timeout: 20000 }, async (t) => {
const occupied = await occupyPort(t);
const startingPort = occupied.address().port;
const run = await fixture(t, { ARTCRAFTX_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/artcraftx"));
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: 20000 }, 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: 20000 }, 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: 20000 }, async (t) => {
const run = await fixture(t, { ARTCRAFTX_DEV_PORT: "65536" });
assert.deepEqual(await run.exited, [1, null]);
assert.match(run.output(), /ARTCRAFTX_DEV_PORT must be an integer/);
});

41
script/unix_dev.sh Executable file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Start the ArtCraftX frontend and Tauri app 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/unix_dev.sh
Starts Vite with JavaScript/CSS live reload and Tauri with Rust rebuild/restart.
Searches for a free loopback port starting at ARTCRAFTX_DEV_PORT (default: 5183).
Rust uses Tauri IPC; it 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 if node_modules is missing.
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
npm install
fi
exec node "${frontend_path}/scripts/unix-dev.mjs"