Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-taskkill-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-desktop': patch
---

Bound the Windows process-tree kill so a stalled taskkill cannot freeze desktop shutdown
5 changes: 5 additions & 0 deletions .changeset/desktop-windows-ci-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-desktop': patch
---

Fix Windows runtime staging and skip empty signing credentials in the desktop release workflow
5 changes: 5 additions & 0 deletions .changeset/desktop-windows-deploy-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-desktop': patch
---

Stage the desktop Host closure inside the workspace so pnpm deploy resolves the target on Windows
5 changes: 5 additions & 0 deletions .changeset/desktop-windows-packaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-desktop': patch
---

Add the Windows NSIS installer target, release script, and release workflow job
5 changes: 5 additions & 0 deletions .changeset/desktop-windows-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-desktop': patch
---

Fix Windows process-tree shutdown, packaged-runtime guards, and taskbar identity in the desktop app
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,5 @@ jobs:
run: pnpm --filter @pymodel/dashboard-server run typecheck
- name: Typecheck dashboard-web
run: pnpm --filter @pymodel/dashboard-web run typecheck
- name: Typecheck desktop
run: pnpm --filter @pymodel/pythinker-desktop run typecheck
91 changes: 86 additions & 5 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,33 @@ jobs:
# Without Developer ID signing secrets, electron-builder publishes an
# ad-hoc/self-signed app. macOS auto-update will not accept unsigned updates,
# but this still proves packaging and the feed shape.
# An unset GitHub secret interpolates to an empty string, and
# electron-builder resolves an empty CSC_LINK as a certificate path
# (path.resolve(appDir, '') === appDir), failing with "not a file".
# Export only the variables that carry a value.
- name: Resolve macOS signing credentials
shell: bash
env:
IN_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
IN_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
IN_APPLE_ID: ${{ secrets.APPLE_ID }}
IN_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
IN_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
input="IN_${name}"
value="${!input:-}"
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
done
if [ -z "${IN_CSC_LINK:-}" ]; then
echo 'CSC_IDENTITY_AUTO_DISCOVERY=false' >> "$GITHUB_ENV"
echo 'No macOS signing certificate configured; building unsigned.'
fi
- name: Package and publish desktop release
working-directory: apps/desktop
run: pnpm exec electron-builder --mac dmg zip --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

- name: Upload macOS artifacts for manual runs
if: github.event_name == 'workflow_dispatch'
Expand All @@ -72,3 +89,67 @@ jobs:
apps/desktop/dist/*.zip
apps/desktop/dist/latest-mac.yml
if-no-files-found: error

windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
with:
fetch-depth: 0
persist-credentials: true

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6

- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6
with:
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Stamp desktop version for tag builds
if: startsWith(github.ref, 'refs/tags/desktop-v')
shell: bash
env:
TAG_NAME: ${{ github.ref_name }}
run: |
export DESKTOP_VERSION="${TAG_NAME#desktop-v}"
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`);'

- name: Build workspace
run: pnpm --workspace-root run build

- name: Stage desktop runtime
working-directory: apps/desktop
run: node --import tsx scripts/stage-runtime.ts

# Only non-empty WIN_CSC_* signing secrets are exported. Without them,
# Windows artifacts are unsigned and installers trigger a SmartScreen
# warning on first run.
- name: Resolve Windows signing credentials
shell: bash
env:
IN_WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
IN_WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
run: |
for name in WIN_CSC_LINK WIN_CSC_KEY_PASSWORD; do
input="IN_${name}"
value="${!input:-}"
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
done
- name: Package and publish desktop release
working-directory: apps/desktop
run: pnpm exec electron-builder --win nsis --x64 --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload Windows artifacts for manual runs
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7
with:
name: desktop-windows
path: |
apps/desktop/dist/*.exe
apps/desktop/dist/latest.yml
if-no-files-found: error
6 changes: 5 additions & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,15 @@ hdiutil detach "$MOUNT_POINT"
rmdir "$MOUNT_POINT"
```

### Windows

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.

## Known limitations

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.

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

## Model Experience

Expand Down
20 changes: 18 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"dev": "pnpm -C ../pythinker-code run build && tsc -p tsconfig.json && tsdown && electron .",
"package": "pnpm --workspace-root run build && node --import tsx scripts/stage-runtime.ts && electron-builder --dir",
"dist": "pnpm --workspace-root run build && node --import tsx scripts/stage-runtime.ts && electron-builder",
"dist:mac": "node --import tsx scripts/release-mac.ts"
"dist:mac": "node --import tsx scripts/release-mac.ts",
"dist:win": "node --import tsx scripts/release-win.ts"
},
"license": "MIT",
"devDependencies": {
Expand Down Expand Up @@ -67,9 +68,24 @@
"win": {
"icon": "build/icon.png",
"target": [
"dir"
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"artifactName": "Pythinker-${version}-${arch}-Setup.${ext}",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"oneClick": false,
"perMachine": false,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"shortcutName": "Pythinker"
},
"linux": {
"category": "Development",
"target": [
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/scripts/release-win.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** Build the Windows NSIS installer from a native Windows host. */

