Skip to content

Commit 70a22fc

Browse files
committed
feat(desktop): ship a Windows NSIS installer and release job
Replace the win "dir" target with a per-user NSIS installer that allows elevation and a selectable installation directory, so electron-updater has a latest.yml feed to read on Windows. Add dist:win, which refuses to run anywhere but a native Windows x64 host: the staged Host closure resolves platform-gated native packages at deploy time, so a macOS-staged tree cannot produce a working Windows build. A companion verifier sniffs the DOS and PE headers of both artifacts rather than trusting the file extension. Windows artifacts are unsigned until WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD are configured; the workflow passes them through so signing needs no code change.
1 parent 9dd2abc commit 70a22fc

9 files changed

Lines changed: 265 additions & 4 deletions

File tree

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

.github/workflows/desktop-release.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,57 @@ jobs:
7272
apps/desktop/dist/*.zip
7373
apps/desktop/dist/latest-mac.yml
7474
if-no-files-found: error
75+
76+
windows:
77+
runs-on: windows-latest
78+
steps:
79+
- name: Checkout
80+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
81+
with:
82+
fetch-depth: 0
83+
persist-credentials: true
84+
85+
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6
86+
87+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6
88+
with:
89+
node-version-file: .nvmrc
90+
cache: pnpm
91+
92+
- run: pnpm install --frozen-lockfile
93+
94+
- name: Stamp desktop version for tag builds
95+
if: startsWith(github.ref, 'refs/tags/desktop-v')
96+
shell: bash
97+
env:
98+
TAG_NAME: ${{ github.ref_name }}
99+
run: |
100+
export DESKTOP_VERSION="${TAG_NAME#desktop-v}"
101+
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`);'
102+
103+
- name: Build workspace
104+
run: pnpm --workspace-root run build
105+
106+
- name: Stage desktop runtime
107+
working-directory: apps/desktop
108+
run: node --import tsx scripts/stage-runtime.ts
109+
110+
# Without WIN_CSC_* signing secrets, Windows artifacts are unsigned and
111+
# installers trigger a SmartScreen warning on first run.
112+
- name: Package and publish desktop release
113+
working-directory: apps/desktop
114+
run: pnpm exec electron-builder --win nsis --x64 --publish always
115+
env:
116+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
117+
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
118+
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
119+
120+
- name: Upload Windows artifacts for manual runs
121+
if: github.event_name == 'workflow_dispatch'
122+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7
123+
with:
124+
name: desktop-windows
125+
path: |
126+
apps/desktop/dist/*.exe
127+
apps/desktop/dist/latest.yml
128+
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`, a per-user NSIS installer with no elevation required and a selectable 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+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/** Reject a Windows release whose installer or unpacked shell is not a real PE binary. */
2+
3+
import { openSync, readSync, closeSync, statSync, readFileSync } from 'node:fs'
4+
import { join } from 'node:path'
5+
6+
const MINIMUM_PE_BYTES = 0x40 + 4
7+
8+
function assertPortableExecutable(path: string): void {
9+
const stats = statSync(path)
10+
if (!stats.isFile()) throw new Error(`Windows release artifact is not a regular file: ${path}`)
11+
if (stats.size < MINIMUM_PE_BYTES) throw new Error(`Windows release artifact is too small to be a PE image: ${path}`)
12+
const handle = openSync(path, 'r')
13+
try {
14+
const header = Buffer.alloc(0x40)
15+
readSync(handle, header, 0, header.length, 0)
16+
if (header.toString('latin1', 0, 2) !== 'MZ') throw new Error(`Windows release artifact has no DOS header: ${path}`)
17+
const peOffset = header.readUInt32LE(0x3c)
18+
if (peOffset + 4 > stats.size) throw new Error(`Windows release artifact has an out-of-range PE offset: ${path}`)
19+
const signature = Buffer.alloc(4)
20+
readSync(handle, signature, 0, 4, peOffset)
21+
if (signature.toString('latin1') !== 'PE\0\0') throw new Error(`Windows release artifact has no PE signature: ${path}`)
22+
} finally {
23+
closeSync(handle)
24+
}
25+
}
26+
27+
/**
28+
* Verify the Windows artifacts electron-builder must have produced.
29+
* @param desktopRoot - The apps/desktop directory containing dist/.
30+
*/
31+
export function verifyWindowsInstaller(desktopRoot: string): void {
32+
const { version } = JSON.parse(readFileSync(join(desktopRoot, 'package.json'), 'utf8')) as { version: string }
33+
assertPortableExecutable(join(desktopRoot, 'dist', `Pythinker-${version}-x64-Setup.exe`))
34+
assertPortableExecutable(join(desktopRoot, 'dist', 'win-unpacked', 'Pythinker.exe'))
35+
console.log(`Windows release verified: Pythinker-${version}-x64-Setup.exe`)
36+
}

apps/desktop/tests/packaging-config.spec.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,24 @@ interface DesktopPackage {
1818
readonly icon: string
1919
readonly notarize: boolean
2020
}
21+
readonly nsis: {
22+
readonly allowElevation: boolean
23+
readonly allowToChangeInstallationDirectory: boolean
24+
readonly artifactName: string
25+
readonly createDesktopShortcut: boolean
26+
readonly createStartMenuShortcut: boolean
27+
readonly oneClick: boolean
28+
readonly perMachine: boolean
29+
readonly shortcutName: string
30+
}
2131
readonly productName: string
22-
readonly win: { readonly icon: string }
32+
readonly win: {
33+
readonly icon: string
34+
readonly target: readonly {
35+
readonly target: string
36+
readonly arch: readonly string[]
37+
}[]
38+
}
2339
}
2440
}
2541

