Skip to content

Commit 5184eab

Browse files
authored
Merge pull request #39 from unicef/feat/desktop-sidecar-bundling
Bundle API server as Tauri sidecar for desktop builds
2 parents 433040c + a583191 commit 5184eab

File tree

19 files changed

+2296
-11
lines changed

19 files changed

+2296
-11
lines changed

.github/workflows/release.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Release Desktop App
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
12+
jobs:
13+
build:
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
include:
18+
- platform: macos-latest # arm64 (Apple Silicon)
19+
- platform: macos-13 # x86_64 (Intel)
20+
- platform: windows-latest
21+
- platform: ubuntu-22.04
22+
23+
runs-on: ${{ matrix.platform }}
24+
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- uses: pnpm/action-setup@v4
29+
30+
- uses: actions/setup-node@v4
31+
with:
32+
node-version: 22
33+
cache: pnpm
34+
35+
- uses: dtolnay/rust-toolchain@stable
36+
37+
- uses: Swatinem/rust-cache@v2
38+
with:
39+
workspaces: apps/desktop/src-tauri -> target
40+
41+
- name: Install Linux dependencies
42+
if: matrix.platform == 'ubuntu-22.04'
43+
run: |
44+
sudo apt-get update
45+
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
46+
47+
- run: pnpm install --frozen-lockfile
48+
49+
# Build TypeScript workspace packages (needed before sidecar bundling)
50+
- run: pnpm build
51+
52+
# tauri-action runs beforeBuildCommand (studio build + sidecar compile),
53+
# then `tauri build` to produce the installer.
54+
- uses: tauri-apps/tauri-action@v0
55+
env:
56+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
57+
with:
58+
projectPath: apps/desktop
59+
tagName: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
60+
releaseName: "ADT Studio ${{ github.ref_name }}"
61+
releaseBody: "Download the installer for your platform below."
62+
releaseDraft: true
63+
prerelease: false

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ Thumbs.db
3535
# Tauri build output
3636
**/src-tauri/target/
3737

38+
# Sidecar build artifacts
39+
apps/api/dist-pkg/
40+
apps/desktop/src-tauri/binaries/
41+
3842
# Vitest
3943
coverage/
4044

CLAUDE.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ ADT Studio is a desktop-first application for automated book production — extr
77
- **Monorepo**: pnpm workspaces
88
- **Backend**: Hono (HTTP server), node-sqlite3-wasm (pure WASM SQLite), Zod
99
- **Frontend**: React + Vite SPA, TanStack (Router, Query, Table, Form), Tailwind CSS
10-
- **Desktop**: Tauri or Electron (TBD)
10+
- **Desktop**: Tauri v2 (sidecar-based — API server compiled to standalone binary)
1111
- **Language**: TypeScript (strict mode)
1212
- **Testing**: Vitest
1313

@@ -33,7 +33,7 @@ packages/ # Shared libraries (@adt/* workspace packages)
3333
apps/ # Application tier
3434
api/ # Hono HTTP server
3535
studio/ # React SPA (Vite)
36-
desktop/ # Desktop wrapper (Tauri or Electron — TBD)
36+
desktop/ # Tauri v2 desktop wrapper
3737
3838
templates/ # Layout templates
3939
config/ # Global configuration
@@ -47,13 +47,38 @@ Frontend MUST NOT import from packages directly. All data flows through the API.
4747

4848
```bash
4949
pnpm install # Install dependencies
50-
pnpm dev # Run development servers
50+
pnpm dev # Run API + Studio dev servers
5151
pnpm test # Run tests
5252
pnpm typecheck # TypeScript strict check
5353
pnpm lint # Lint
5454
pnpm build # Build all packages
5555
```
5656

