Skip to content

Commit 31009aa

Browse files
authored
Merge branch 'main' into chore/remove-greptile
2 parents 082fe27 + 1cd8682 commit 31009aa

17 files changed

Lines changed: 412 additions & 15 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+
Sign the Windows installer through Azure Artifact Signing when the signing environment is configured
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Add a Windows download button to the site and point both desktop download buttons directly at the published installer assets.
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+
Render the Windows desktop window opaquely so the theme colours are not blended with the desktop wallpaper
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Reserve the Windows title-bar area so the window controls no longer overlap the chat header, paint the Windows sidebar solid, and change the VS Code extension display name to `Pythinker` because the previous name is reserved on the Marketplace.

.github/workflows/desktop-release.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,25 @@ jobs:
138138
value="${!input:-}"
139139
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
140140
done
141+
- name: Resolve Azure signing configuration
142+
shell: bash
143+
env:
144+
IN_AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
145+
IN_AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
146+
IN_AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
147+
IN_AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
148+
IN_AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
149+
IN_AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }}
150+
IN_AZURE_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_SIGNING_PUBLISHER_NAME }}
151+
run: |
152+
for name in AZURE_TENANT_ID AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_SIGNING_ENDPOINT AZURE_SIGNING_ACCOUNT AZURE_SIGNING_CERT_PROFILE AZURE_SIGNING_PUBLISHER_NAME; do
153+
input="IN_${name}"
154+
value="${!input:-}"
155+
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
156+
done
141157
- name: Package and publish desktop release
142158
working-directory: apps/desktop
143-
run: pnpm exec electron-builder --win nsis --x64 --publish always
159+
run: node --import tsx scripts/package-win.ts --publish always
144160
env:
145161
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
146162

apps/desktop/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ rmdir "$MOUNT_POINT"
6666

6767
### Windows
6868

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.
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. The existing certificate-file signing path uses `WIN_CSC_LINK` and `WIN_CSC_KEY_PASSWORD`.
70+
71+
#### Azure Artifact Signing
72+
73+
Windows artifacts are signed through Azure Artifact Signing when `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_CERT_PROFILE`, and `AZURE_SIGNING_PUBLISHER_NAME` are all set; they are unsigned when none are set. The credential variables are read from the environment; the four `AZURE_SIGNING_*` variables map to `azureSignOptions.endpoint`, `azureSignOptions.codeSigningAccountName`, `azureSignOptions.certificateProfileName`, and `azureSignOptions.publisherName`, respectively. Setting only some of the seven variables is a hard error by design.
7074

