Skip to content

Commit a62376e

Browse files
authored
Merge branch 'main' into changeset-release/main
2 parents bc040af + 8717330 commit a62376e

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)