Skip to content

Commit f43acbc

Browse files
heqikaicursoragent
andcommitted
feat(desktop): add Windows NSIS installer and unified GitHub Releases
Extend desktop staging and Tauri server spawn for Windows, build x64 setup.exe in CI, and publish macOS dmg + Windows installer under the same codedelta-desktop-v* tag from tauri.conf.json version. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 763c058 commit f43acbc

9 files changed

Lines changed: 341 additions & 105 deletions

File tree

.github/workflows/desktop-macos.yml

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
- 'apps/web/**'
1010
- 'packages/**'
1111
- 'scripts/desktop-stage.mjs'
12+
- 'scripts/desktop-publish-release.mjs'
1213
- 'src/**'
1314

1415
permissions:
@@ -35,7 +36,7 @@ jobs:
3536
run: npm run stage:desktop
3637

3738
- name: Build desktop app
38-
run: npm run build:app -w @codedelta/desktop
39+
run: npm run build:app -w @codedelta/desktop -- --bundles dmg
3940

4041
- name: Upload dmg artifact
4142
uses: actions/upload-artifact@v4
@@ -51,25 +52,7 @@ jobs:
5152
GH_TOKEN: ${{ github.token }}
5253
run: |
5354
set -euo pipefail
54-
VERSION=$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version")
55-
TAG="codedelta-desktop-v${VERSION}"
5655
DMG=$(ls apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg)
57-
SHA_SHORT=$(git rev-parse --short HEAD)
58-
NOTES=$(cat <<EOF
59-
CodeDelta macOS desktop app (unsigned, Apple Silicon arm64).
60-
61-
Bundled: web UI, API server, CodeGraph runtime, and Node 22 — no separate Node install required.
62-
63-
- Built from commit \`${SHA_SHORT}\`
64-
- Requires **git** on \`PATH\`
65-
- If macOS blocks launch: right-click → **Open**, or \`xattr -cr /Applications/CodeDelta.app\`
66-
EOF
67-
)
68-
if gh release view "$TAG" >/dev/null 2>&1; then
69-
gh release upload "$TAG" "$DMG" --clobber
70-
gh release edit "$TAG" --notes "$NOTES"
71-
else
72-
gh release create "$TAG" "$DMG" --title "CodeDelta Desktop v${VERSION}" --notes "$NOTES"
73-
fi
56+
node scripts/desktop-publish-release.mjs macos "$DMG"
7457
7558
# Optional: add APPLE_CERTIFICATE / notarytool steps when signing secrets are configured.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Desktop (Windows)
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches: [main]
7+
paths:
8+
- 'apps/desktop/**'
9+
- 'apps/web/**'
10+
- 'packages/**'
11+
- 'scripts/desktop-stage.mjs'
12+
- 'scripts/desktop-publish-release.mjs'
13+
- 'src/**'
14+
15+
permissions:
16+
contents: write
17+
18+
jobs:
19+
build-windows:
20+
runs-on: windows-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- uses: actions/setup-node@v4
25+
with:
26+
node-version: '22'
27+
cache: npm
28+
29+
- name: Install Rust
30+
uses: dtolnay/rust-toolchain@1.88.0
31+
32+
- name: Install NSIS
33+
run: choco install nsis -y
34+
35+
- name: Install dependencies
36+
run: npm ci
37+
38+
- name: Stage desktop runtime
39+
run: npm run stage:desktop
40+
41+
- name: Build desktop app
42+
run: npm run build:app -w @codedelta/desktop -- --bundles nsis
43+
44+
- name: Upload Windows installer artifact
45+
uses: actions/upload-artifact@v4
46+
with:
47+
name: codedelta-windows-exe
48+
path: |
49+
apps/desktop/src-tauri/target/release/bundle/nsis/*.exe
50+
if-no-files-found: error
51+
52+
- name: Publish installer to GitHub Release
53+
if: github.ref == 'refs/heads/main'
54+
shell: bash
55+
env:
56+
GH_TOKEN: ${{ github.token }}
57+
run: |
58+
set -euo pipefail
59+
EXE=$(ls apps/desktop/src-tauri/target/release/bundle/nsis/*.exe)
60+
node scripts/desktop-publish-release.mjs windows "$EXE"

README.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -229,24 +229,31 @@ Roadmap and deferred work: [docs/codedelta/ROADMAP.md](docs/codedelta/ROADMAP.md
229229
- Panorama export is a simplified card layout (no live *Expand* buttons); prefer **SVG** for zoom/clarity
230230
- Symbol click opens **file** diff, not symbol-to-hunk mapping
231231

232-
## Desktop (macOS)
232+
## Desktop (macOS & Windows)
233233

234-
CodeDelta ships a **macOS desktop app** ([`apps/desktop/`](apps/desktop/)) — a Tauri 2 shell that bundles Node 22 (for CodeGraph’s `node:sqlite`) and the API server. End users do not need a separate Node install.
234+
CodeDelta ships **desktop apps** ([`apps/desktop/`](apps/desktop/)) — Tauri 2 shells that bundle Node 22 (for CodeGraph’s `node:sqlite`) and the API server. End users do not need a separate Node install.
235235

236-
### Download (Apple Silicon)
236+
**Version** is read from `apps/desktop/src-tauri/tauri.conf.json` (currently `0.1.0`). macOS and Windows installers publish to the same GitHub Release: `codedelta-desktop-v0.1.0`.
237237

238-
Pre-built **unsigned** `.dmg` (arm64 / M1–M4):
238+
### Download
239239

240-
- [GitHub Releases](https://github.com/ingeniousfrog/CodeDelta/releases/tag/codedelta-desktop-v0.1.0)`CodeDelta_0.1.0_aarch64.dmg` (auto-updated on each `main` desktop CI build)
241-
- [百度网盘](https://pan.baidu.com/s/1FQxOgNHyvU1Y5EB34RpogQ?pwd=frog) · 提取码: `frog` (mirror)
240+
| Platform | File | Notes |
241+
|----------|------|-------|
242+
| **macOS** (Apple Silicon) | [GitHub Releases](https://github.com/ingeniousfrog/CodeDelta/releases/tag/codedelta-desktop-v0.1.0)`CodeDelta_*_aarch64.dmg` | Unsigned; right-click → Open if blocked |
243+
| **Windows** (x64) | Same release → `CodeDelta_*_x64-setup.exe` | NSIS installer |
244+
| macOS mirror | [百度网盘](https://pan.baidu.com/s/1FQxOgNHyvU1Y5EB34RpogQ?pwd=frog) · 提取码: `frog` | |
242245

243-
Install: open the dmg → drag **CodeDelta** to Applications. If macOS blocks launch, right-click the app → **Open**, or run `xattr -cr /Applications/CodeDelta.app` (common after Baidu Netdisk download). Requires **git** on `PATH`.
246+
**Install (macOS):** open the dmg → drag **CodeDelta** to Applications. If blocked: `xattr -cr /Applications/CodeDelta.app`
244247

245-
**Runtime data:** `~/Library/Application Support/CodeDelta` (repos, snapshots, settings).
248+
**Install (Windows):** run the setup `.exe` and follow the wizard.
249+
250+
Requires **git** on `PATH` on both platforms.
251+
252+
**Runtime data:** `~/Library/Application Support/CodeDelta` (macOS) · `%APPDATA%\CodeDelta` (Windows)
246253

247254
### Build from source
248255

249-
**Requirements:** macOS (arm64 or x64), [Xcode Command Line Tools](https://developer.apple.com/xcode/resources/), [Rust 1.88+](https://rustup.rs/) via `rustup` (Homebrew `cargo` alone may be too old), and repo dev dependencies (`npm ci`).
256+
**Requirements:** target OS (macOS or Windows), [Rust 1.88+](https://rustup.rs/), repo dev dependencies (`npm ci`). macOS also needs Xcode Command Line Tools; Windows needs [NSIS](https://nsis.sourceforge.io/) for the installer.
250257

251258
```bash
252259
# One-time: stage embedded Node + server runtime (~200MB under apps/desktop/src-tauri/resources/runtime/)
@@ -259,7 +266,9 @@ npm run build:desktop
259266
npm run dev:desktop
260267
```
261268

262-
Output: `apps/desktop/src-tauri/target/release/bundle/dmg/CodeDelta_*_aarch64.dmg` (or copy to `release/` manually if `bundle_dmg.sh` fails).
269+
Output:
270+
- macOS: `apps/desktop/src-tauri/target/release/bundle/dmg/CodeDelta_*_aarch64.dmg`
271+
- Windows: `apps/desktop/src-tauri/target/release/bundle/nsis/CodeDelta_*_x64-setup.exe`
263272

264273
**`apps/desktop/` layout:**
265274

apps/desktop/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
"name": "@codedelta/desktop",
33
"version": "0.1.0",
44
"private": true,
5-
"description": "CodeDelta desktop shell (macOS, Tauri 2)",
5+
"description": "CodeDelta desktop shell (Tauri 2, macOS + Windows)",
66
"scripts": {
7-
"dev": "PATH=\"$HOME/.cargo/bin:$PATH\" tauri dev",
8-
"build:app": "PATH=\"$HOME/.cargo/bin:$PATH\" tauri build"
7+
"dev": "tauri dev",
8+
"build:app": "tauri build"
99
},
1010
"devDependencies": {
1111
"@tauri-apps/cli": "^2.5.0"

apps/desktop/src-tauri/src/server.rs

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,60 @@ pub fn api_base_url() -> String {
1414
}
1515

1616
fn health_ok() -> bool {
17-
let output = Command::new("curl")
18-
.args([
19-
"-sf",
20-
&format!("{}/api/health", api_base_url()),
21-
])
22-
.output();
23-
matches!(output, Ok(o) if o.status.success())
17+
#[cfg(windows)]
18+
{
19+
let script = format!(
20+
"try {{ (Invoke-WebRequest -Uri '{}/api/health' -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200 }} catch {{ $false }}",
21+
api_base_url()
22+
);
23+
let output = Command::new("powershell")
24+
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
25+
.output();
26+
return matches!(output, Ok(o) if o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "True");
27+
}
28+
29+
#[cfg(not(windows))]
30+
{
31+
let output = Command::new("curl")
32+
.args(["-sf", &format!("{}/api/health", api_base_url())])
33+
.output();
34+
matches!(output, Ok(o) if o.status.success())
35+
}
2436
}
2537

2638
fn cache_dir() -> Result<PathBuf, String> {
27-
let home = dirs::home_dir().ok_or("Could not resolve home directory")?;
28-
Ok(home.join("Library/Application Support/CodeDelta"))
39+
let base = dirs::data_dir()
40+
.or_else(dirs::home_dir)
41+
.ok_or("Could not resolve application data directory")?;
42+
Ok(base.join("CodeDelta"))
43+
}
44+
45+
fn bundled_node_path(runtime: &PathBuf) -> PathBuf {
46+
#[cfg(windows)]
47+
{
48+
runtime.join("node/node.exe")
49+
}
50+
#[cfg(not(windows))]
51+
{
52+
runtime.join("node/bin/node")
53+
}
54+
}
55+
56+
fn runtime_has_node(runtime: &PathBuf) -> bool {
57+
bundled_node_path(runtime).exists()
2958
}
3059

3160
fn resolve_runtime_dir(app: &AppHandle) -> Result<PathBuf, String> {
3261
let resource = app
3362
.path()
3463
.resource_dir()
3564
.map_err(|e| e.to_string())?;
36-
// Tauri copies `resources/runtime/` → `$RESOURCE/resources/runtime/`.
3765
let candidates = [
3866
resource.join("resources/runtime"),
3967
resource.join("runtime"),
4068
];
4169
for staged in candidates {
42-
if staged.join("node/bin/node").exists() {
70+
if runtime_has_node(&staged) {
4371
return Ok(staged);
4472
}
4573
}
@@ -48,7 +76,7 @@ fn resolve_runtime_dir(app: &AppHandle) -> Result<PathBuf, String> {
4876
{
4977
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
5078
let dev_staged = manifest.join("resources/runtime");
51-
if dev_staged.join("node/bin/node").exists() {
79+
if runtime_has_node(&dev_staged) {
5280
return Ok(dev_staged);
5381
}
5482
}
@@ -77,7 +105,7 @@ fn resolve_server_entry(runtime: &PathBuf) -> Result<PathBuf, String> {
77105

78106
fn spawn_bundled(app: &AppHandle) -> Result<Child, String> {
79107
let runtime = resolve_runtime_dir(app)?;
80-
let node = runtime.join("node/bin/node");
108+
let node = bundled_node_path(&runtime);
81109
let server_js = resolve_server_entry(&runtime)?;
82110

83111
let web_dist = runtime.join("web-dist");
@@ -144,18 +172,45 @@ pub fn stop(_app: &AppHandle) {
144172
}
145173

146174
pub fn port_in_use_hint() -> Option<String> {
147-
let output = Command::new("lsof")
148-
.args(["-i", &format!(":{API_PORT}")])
149-
.output()
150-
.ok()?;
151-
if !output.status.success() {
152-
return None;
175+
#[cfg(windows)]
176+
{
177+
let output = Command::new("netstat")
178+
.args(["-ano"])
179+
.output()
180+
.ok()?;
181+
if !output.status.success() {
182+
return None;
183+
}
184+
let text = String::from_utf8_lossy(&output.stdout);
185+
let needle = format!(":{API_PORT}");
186+
let lines: Vec<&str> = text
187+
.lines()
188+
.filter(|l| l.contains(&needle))
189+
.collect();
190+
if lines.is_empty() {
191+
return None;
192+
}
193+
return Some(format!(
194+
"Port {API_PORT} is already in use. Quit the other CodeDelta or dev server and try again.\n{}",
195+
lines.join("\n")
196+
));
153197
}
154-
let text = String::from_utf8_lossy(&output.stdout);
155-
if text.trim().is_empty() {
156-
return None;
198+
199+
#[cfg(not(windows))]
200+
{
201+
let output = Command::new("lsof")
202+
.args(["-i", &format!(":{API_PORT}")])
203+
.output()
204+
.ok()?;
205+
if !output.status.success() {
206+
return None;
207+
}
208+
let text = String::from_utf8_lossy(&output.stdout);
209+
if text.trim().is_empty() {
210+
return None;
211+
}
212+
Some(format!(
213+
"Port {API_PORT} is already in use. Quit the other CodeDelta or dev server and try again.\n{text}"
214+
))
157215
}
158-
Some(format!(
159-
"Port {API_PORT} is already in use. Quit the other CodeDelta or dev server and try again.\n{text}"
160-
))
161216
}

apps/desktop/src-tauri/tauri.conf.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,22 @@
2828
},
2929
"bundle": {
3030
"active": true,
31-
"targets": ["dmg"],
31+
"targets": ["dmg", "nsis"],
3232
"icon": [
3333
"icons/32x32.png",
3434
"icons/128x128.png",
3535
"icons/128x128@2x.png",
36-
"icons/icon.icns"
36+
"icons/icon.icns",
37+
"icons/icon.ico"
3738
],
3839
"resources": ["resources/runtime/"],
3940
"macOS": {
4041
"minimumSystemVersion": "12.0"
42+
},
43+
"windows": {
44+
"nsis": {
45+
"installMode": "currentUser"
46+
}
4147
}
4248
}
4349
}

docs/codedelta/ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ Not implemented yet (planned):
131131
- [x] Import page recent repositories
132132
- [x] `npm run dev:desktop` / `stage:desktop` / `build:desktop`
133133
- [ ] Apple code signing + notarization for public distribution
134-
- [ ] Windows / Linux desktop
134+
- [x] Windows desktop (x64 NSIS installer, CI + GitHub Releases)
135+
- [ ] Linux desktop
135136
- [ ] Auto-update, menu bar quick open, drag-and-drop import
136137

137138
## Architecture snapshot

0 commit comments

Comments
 (0)