7175
## Known limitations
7276

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/** Package the Windows NSIS installer with optional Azure Artifact Signing. */
2+
3+
import { spawnSync } from 'node:child_process'
4+
import { dirname, resolve } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { packageManagerInvocation } from './stage-runtime'
7+
8+
function trimmedValue(value: string | undefined): string | undefined {
9+
const trimmed = value?.trim()
10+
return trimmed === '' ? undefined : trimmed
11+
}
12+
13+
/** Return Electron Builder overrides when all Azure signing settings are configured. */
14+
export function windowsSigningArgs(env: NodeJS.ProcessEnv): readonly string[] {
15+
const values: readonly (readonly [string, string | undefined])[] = [
16+
['AZURE_TENANT_ID', trimmedValue(env['AZURE_TENANT_ID'])],
17+
['AZURE_CLIENT_ID', trimmedValue(env['AZURE_CLIENT_ID'])],
18+
['AZURE_CLIENT_SECRET', trimmedValue(env['AZURE_CLIENT_SECRET'])],
19+
['AZURE_SIGNING_ENDPOINT', trimmedValue(env['AZURE_SIGNING_ENDPOINT'])],
20+
['AZURE_SIGNING_ACCOUNT', trimmedValue(env['AZURE_SIGNING_ACCOUNT'])],
21+
['AZURE_SIGNING_CERT_PROFILE', trimmedValue(env['AZURE_SIGNING_CERT_PROFILE'])],
22+
['AZURE_SIGNING_PUBLISHER_NAME', trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])],
23+
]
24+
const missing: string[] = []
25+
const args: string[] = []
26+
for (const [name, value] of values) {
27+
if (value === undefined) {
28+
missing.push(name)
29+
continue
30+
}
31+
32+
switch (name) {
33+
case 'AZURE_SIGNING_ENDPOINT':
34+
args.push('--config.win.azureSignOptions.endpoint', value)
35+
break
36+
case 'AZURE_SIGNING_ACCOUNT':
37+
args.push('--config.win.azureSignOptions.codeSigningAccountName', value)
38+
break
39+
case 'AZURE_SIGNING_CERT_PROFILE':
40+
args.push('--config.win.azureSignOptions.certificateProfileName', value)
41+
break
42+
case 'AZURE_SIGNING_PUBLISHER_NAME':
43+
args.push('--config.win.azureSignOptions.publisherName', value)
44+
break
45+
}
46+
}
47+
missing.sort()
48+
49+
if (missing.length === values.length) return []
50+
if (missing.length > 0) {
51+
throw new Error(
52+
`Windows signing is partially configured; missing: ${missing.join(', ')}. Set all seven signing variables or none.`,
53+
)
54+
}
55+
56+
return args
57+
}
58+
59+
/** Return the package-manager invocation for a Windows installer build. */
60+
export function windowsPackageInvocation(platform: string, env: NodeJS.ProcessEnv, publish: string): {
61+
readonly command: string
62+
readonly args: readonly string[]
63+
readonly shell: boolean
64+
} {
65+
const args = ['exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', publish, ...windowsSigningArgs(env)]
66+
return packageManagerInvocation(platform, 'pnpm', args)
67+
}
68+
69+
/** Package the Windows installer and optionally sign it through Azure Artifact Signing. */
70+
export function packageWin(options: { readonly publish: string }): void {
71+
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
72+
const invocation = windowsPackageInvocation(process.platform, process.env, options.publish)
73+
const result = spawnSync(invocation.command, invocation.args, { cwd: desktopRoot, stdio: 'inherit', shell: invocation.shell })
74+
if (result.error !== undefined) throw result.error
75+
if (result.status !== 0) throw new Error(`electron-builder exited with ${String(result.status)}`)
76+
}
77+
78+
const invokedPath = process.argv[1]
79+
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
80+
try {
81+
const publishIndex = process.argv.indexOf('--publish')
82+
packageWin({ publish: publishIndex === -1 ? 'never' : (process.argv[publishIndex + 1] ?? 'never') })
83+
} catch (error) {
84+
console.error(error instanceof Error ? error.message : String(error))
85+
process.exitCode = 1
86+
}
87+
}

apps/desktop/scripts/release-win.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { spawnSync } from 'node:child_process'
44
import { dirname, resolve } from 'node:path'
55
import { fileURLToPath } from 'node:url'
6+
import { packageWin } from './package-win'
67
import { verifyWindowsInstaller } from './verify-win-installer'
78