57+
### Desktop Development
58+
59+
Prerequisites: [Rust toolchain](https://rustup.rs/) and Tauri v2 CLI (`pnpm add -g @tauri-apps/cli`).
60+
61+
```bash
62+
# Dev mode — build sidecar once, then iterate on Rust/frontend
63+
pnpm --filter @adt/api build:sidecar # Compile API → standalone binary
64+
pnpm dev # Start API + Studio dev servers (terminal 1)
65+
pnpm dev:desktop # Start Tauri dev window (terminal 2)
66+
67+
# Production build — single command, outputs .app/.dmg/.msi
68+
pnpm build:desktop
69+
```
70+
71+
#### How the sidecar works
72+
73+
The API server is compiled into a standalone Node.js binary (`@yao-pkg/pkg`) and bundled inside the Tauri app as a **sidecar**. On launch, `lib.rs` spawns the sidecar, passing resource paths (prompts, config) via environment variables. The frontend detects the Tauri environment and routes API calls to `localhost:3001`.
74+
75+
Key files:
76+
- `apps/api/scripts/bundle.mjs` — esbuild bundle (JS + WASM assets)
77+
- `apps/api/scripts/pkg.mjs` — Compile bundle → standalone binary, copy to `desktop/src-tauri/binaries/`
78+
- `apps/desktop/src-tauri/src/lib.rs` — Sidecar spawn, env vars, lifecycle
79+
- `apps/desktop/src-tauri/tauri.conf.json``externalBin`, `resources` mapping
80+
- `apps/studio/src/api/client.ts` — Tauri base URL detection
81+
5782
## Key Rules
5883

5984
- All types defined as Zod schemas in `packages/types/`, infer TS types with `z.infer<>`

apps/api/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"dev": "tsx watch src/index.ts"
7+
"dev": "tsx watch src/index.ts",
8+
"bundle": "node scripts/bundle.mjs",
9+
"compile": "node scripts/pkg.mjs",
10+
"build:sidecar": "pnpm bundle && pnpm compile"
811
},
912
"dependencies": {
1013
"@adt/llm": "workspace:*",
@@ -16,5 +19,9 @@
1619
"hono": "^4.7.0",
1720
"fflate": "^0.8.2",
1821
"js-yaml": "^4.1.0"
22+
},
23+
"devDependencies": {
24+
"esbuild": "^0.25.0",
25+
"@yao-pkg/pkg": "^6.0.0"
1926
}
2027
}