import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { verifyWindowsInstaller } from './verify-win-installer'

function run(command: string, args: readonly string[], cwd: string): void {
const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
}

/** Build and verify the unsigned Windows installer. */
export function releaseWin(): void {
if (process.platform !== 'win32') {
throw new Error('The Windows installer must be built on Windows: the staged Host closure contains platform-specific native packages')
}
if (process.arch !== 'x64') {
throw new Error(`The Windows installer targets x64; this host is ${process.arch}`)
}
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
run('pnpm', ['--workspace-root', 'run', 'build'], desktopRoot)
run('node', ['--import', 'tsx', 'scripts/stage-runtime.ts'], desktopRoot)
run('pnpm', ['exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', 'never'], desktopRoot)
verifyWindowsInstaller(desktopRoot)
}

const invokedPath = process.argv[1]
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
try {
releaseWin()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
68 changes: 61 additions & 7 deletions apps/desktop/scripts/stage-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'

const desktopRoot = resolve(import.meta.dirname, '..')
const repositoryRoot = resolve(desktopRoot, '../..')
Expand All @@ -13,10 +13,59 @@ const deployPackage = '@pymodel/pythinker-code'
const entry = join(staging, 'node_modules/@pymodel/pythinker-code/dist/launcher.mjs')
const frontend = join(staging, 'node_modules/@pymodel/pythinker-code/dist-web/index.html')
const workspaceState = join(repositoryRoot, 'node_modules/.pnpm-workspace-state-v1.json')
const stagingParent = join(repositoryRoot, 'node_modules', '.pythinker-desktop-staging')

/** Windows characters that make an argument unsafe to hand to `cmd.exe` unquoted. */
const WINDOWS_UNSAFE_ARGUMENT = /[\s"&()<>^|]/u

/**
* Decide how to invoke a package manager on one platform.
*
* Node refuses to spawn a `.cmd` or `.bat` shim without a shell, so Windows
* needs `shell: true`. With a shell, Node does not quote arguments, so any
* argument carrying whitespace or a `cmd.exe` metacharacter is quoted here.
* @param platform - The value of `process.platform`.
* @param command - The package-manager binary name.
* @param args - Arguments in their unquoted form.
* @returns The command, arguments and shell flag to pass to `spawn`.
*/
export function packageManagerInvocation(platform: string, command: string, args: readonly string[]): {
readonly command: string
readonly args: readonly string[]
readonly shell: boolean
} {
if (platform !== 'win32') return { command, args, shell: false }
return {
command,
args: args.map(argument => (WINDOWS_UNSAFE_ARGUMENT.test(argument) ? `"${argument}"` : argument)),
shell: true,
}
}

/**
* Express a deploy target the way pnpm accepts it.
*
* pnpm joins its workspace root with the deploy target rather than resolving
* it, so an absolute path on another volume produces a concatenated,
* non-existent directory such as `D:\repo\C:\Users\…`. A workspace-relative
* target is correct whether pnpm joins or resolves.
* @param workspaceRoot - The pnpm workspace root, and the child process's cwd.
* @param target - The absolute staging directory.
* @returns The target expressed relative to the workspace root.
*/
export function deployTargetArgument(workspaceRoot: string, target: string): string {
return relative(workspaceRoot, target)
}

async function run(command: string, args: readonly string[]): Promise<void> {
const invocation = packageManagerInvocation(process.platform, command, args)
await new Promise<void>((accept, reject) => {
const child = spawn(command, args, { cwd: repositoryRoot, env: { ...process.env, CI: 'true' }, stdio: 'inherit' })
const child = spawn(invocation.command, [...invocation.args], {
cwd: repositoryRoot,
env: { ...process.env, CI: 'true' },
stdio: 'inherit',
shell: invocation.shell,
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) accept()
Expand Down Expand Up @@ -60,9 +109,10 @@ async function materializeLinks(): Promise<void> {
async function deploy(target: string): Promise<void> {
const savedWorkspaceState = existsSync(workspaceState) ? await readFile(workspaceState) : undefined
try {
await run(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', [
await run('pnpm', [
'--config.verify-deps-before-run=false', '--filter', deployPackage, 'deploy', '--legacy', '--prod',
'--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', target,
'--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true',
deployTargetArgument(repositoryRoot, target),
])
} finally {
if (savedWorkspaceState === undefined) await rm(workspaceState, { force: true })
Expand All @@ -71,7 +121,8 @@ async function deploy(target: string): Promise<void> {
}

async function main(): Promise<void> {
const deployed = await mkdtemp(join(tmpdir(), 'pythinker-desktop-runtime-'))
await mkdir(stagingParent, { recursive: true })
const deployed = await mkdtemp(join(stagingParent, 'runtime-'))
try {
await deploy(deployed)
await rm(join(staging, 'node_modules'), { recursive: true, force: true })
Expand All @@ -96,4 +147,7 @@ async function main(): Promise<void> {
console.log(`desktop runtime staged at ${staging}`)
}

await main()
const invokedPath = process.argv[1]
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
await main()
}
10 changes: 10 additions & 0 deletions apps/desktop/scripts/verify-packaged-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ const REQUIRED_HOST_FILES = [
['@pymodel', 'pythinker-code', 'dist-web', 'index.html'],
] as const

const REQUIRED_WINDOWS_NODE_PTY_ENTRIES = [
['node-pty', 'prebuilds', 'win32-x64', 'pty.node'],
['node-pty', 'prebuilds', 'win32-x64', 'conpty.node'],
['node-pty', 'prebuilds', 'win32-x64', 'conpty_console_list.node'],
] as const

/**
* Verify the Host files required before the signed application can start.
* @param context - Electron Builder's completed application directory.
Expand All @@ -21,6 +27,10 @@ export async function afterPack(context: AfterPackContext): Promise<void> {
for (const segments of REQUIRED_HOST_FILES) {
await access(join(resources, 'host', 'node_modules', ...segments))
}
if (context.electronPlatformName !== 'win32') return
for (const segments of REQUIRED_WINDOWS_NODE_PTY_ENTRIES) {
await access(join(resources, 'host', 'node_modules', ...segments))
}
}

export default afterPack
36 changes: 36 additions & 0 deletions apps/desktop/scripts/verify-win-installer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** Reject a Windows release whose installer or unpacked shell is not a real PE binary. */

import { openSync, readSync, closeSync, statSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

const MINIMUM_PE_BYTES = 0x40 + 4

function assertPortableExecutable(path: string): void {
const stats = statSync(path)
if (!stats.isFile()) throw new Error(`Windows release artifact is not a regular file: ${path}`)
if (stats.size < MINIMUM_PE_BYTES) throw new Error(`Windows release artifact is too small to be a PE image: ${path}`)
const handle = openSync(path, 'r')
try {
const header = Buffer.alloc(0x40)
readSync(handle, header, 0, header.length, 0)
if (header.toString('latin1', 0, 2) !== 'MZ') throw new Error(`Windows release artifact has no DOS header: ${path}`)
const peOffset = header.readUInt32LE(0x3c)
if (peOffset + 4 > stats.size) throw new Error(`Windows release artifact has an out-of-range PE offset: ${path}`)
const signature = Buffer.alloc(4)
readSync(handle, signature, 0, 4, peOffset)
if (signature.toString('latin1') !== 'PE\0\0') throw new Error(`Windows release artifact has no PE signature: ${path}`)
} finally {
closeSync(handle)
}
}

/**
* Verify the Windows artifacts electron-builder must have produced.
* @param desktopRoot - The apps/desktop directory containing dist/.
*/
export function verifyWindowsInstaller(desktopRoot: string): void {
const { version } = JSON.parse(readFileSync(join(desktopRoot, 'package.json'), 'utf8')) as { version: string }
assertPortableExecutable(join(desktopRoot, 'dist', `Pythinker-${version}-x64-Setup.exe`))
assertPortableExecutable(join(desktopRoot, 'dist', 'win-unpacked', 'Pythinker.exe'))
console.log(`Windows release verified: Pythinker-${version}-x64-Setup.exe`)
}
Loading
Loading