89
function run(command: string, args: readonly string[], cwd: string): void {
@@ -11,7 +12,7 @@ function run(command: string, args: readonly string[], cwd: string): void {
1112
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
1213
}
1314

14-
/** Build and verify the unsigned Windows installer. */
15+
/** Build and verify the Windows installer. */
1516
export function releaseWin(): void {
1617
if (process.platform !== 'win32') {
1718
throw new Error('The Windows installer must be built on Windows: the staged Host closure contains platform-specific native packages')
@@ -22,7 +23,7 @@ export function releaseWin(): void {
2223
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
2324
run('pnpm', ['--workspace-root', 'run', 'build'], desktopRoot)
2425
run('node', ['--import', 'tsx', 'scripts/stage-runtime.ts'], desktopRoot)
25-
run('pnpm', ['exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', 'never'], desktopRoot)
26+
packageWin({ publish: 'never' })
2627
verifyWindowsInstaller(desktopRoot)
2728
}
2829

apps/desktop/src/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,9 @@ async function createMainWindow(): Promise<BrowserWindow> {
201201
vibrancy: 'sidebar' as const,
202202
visualEffectState: 'followWindow' as const,
203203
} : {}),
204+
// Windows uses an opaque window so theme colors do not blend with desktop wallpaper.
204205
...(process.platform === 'win32' ? {
205-
backgroundMaterial: 'acrylic' as const,
206+
backgroundColor: '#0d1117',
206207
hasShadow: true,
207208
roundedCorners: true,
208209
thickFrame: true,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { windowsPackageInvocation, windowsSigningArgs } from '../scripts/package-win'
3+
4+
const signingEnvironment: NodeJS.ProcessEnv = {
5+
AZURE_TENANT_ID: 'tenant-id',
6+
AZURE_CLIENT_ID: 'client-id',
7+
AZURE_CLIENT_SECRET: 'client-secret',
8+
AZURE_SIGNING_ENDPOINT: 'https://example.test',
9+
AZURE_SIGNING_ACCOUNT: 'signing-account',
10+
AZURE_SIGNING_CERT_PROFILE: 'certificate-profile',
11+
AZURE_SIGNING_PUBLISHER_NAME: 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US',
12+
}
13+
14+
describe('Windows Azure signing configuration', () => {
15+
it('leaves the build unsigned when no signing variables are set', () => {
16+
expect(windowsSigningArgs({})).toEqual([])
17+
})
18+
19+
it('passes Azure signing settings as separate Electron Builder arguments', () => {
20+
expect(windowsSigningArgs(signingEnvironment)).toEqual([
21+
'--config.win.azureSignOptions.endpoint', 'https://example.test',
22+
'--config.win.azureSignOptions.codeSigningAccountName', 'signing-account',
23+
'--config.win.azureSignOptions.certificateProfileName', 'certificate-profile',
24+
'--config.win.azureSignOptions.publisherName', 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US',
25+
])
26+
})
27+
28+
it('rejects a missing Azure credential', () => {
29+
expect(() => windowsSigningArgs({
30+
...signingEnvironment,
31+
AZURE_CLIENT_SECRET: undefined,
32+
})).toThrow(
33+
'Windows signing is partially configured; missing: AZURE_CLIENT_SECRET. Set all seven signing variables or none.',
34+
)
35+
})
36+
37+
it('rejects a missing Azure signing setting', () => {
38+
expect(() => windowsSigningArgs({
39+
...signingEnvironment,
40+
AZURE_SIGNING_ACCOUNT: undefined,
41+
})).toThrow(
42+
'Windows signing is partially configured; missing: AZURE_SIGNING_ACCOUNT. Set all seven signing variables or none.',
43+
)
44+
})
45+
46+
it('treats whitespace-only signing values as absent', () => {
47+
expect(() => windowsSigningArgs({
48+
...signingEnvironment,
49+
AZURE_SIGNING_PUBLISHER_NAME: ' ',
50+
})).toThrow('AZURE_SIGNING_PUBLISHER_NAME')
51+
})
52+
})
53+
54+
describe('Windows package invocation', () => {
55+
it('quotes the publisher name on Windows', () => {
56+
const invocation = windowsPackageInvocation('win32', signingEnvironment, 'never')
57+
58+
expect(invocation.args).toContain('"CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US"')
59+
expect(invocation.shell).toBe(true)
60+
})
61+
62+
it('leaves the publisher name unquoted outside Windows', () => {
63+
const invocation = windowsPackageInvocation('darwin', signingEnvironment, 'never')
64+
65+
expect(invocation.args).toContain('CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US')
66+
expect(invocation.shell).toBe(false)
67+
})
68+
69+
it('preserves argument order and content outside Windows', () => {
70+
expect(windowsPackageInvocation('darwin', signingEnvironment, 'never').args).toEqual([
71+
'exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', 'never',
72+
'--config.win.azureSignOptions.endpoint', 'https://example.test',
73+
'--config.win.azureSignOptions.codeSigningAccountName', 'signing-account',
74+
'--config.win.azureSignOptions.certificateProfileName', 'certificate-profile',
75+
'--config.win.azureSignOptions.publisherName', 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US',
76+
])
77+
})
78+
79+
it('omits signing arguments when signing is not configured', () => {
80+
const expectedArgs = ['exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', 'never']
81+
const darwin = windowsPackageInvocation('darwin', {}, 'never')
82+
const win32 = windowsPackageInvocation('win32', {}, 'never')
83+
84+
expect(darwin.args).toEqual(expectedArgs)
85+
expect(win32.args).toEqual(expectedArgs)
86+
expect(darwin.args.some(argument => argument.startsWith('--config.win.'))).toBe(false)
87+
expect(win32.args.some(argument => argument.startsWith('--config.win.'))).toBe(false)
88+
})
89+
90+
it('uses pnpm on both platforms', () => {
91+
expect(windowsPackageInvocation('darwin', signingEnvironment, 'never').command).toBe('pnpm')
92+
expect(windowsPackageInvocation('win32', signingEnvironment, 'never').command).toBe('pnpm')
93+
})
94+
})

0 commit comments

Comments
 (0)