apps/api/scripts/bundle.mjs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Bundle the API server into a single ESM file using esbuild.
3+
*
4+
* All JS code (including WASM-loader modules) is bundled. The .wasm binary
5+
* files are copied next to the output so that runtime fs.readFileSync()
6+
* and import.meta.url-based resolution finds them.
7+
*
8+
* An esbuild plugin rewrites `await import("...")` dynamic imports in mupdf
9+
* to synchronous `require("...")` calls. This is required because pkg (the
10+
* standalone-binary compiler) doesn't support dynamic import() in its runtime.
11+
*/
12+
import { build } from "esbuild"
13+
import fs from "node:fs"
14+
import path from "node:path"
15+
import { fileURLToPath } from "node:url"
16+
17+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
18+
const root = path.resolve(__dirname, "..")
19+
const monorepoRoot = path.resolve(root, "../..")
20+
const outDir = path.join(root, "dist-pkg")
21+
22+
/**
23+
* esbuild plugin: replace `await import("node:fs")` and `await import("module")`
24+
* with synchronous require() calls in mupdf source files. This eliminates
25+
* dynamic ESM imports that pkg's Node.js snapshot runtime can't handle.
26+
*/
27+
const replaceDynamicImports = {
28+
name: "replace-dynamic-imports",
29+
setup(build) {
30+
build.onLoad({ filter: /mupdf.*\.(js|mjs)$/ }, async (args) => {
31+
let source = await fs.promises.readFile(args.path, "utf-8")
32+
let modified = false
33+
34+
// Replace await import("node:fs") → require("node:fs")
35+
if (source.includes('await import("node:fs")')) {
36+
source = source.replace(
37+
/await import\("node:fs"\)/g,
38+
'require("node:fs")'
39+
)
40+
modified = true
41+
}
42+
43+
// Replace await import("module") → require("node:module")
44+
if (source.includes('await import("module")')) {
45+
source = source.replace(
46+
/await import\("module"\)/g,
47+
'require("node:module")'
48+
)
49+
modified = true
50+
}
51+
52+
if (!modified) return undefined // let esbuild handle it normally
53+
54+
return {
55+
contents: source,
56+
loader: args.path.endsWith(".mjs") ? "js" : "js",
57+
}
58+
})
59+
},
60+
}
61+
62+
await build({
63+
entryPoints: [path.join(root, "src/index.ts")],
64+
bundle: true,
65+
platform: "node",
66+
target: "node20",
67+
format: "esm",
68+
outfile: path.join(outDir, "api-server.mjs"),
69+
plugins: [replaceDynamicImports],
70+
banner: {
71+
js: [
72+
// Polyfill __dirname, __filename, and require for ESM
73+
// (needed by Emscripten-generated WASM loaders that use CJS patterns)
74+
'import { createRequire as __polyfill_createRequire } from "node:module";',
75+
'import { fileURLToPath as __polyfill_fileURLToPath } from "node:url";',
76+
'import { dirname as __polyfill_dirname } from "node:path";',
77+
"var __filename = __polyfill_fileURLToPath(import.meta.url);",
78+
"var __dirname = __polyfill_dirname(__filename);",
79+
"var require = __polyfill_createRequire(import.meta.url);",
80+
].join("\n"),
81+
},
82+
})
83+
84+
// Copy .wasm files next to the bundle so runtime loaders find them.
85+
// Search the pnpm store since these packages are transitive deps.
86+
const WASM_PACKAGES = ["node-sqlite3-wasm", "mupdf", "@resvg/resvg-wasm"]
87+
88+
fs.mkdirSync(outDir, { recursive: true })
89+
90+
for (const pkg of WASM_PACKAGES) {
91+
const pnpmDir = path.join(monorepoRoot, "node_modules/.pnpm")
92+
const safeName = pkg.replace(/\//g, "+").replace(/@/g, "")
93+
const dirs = fs.readdirSync(pnpmDir).filter((d) => {
94+
const normalized = d.replace(/@/g, "").replace(/\//g, "+")
95+
return normalized.startsWith(safeName)
96+
})
97+
98+
for (const dir of dirs) {
99+
const pkgPath = pkg.startsWith("@")
100+
? path.join(pnpmDir, dir, "node_modules", ...pkg.split("/"))
101+
: path.join(pnpmDir, dir, "node_modules", pkg)
102+
103+
if (!fs.existsSync(pkgPath)) continue
104+
105+
for (const sub of [".", "dist", "lib"]) {
106+
const searchDir = path.join(pkgPath, sub)
107+
if (!fs.existsSync(searchDir)) continue
108+
for (const file of fs.readdirSync(searchDir)) {
109+
if (file.endsWith(".wasm")) {
110+
fs.copyFileSync(path.join(searchDir, file), path.join(outDir, file))
111+
console.log(` Copied ${file}`)
112+
}
113+
}
114+
}
115+
}
116+
}
117+
118+
console.log("✓ Bundled → dist-pkg/api-server.mjs")

apps/api/scripts/pkg.mjs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Compile the esbuild bundle into a standalone Node.js binary using @yao-pkg/pkg,
3+
* then rename to Tauri's target-triple convention and copy to desktop binaries/.
4+
*
5+
* The .wasm files in dist-pkg/ (copied there by bundle.mjs) are included as
6+
* pkg assets so they're embedded in the binary alongside the JS.
7+
*/
8+
import { exec as execPkg } from "@yao-pkg/pkg"
9+
import { execSync } from "node:child_process"
10+
import fs from "node:fs"
11+
import path from "node:path"
12+
import { fileURLToPath } from "node:url"
13+
14+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
15+
const root = path.resolve(__dirname, "..")
16+
const distDir = path.join(root, "dist-pkg")
17+
const desktopBinDir = path.resolve(root, "../desktop/src-tauri/binaries")
18+
19+
// --- Detect host platform ---
20+
21+
const rustcOut = execSync("rustc -vV", { encoding: "utf-8" })
22+
const hostLine = rustcOut.match(/^host:\s*(.+)$/m)
23+
if (!hostLine) throw new Error("Could not detect Rust host target triple")
24+
const targetTriple = hostLine[1].trim()
25+
26+
const isWindows = targetTriple.includes("windows")
27+
const binaryName = `api-server-${targetTriple}${isWindows ? ".exe" : ""}`
28+
29+
// Map Rust target triple → pkg target string
30+
function rustTripleToPkgTarget(triple) {
31+
let arch = "x64"
32+
if (triple.includes("aarch64")) arch = "arm64"
33+
34+
let os = "linux"
35+
if (triple.includes("apple") || triple.includes("darwin")) os = "macos"
36+
else if (triple.includes("windows")) os = "win"
37+
38+
return `node20-${os}-${arch}`
39+
}
40+
41+
const pkgTarget = rustTripleToPkgTarget(targetTriple)
42+
43+
// --- Collect WASM assets from dist-pkg/ ---
44+
45+
const wasmFiles = fs
46+
.readdirSync(distDir)
47+
.filter((f) => f.endsWith(".wasm"))
48+
49+
console.log(`Target: ${pkgTarget} (${targetTriple})`)
50+
console.log(`WASM assets (${wasmFiles.length}):`)
51+
for (const f of wasmFiles) console.log(` ${f}`)
52+
53+
// --- Write temporary package.json for pkg (configures assets) ---
54+
55+
const input = path.join(distDir, "api-server.mjs")
56+
if (!fs.existsSync(input)) {
57+
throw new Error("dist-pkg/api-server.mjs not found — run `pnpm bundle` first")
58+
}
59+
60+
const pkgJson = {
61+
name: "api-server",
62+
bin: "api-server.mjs",
63+
pkg: {
64+
assets: wasmFiles.map((f) => f),
65+
},
66+
}
67+
68+
const pkgJsonPath = path.join(distDir, "package.json")
69+
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
70+
71+
// --- Run pkg ---
72+
73+
const pkgOutput = path.join(distDir, "api-server")
74+
75+
await execPkg([
76+
pkgJsonPath,
77+
"--target", pkgTarget,
78+
"--output", pkgOutput,
79+
"--compress", "GZip",
80+
])
81+
82+
// Clean up temp package.json
83+
fs.unlinkSync(pkgJsonPath)
84+
85+
// pkg outputs "api-server" (no suffix for single target)
86+
const outputFile = pkgOutput + (isWindows ? ".exe" : "")
87+
if (!fs.existsSync(outputFile)) {
88+
throw new Error(`Expected pkg output not found: ${outputFile}`)
89+
}
90+
91+
// --- Copy to Tauri binaries ---
92+
93+
fs.mkdirSync(desktopBinDir, { recursive: true })
94+
const dest = path.join(desktopBinDir, binaryName)
95+
fs.copyFileSync(outputFile, dest)
96+
fs.chmodSync(dest, 0o755)
97+
98+
console.log(`✓ Binary → ${dest}`)

apps/api/src/app.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ const pipelineService = createPipelineService(pipelineRunner)
2727
const app = new Hono()
2828

2929
app.use("*", logger())
30+
const ALLOWED_ORIGINS = [
31+
"http://localhost:5173", // Vite dev
32+
"tauri://localhost", // Tauri macOS
33+
"https://tauri.localhost", // Tauri Windows
34+
"http://tauri.localhost", // Tauri Linux
35+
]
36+
3037
app.use(
3138
"*",
3239
cors({
33-
origin: "http://localhost:5173",
40+
origin: ALLOWED_ORIGINS,
3441
})
3542
)
3643
app.onError(errorHandler)

0 commit comments

Comments
 (0)