Skip to content

Commit 8717330

Browse files
authored
feat(desktop): add Windows support with an NSIS installer and release job (#81)
## Related Issue No prior issue. The problem is described below. ## Problem `apps/desktop` shipped a macOS-only application. `build.win` used the `dir` target, which emits an unpacked directory and no installer, so Windows had nothing to distribute and `electron-updater` had no `latest.yml` feed to read. Three Windows defects also lived in the shell itself, each dead or wrong code rather than a missing feature: - `host-supervisor.ts` terminated the Host with `child.kill()`. Windows has no signal delivery, so that is `TerminateProcess` on one PID and the Host's own children — node-pty shells, subagent hosts — survived and kept holding the loopback port. - The packaged-runtime guard tested `nodeExecutable.includes('/')`, which is never true for `C:\...\Pythinker.exe`. The check could not fire on Windows. - No `app.setAppUserModelId`, so taskbar pinning did not survive an installer upgrade and toasts were attributed to a generated identity. Separately, `apps/desktop`'s 65 vitest cases and its typecheck script ran in no required check, so a desktop regression merged green. ## What changed **Windows packaging** — per-user NSIS installer (`oneClick: false`, `perMachine: false`, elevation allowed, installation directory selectable, desktop and Start Menu shortcuts), `dist:win`, and a `windows-latest` release job running in parallel with `mac`. `verify-win-installer.ts` sniffs the DOS and PE headers of both artifacts rather than trusting the file extension. `dist:win` refuses to run anywhere but native Windows x64. This is not caution: `stage-runtime.ts` runs `pnpm deploy` on the build machine, and the closure resolves platform-gated natives — a macOS-staged tree carries `@opentui/core-darwin-arm64` and cannot produce a working Windows build. **Windows runtime** — tree kill via `taskkill /T /F`, `isAbsolute()` for the artifact guard, `setAppUserModelId`, and an `afterPack` check for the win32 `node-pty` prebuilds. **Release workflow** — an unset GitHub secret interpolates to an empty string, not to an absent variable, and electron-builder resolves an empty `CSC_LINK` as a certificate path: `path.resolve(appDir, '')` is the app directory, so the macOS job died on `not a file`. Credentials are now exported only when they carry a value, which also fixed the pre-existing macOS failure. Windows artifacts are unsigned until `WIN_CSC_LINK` and `WIN_CSC_KEY_PASSWORD` are configured; the job passes them through, so enabling signing needs no code change. **Staging** — Node refuses to spawn a `.cmd` shim without a shell, so `pnpm.cmd` raised `EINVAL`. And pnpm joins its workspace root with the deploy target rather than resolving it, so an absolute path on another volume became `D:\repo\C:\Users\...`. The target is now workspace-relative, which is correct whether pnpm joins or resolves, and the staging directory moved onto the repository's own volume. **CI gates** — `apps/desktop` registered in the root vitest projects, plus a per-package typecheck step. Deliberately not added to the `tsgo` loop, which covers the source tsconfig and would silently skip `tests/tsconfig.json`. Out of scope, and left alone: the reference implementation's Windows ACL runner and pwsh sandbox trampoline (`packages/kaos` and `agent-core` carry their own win32 handling), Windows arm64, Linux packaging, and signing certificates. `apps/vscode` has the same CI-gate omission; that is pre-existing and belongs in its own change. ## How this was verified Windows packaging cannot be validated on a macOS workstation, so the proof is a green CI run, not a local claim. Run [31914773126](https://github.com/PyModel/pythinker-code/actions/runs/31914773126) — `mac: success`, `windows: success`: ``` • building target=nsis file=dist\Pythinker-0.1.0-x64-Setup.exe archs=x64 oneClick=false perMachine=false • building block map blockMapFile=dist\Pythinker-0.1.0-x64-Setup.exe.blockmap • uploading file=Pythinker-0.1.0-x64-Setup.exe provider=github ``` Artifacts: `desktop-windows` 134 MB, `desktop-macos` 329 MB, with `latest.yml` and a `.blockmap` so Windows auto-update downloads differentially. Three earlier runs failed and each fix is a separate commit, so the sequence is reviewable: `EINVAL` on `.cmd`, then empty signing credentials, then the pnpm deploy target. Locally, every added test was mutation-proved — the guard was reverted, the test was watched go red, then restored and watched go green. That includes the new CI gate itself: breaking a desktop test now fails root `pnpm run test` with `FAIL |@pymodel/pythinker-desktop| tests/stage-runtime.spec.ts`, which it could not do before this change. Full root suite: 682 files, 10184 passed, 71 skipped. Desktop typecheck and `pnpm run lint` both exit 0. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Windows x64 NSIS installer packaging with customizable installation location, shortcuts, and optional signing. * Added a Windows desktop release workflow and distribution command. * **Bug Fixes** * Improved Windows process-tree shutdown and packaged runtime validation. * Fixed Windows application identity and runtime staging behavior. * Added installer validation to catch invalid or incomplete release artifacts. * **Documentation** * Documented Windows packaging requirements, installer output, signing options, and current platform limitations. * **Tests** * Expanded automated coverage for Windows packaging, staging, runtime checks, and process termination. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 17818ea commit 8717330

22 files changed

Lines changed: 612 additions & 23 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-desktop': patch
3+
---
4+
5+
Bound the Windows process-tree kill so a stalled taskkill cannot freeze desktop shutdown
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-desktop': patch
3+
---
4+
5+
Fix Windows runtime staging and skip empty signing credentials in the desktop release workflow
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-desktop': patch
3+
---
4+
5+
Stage the desktop Host closure inside the workspace so pnpm deploy resolves the target on Windows
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-desktop': patch
3+
---
4+
5+
Add the Windows NSIS installer target, release script, and release workflow job
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-desktop': patch
3+
---
4+
5+
Fix Windows process-tree shutdown, packaged-runtime guards, and taskbar identity in the desktop app

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,5 @@ jobs:
8888
run: pnpm --filter @pymodel/dashboard-server run typecheck
8989
- name: Typecheck dashboard-web
9090
run: pnpm --filter @pymodel/dashboard-web run typecheck
91+
- name: Typecheck desktop
92+
run: pnpm --filter @pymodel/pythinker-desktop run typecheck

.github/workflows/desktop-release.yml

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,33 @@ jobs:
5151
# Without Developer ID signing secrets, electron-builder publishes an
5252
# ad-hoc/self-signed app. macOS auto-update will not accept unsigned updates,
5353
# but this still proves packaging and the feed shape.
54+
# An unset GitHub secret interpolates to an empty string, and
55+
# electron-builder resolves an empty CSC_LINK as a certificate path
56+
# (path.resolve(appDir, '') === appDir), failing with "not a file".
57+
# Export only the variables that carry a value.
58+
- name: Resolve macOS signing credentials
59+
shell: bash
60+
env:
61+
IN_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
62+
IN_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
63+
IN_APPLE_ID: ${{ secrets.APPLE_ID }}
64+
IN_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
65+
IN_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
66+
run: |
67+
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
68+
input="IN_${name}"
69+
value="${!input:-}"
70+
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
71+
done
72+
if [ -z "${IN_CSC_LINK:-}" ]; then
73+
echo 'CSC_IDENTITY_AUTO_DISCOVERY=false' >> "$GITHUB_ENV"
74+
echo 'No macOS signing certificate configured; building unsigned.'
75+
fi
5476
- name: Package and publish desktop release
5577
working-directory: apps/desktop
5678
run: pnpm exec electron-builder --mac dmg zip --publish always
5779
env:
5880
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59-
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
60-
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
61-
APPLE_ID: ${{ secrets.APPLE_ID }}
62-
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
63-
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
6481

6582
- name: Upload macOS artifacts for manual runs
6683
if: github.event_name == 'workflow_dispatch'
@@ -72,3 +89,67 @@ jobs:
7289
apps/desktop/dist/*.zip
7390
apps/desktop/dist/latest-mac.yml
7491
if-no-files-found: error
92+
93+
windows:
94+
runs-on: windows-latest
95+
steps:
96+
- name: Checkout
97+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
98+
with:
99+
fetch-depth: 0
100+
persist-credentials: true
101+
102+
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6
103+
104+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6
105+
with:
106+
node-version-file: .nvmrc
107+
cache: pnpm
108+
109+
- run: pnpm install --frozen-lockfile
110+
111+
- name: Stamp desktop version for tag builds
112+
if: startsWith(github.ref, 'refs/tags/desktop-v')
113+
shell: bash
114+
env:
115+
TAG_NAME: ${{ github.ref_name }}
116+
run: |
117+
export DESKTOP_VERSION="${TAG_NAME#desktop-v}"
118+
node -e 'const fs = require("node:fs"); const path = "apps/desktop/package.json"; const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); packageJson.version = process.env.DESKTOP_VERSION; fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);'
119+
120+
- name: Build workspace
121+
run: pnpm --workspace-root run build
122+
123+
- name: Stage desktop runtime
124+
working-directory: apps/desktop
125+
run: node --import tsx scripts/stage-runtime.ts
126+
127+
# Only non-empty WIN_CSC_* signing secrets are exported. Without them,
128+
# Windows artifacts are unsigned and installers trigger a SmartScreen
129+
# warning on first run.
130+
- name: Resolve Windows signing credentials
131+
shell: bash
132+
env:
133+
IN_WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
134+
IN_WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
135+
run: |
136+
for name in WIN_CSC_LINK WIN_CSC_KEY_PASSWORD; do
137+
input="IN_${name}"
138+
value="${!input:-}"
139+
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
140+
done
141+
- name: Package and publish desktop release
142+
working-directory: apps/desktop
143+
run: pnpm exec electron-builder --win nsis --x64 --publish always
144+
env:
145+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
146+
147+
- name: Upload Windows artifacts for manual runs
148+
if: github.event_name == 'workflow_dispatch'
149+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7
150+
with:
151+
name: desktop-windows
152+
path: |
153+
apps/desktop/dist/*.exe
154+
apps/desktop/dist/latest.yml
155+
if-no-files-found: error

apps/desktop/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,15 @@ hdiutil detach "$MOUNT_POINT"
6464
rmdir "$MOUNT_POINT"
6565
```
6666

67+
### Windows
68+
69+
Run `pnpm run dist:win` on a native Windows x64 host; cross-building from macOS is not possible because the staged Host closure contains platform-gated native packages. The output is `dist/Pythinker-<version>-x64-Setup.exe`, an assisted NSIS installer that defaults to a per-user install, offers a per-machine option that requires elevation, and lets you select the installation directory. Artifacts are unsigned unless `WIN_CSC_LINK` and `WIN_CSC_KEY_PASSWORD` are set.
70+
6771
## Known limitations
6872

6973
The first desktop assembly uses a loopback HTTP Host. The renderer and Host protocol remain unchanged so the application can replace the transport with the IPC carrier reserved by the GUI architecture without changing product features.
7074

71-
The signed installer path currently targets macOS. Windows and Linux packaging creates unpacked applications; their installer formats and distribution signing remain release work.
75+
The signed installer path currently targets macOS. Linux packaging creates an unpacked application; its installer format and distribution signing remain release work.
7276

7377
## Model Experience
7478

apps/desktop/package.json

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"dev": "pnpm -C ../pythinker-code run build && tsc -p tsconfig.json && tsdown && electron .",
1212
"package": "pnpm --workspace-root run build && node --import tsx scripts/stage-runtime.ts && electron-builder --dir",
1313
"dist": "pnpm --workspace-root run build && node --import tsx scripts/stage-runtime.ts && electron-builder",
14-
"dist:mac": "node --import tsx scripts/release-mac.ts"
14+
"dist:mac": "node --import tsx scripts/release-mac.ts",
15+
"dist:win": "node --import tsx scripts/release-win.ts"
1516
},
1617
"license": "MIT",
1718
"devDependencies": {
@@ -67,9 +68,24 @@
6768
"win": {
6869
"icon": "build/icon.png",
6970
"target": [
70-
"dir"
71+
{
72+
"target": "nsis",
73+
"arch": [
74+
"x64"
75+
]
76+
}
7177
]
7278
},
79+
"nsis": {
80+
"allowElevation": true,
81+
"allowToChangeInstallationDirectory": true,
82+
"artifactName": "Pythinker-${version}-${arch}-Setup.${ext}",
83+
"createDesktopShortcut": true,
84+
"createStartMenuShortcut": true,
85+
"oneClick": false,
86+
"perMachine": false,
87+
"shortcutName": "Pythinker"
88+
},
7389
"linux": {
7490
"category": "Development",
7591
"target": [
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/** Build the Windows NSIS installer from a native Windows host. */
2+
3+
import { spawnSync } from 'node:child_process'
4+
import { dirname, resolve } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { verifyWindowsInstaller } from './verify-win-installer'
7+
8+
function run(command: string, args: readonly string[], cwd: string): void {
9+
const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
10+
if (result.error !== undefined) throw result.error
11+
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
12+
}
13+
14+
/** Build and verify the unsigned Windows installer. */
15+
export function releaseWin(): void {
16+
if (process.platform !== 'win32') {
17+
throw new Error('The Windows installer must be built on Windows: the staged Host closure contains platform-specific native packages')
18+
}
19+
if (process.arch !== 'x64') {
20+
throw new Error(`The Windows installer targets x64; this host is ${process.arch}`)
21+
}
22+
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
23+
run('pnpm', ['--workspace-root', 'run', 'build'], desktopRoot)
24+
run('node', ['--import', 'tsx', 'scripts/stage-runtime.ts'], desktopRoot)
25+
run('pnpm', ['exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', 'never'], desktopRoot)
26+
verifyWindowsInstaller(desktopRoot)
27+
}
28+
29+
const invokedPath = process.argv[1]
30+
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
31+
try {
32+
releaseWin()
33+
} catch (error) {
34+
console.error(error instanceof Error ? error.message : String(error))
35+
process.exitCode = 1
36+
}
37+
}

0 commit comments

Comments
 (0)