@@ -79,9 +95,27 @@ describe('desktop packaging configuration', () => {
7995
expect(desktopPackage.build.mac.notarize).toBe(true)
8096
})
8197

98+
it('configures the Windows x64 NSIS installer', () => {
99+
expect(desktopPackage.build.win.target).toEqual([{ target: 'nsis', arch: ['x64'] }])
100+
expect(desktopPackage.build.nsis).toEqual({
101+
allowElevation: true,
102+
allowToChangeInstallationDirectory: true,
103+
artifactName: 'Pythinker-${version}-${arch}-Setup.${ext}',
104+
createDesktopShortcut: true,
105+
createStartMenuShortcut: true,
106+
oneClick: false,
107+
perMachine: false,
108+
shortcutName: 'Pythinker',
109+
})
110+
expect(desktopPackage.build.nsis.artifactName).toBe('Pythinker-${version}-${arch}-Setup.${ext}')
111+
expect(desktopPackage.build.productName).toBe('Pythinker')
112+
expect(desktopPackage.scripts['dist:win']).toBe('node --import tsx scripts/release-win.ts')
113+
})
114+
82115
it('exposes desktop commands at the repository root', () => {
83116
expect(rootPackage.scripts['dev:desktop']).toBe('pnpm -C apps/desktop run dev')
84117
expect(rootPackage.scripts['package:desktop']).toBe('pnpm -C apps/desktop run package')
85118
expect(rootPackage.scripts['dist:mac:desktop']).toBe('pnpm -C apps/desktop run dist:mac')
119+
expect(rootPackage.scripts['dist:win:desktop']).toBe('pnpm -C apps/desktop run dist:win')
86120
})
87121
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { describe, expect, it } from 'vitest'
5+
import { verifyWindowsInstaller } from '../scripts/verify-win-installer'
6+
7+
const VERSION = '9.9.9'
8+
9+
function portableExecutable(magic = 'MZ', signature = 'PE\0\0', offset = 0x80): Buffer {
10+
const image = Buffer.alloc(offset + signature.length)
11+
image.write(magic, 0, magic.length, 'latin1')
12+
image.writeUInt32LE(offset, 0x3c)
13+
image.write(signature, offset, signature.length, 'latin1')
14+
return image
15+
}
16+
17+
async function withFixture(callback: (root: string) => Promise<void>): Promise<void> {
18+
const root = await mkdtemp(join(tmpdir(), 'pythinker-win-installer-'))
19+
try {
20+
await callback(root)
21+
} finally {
22+
await rm(root, { force: true, recursive: true })
23+
}
24+
}
25+
26+
async function createFixture(root: string, installer?: Buffer): Promise<void> {
27+
const dist = join(root, 'dist')
28+
await mkdir(join(dist, 'win-unpacked'), { recursive: true })
29+
await writeFile(join(root, 'package.json'), JSON.stringify({ version: VERSION }))
30+
if (installer !== undefined) {
31+
await writeFile(join(dist, `Pythinker-${VERSION}-x64-Setup.exe`), installer)
32+
}
33+
await writeFile(join(dist, 'win-unpacked', 'Pythinker.exe'), portableExecutable())
34+
}
35+
36+
describe('Windows installer verification', () => {
37+
it('accepts the installer and unpacked shell when both are PE binaries', async () => {
38+
await withFixture(async (root) => {
39+
await createFixture(root, portableExecutable())
40+
expect(() => verifyWindowsInstaller(root)).not.toThrow()
41+
})
42+
})
43+
44+
it('rejects a missing installer', async () => {
45+
await withFixture(async (root) => {
46+
await createFixture(root)
47+
expect(() => verifyWindowsInstaller(root)).toThrow('ENOENT')
48+
})
49+
})
50+
51+
it('rejects an artifact without a DOS header', async () => {
52+
await withFixture(async (root) => {
53+
await createFixture(root, portableExecutable('ZZ'))
54+
expect(() => verifyWindowsInstaller(root)).toThrow(/no DOS header/)
55+
})
56+
})
57+
58+
it('rejects an artifact with a truncated PE offset', async () => {
59+
await withFixture(async (root) => {
60+
const truncated = Buffer.alloc(0x44)
61+
truncated.write('MZ', 0, 2, 'latin1')
62+
truncated.writeUInt32LE(0x80, 0x3c)
63+
await createFixture(root, truncated)
64+
expect(() => verifyWindowsInstaller(root)).toThrow(/out-of-range PE offset/)
65+
})
66+
})
67+
68+
it('rejects an artifact without a PE signature', async () => {
69+
await withFixture(async (root) => {
70+
await createFixture(root, portableExecutable('MZ', 'NE\0\0'))
71+
expect(() => verifyWindowsInstaller(root)).toThrow(/no PE signature/)
72+
})
73+
})
74+
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"dev:web": "pnpm -C apps/pythinker-web run dev",
1414
"package:desktop": "pnpm -C apps/desktop run package",
1515
"dist:mac:desktop": "pnpm -C apps/desktop run dist:mac",
16+
"dist:win:desktop": "pnpm -C apps/desktop run dist:win",
1617
"dev:server": "pnpm -C apps/pythinker-code run dev:server",
1718
"build:plugin-marketplace": "pnpm -C apps/pythinker-code run build:plugin-marketplace",
1819
"dashboard": "pnpm -C apps/dashboard run dev",

0 commit comments

Comments
 (0)