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-windows-azure-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-desktop': patch
---

Sign the Windows installer through Azure Artifact Signing when the signing environment is configured
5 changes: 5 additions & 0 deletions .changeset/site-windows-download.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Add a Windows download button to the site and point both desktop download buttons directly at the published installer assets.
5 changes: 5 additions & 0 deletions .changeset/windows-opaque-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-desktop': patch
---

Render the Windows desktop window opaquely so the theme colours are not blended with the desktop wallpaper
5 changes: 5 additions & 0 deletions .changeset/windows-titlebar-and-display-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

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.
18 changes: 17 additions & 1 deletion .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,25 @@ jobs:
value="${!input:-}"
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
done
- name: Resolve Azure signing configuration
shell: bash
env:
IN_AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
IN_AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
IN_AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
IN_AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
IN_AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
IN_AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }}
IN_AZURE_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_SIGNING_PUBLISHER_NAME }}
run: |
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
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
run: node --import tsx scripts/package-win.ts --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ 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.
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`.

#### Azure Artifact Signing

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.

## Known limitations

Expand Down
87 changes: 87 additions & 0 deletions apps/desktop/scripts/package-win.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/** Package the Windows NSIS installer with optional Azure Artifact Signing. */

import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { packageManagerInvocation } from './stage-runtime'

function trimmedValue(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed === '' ? undefined : trimmed
}

/** Return Electron Builder overrides when all Azure signing settings are configured. */
export function windowsSigningArgs(env: NodeJS.ProcessEnv): readonly string[] {
const values: readonly (readonly [string, string | undefined])[] = [
['AZURE_TENANT_ID', trimmedValue(env['AZURE_TENANT_ID'])],
['AZURE_CLIENT_ID', trimmedValue(env['AZURE_CLIENT_ID'])],
['AZURE_CLIENT_SECRET', trimmedValue(env['AZURE_CLIENT_SECRET'])],
['AZURE_SIGNING_ENDPOINT', trimmedValue(env['AZURE_SIGNING_ENDPOINT'])],
['AZURE_SIGNING_ACCOUNT', trimmedValue(env['AZURE_SIGNING_ACCOUNT'])],
['AZURE_SIGNING_CERT_PROFILE', trimmedValue(env['AZURE_SIGNING_CERT_PROFILE'])],
['AZURE_SIGNING_PUBLISHER_NAME', trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])],
]
const missing: string[] = []
const args: string[] = []
for (const [name, value] of values) {
if (value === undefined) {
missing.push(name)
continue
}

switch (name) {
case 'AZURE_SIGNING_ENDPOINT':
args.push('--config.win.azureSignOptions.endpoint', value)
break
case 'AZURE_SIGNING_ACCOUNT':
args.push('--config.win.azureSignOptions.codeSigningAccountName', value)
break
case 'AZURE_SIGNING_CERT_PROFILE':
args.push('--config.win.azureSignOptions.certificateProfileName', value)
break
case 'AZURE_SIGNING_PUBLISHER_NAME':
args.push('--config.win.azureSignOptions.publisherName', value)
break
}
}
missing.sort()

if (missing.length === values.length) return []
if (missing.length > 0) {
throw new Error(
`Windows signing is partially configured; missing: ${missing.join(', ')}. Set all seven signing variables or none.`,
)
}

return args
}

/** Return the package-manager invocation for a Windows installer build. */
export function windowsPackageInvocation(platform: string, env: NodeJS.ProcessEnv, publish: string): {
readonly command: string
readonly args: readonly string[]
readonly shell: boolean
} {
const args = ['exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', publish, ...windowsSigningArgs(env)]
return packageManagerInvocation(platform, 'pnpm', args)
}

/** Package the Windows installer and optionally sign it through Azure Artifact Signing. */
export function packageWin(options: { readonly publish: string }): void {
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const invocation = windowsPackageInvocation(process.platform, process.env, options.publish)
const result = spawnSync(invocation.command, invocation.args, { cwd: desktopRoot, stdio: 'inherit', shell: invocation.shell })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(`electron-builder exited with ${String(result.status)}`)
}

const invokedPath = process.argv[1]
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
try {
const publishIndex = process.argv.indexOf('--publish')
packageWin({ publish: publishIndex === -1 ? 'never' : (process.argv[publishIndex + 1] ?? 'never') })
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
5 changes: 3 additions & 2 deletions apps/desktop/scripts/release-win.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { packageWin } from './package-win'
import { verifyWindowsInstaller } from './verify-win-installer'

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

/** Build and verify the unsigned Windows installer. */
/** Build and verify the 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')
Expand All @@ -22,7 +23,7 @@ export function releaseWin(): void {
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)
packageWin({ publish: 'never' })
verifyWindowsInstaller(desktopRoot)
}

Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,9 @@ async function createMainWindow(): Promise<BrowserWindow> {
vibrancy: 'sidebar' as const,
visualEffectState: 'followWindow' as const,
} : {}),
// Windows uses an opaque window so theme colors do not blend with desktop wallpaper.
...(process.platform === 'win32' ? {
backgroundMaterial: 'acrylic' as const,
backgroundColor: '#0d1117',
hasShadow: true,
roundedCorners: true,
thickFrame: true,
Expand Down
94 changes: 94 additions & 0 deletions apps/desktop/tests/package-win.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { windowsPackageInvocation, windowsSigningArgs } from '../scripts/package-win'

const signingEnvironment: NodeJS.ProcessEnv = {
AZURE_TENANT_ID: 'tenant-id',
AZURE_CLIENT_ID: 'client-id',
AZURE_CLIENT_SECRET: 'client-secret',
AZURE_SIGNING_ENDPOINT: 'https://example.test',
AZURE_SIGNING_ACCOUNT: 'signing-account',
AZURE_SIGNING_CERT_PROFILE: 'certificate-profile',
AZURE_SIGNING_PUBLISHER_NAME: 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US',
}

describe('Windows Azure signing configuration', () => {
it('leaves the build unsigned when no signing variables are set', () => {
expect(windowsSigningArgs({})).toEqual([])
})

it('passes Azure signing settings as separate Electron Builder arguments', () => {
expect(windowsSigningArgs(signingEnvironment)).toEqual([
'--config.win.azureSignOptions.endpoint', 'https://example.test',
'--config.win.azureSignOptions.codeSigningAccountName', 'signing-account',
'--config.win.azureSignOptions.certificateProfileName', 'certificate-profile',
'--config.win.azureSignOptions.publisherName', 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US',
])
})

it('rejects a missing Azure credential', () => {
expect(() => windowsSigningArgs({
...signingEnvironment,
AZURE_CLIENT_SECRET: undefined,
})).toThrow(
'Windows signing is partially configured; missing: AZURE_CLIENT_SECRET. Set all seven signing variables or none.',
)
})

it('rejects a missing Azure signing setting', () => {
expect(() => windowsSigningArgs({
...signingEnvironment,
AZURE_SIGNING_ACCOUNT: undefined,
})).toThrow(
'Windows signing is partially configured; missing: AZURE_SIGNING_ACCOUNT. Set all seven signing variables or none.',
)
})

it('treats whitespace-only signing values as absent', () => {
expect(() => windowsSigningArgs({
...signingEnvironment,
AZURE_SIGNING_PUBLISHER_NAME: ' ',
})).toThrow('AZURE_SIGNING_PUBLISHER_NAME')
})
})

describe('Windows package invocation', () => {
it('quotes the publisher name on Windows', () => {
const invocation = windowsPackageInvocation('win32', signingEnvironment, 'never')

expect(invocation.args).toContain('"CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US"')
expect(invocation.shell).toBe(true)
})

it('leaves the publisher name unquoted outside Windows', () => {
const invocation = windowsPackageInvocation('darwin', signingEnvironment, 'never')

expect(invocation.args).toContain('CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US')
expect(invocation.shell).toBe(false)
})

it('preserves argument order and content outside Windows', () => {
expect(windowsPackageInvocation('darwin', signingEnvironment, 'never').args).toEqual([
'exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', 'never',
'--config.win.azureSignOptions.endpoint', 'https://example.test',
'--config.win.azureSignOptions.codeSigningAccountName', 'signing-account',
'--config.win.azureSignOptions.certificateProfileName', 'certificate-profile',
'--config.win.azureSignOptions.publisherName', 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US',
])
})

it('omits signing arguments when signing is not configured', () => {
const expectedArgs = ['exec', 'electron-builder', '--win', 'nsis', '--x64', '--publish', 'never']
const darwin = windowsPackageInvocation('darwin', {}, 'never')
const win32 = windowsPackageInvocation('win32', {}, 'never')

expect(darwin.args).toEqual(expectedArgs)
expect(win32.args).toEqual(expectedArgs)
expect(darwin.args.some(argument => argument.startsWith('--config.win.'))).toBe(false)
expect(win32.args.some(argument => argument.startsWith('--config.win.'))).toBe(false)
})

it('uses pnpm on both platforms', () => {
expect(windowsPackageInvocation('darwin', signingEnvironment, 'never').command).toBe('pnpm')
expect(windowsPackageInvocation('win32', signingEnvironment, 'never').command).toBe('pnpm')
})
})
18 changes: 18 additions & 0 deletions apps/desktop/tests/packaging-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ describe('desktop packaging configuration', () => {
expect(desktopPackage.build.productName).toBe('Pythinker')
})

it('keeps desktop download URLs derived from their published release version', () => {
const siteSource = readFileSync(resolve(repositoryRoot, 'apps/site/src/App.vue'), 'utf8')
const desktopVersionMatch = siteSource.match(/const DESKTOP_VERSION = '([^']+)'/)

expect(desktopVersionMatch).not.toBeNull()
expect(desktopVersionMatch![1]).not.toBe('')
expect(siteSource).not.toContain('Pythinker-0.1.0-arm64.dmg')
expect(siteSource).not.toContain('Pythinker-0.1.0-x64-Setup.exe')
expect(siteSource).toContain('Pythinker-${DESKTOP_VERSION}-arm64.dmg')
expect(siteSource).toContain('Pythinker-${DESKTOP_VERSION}-x64-Setup.exe')
expect(siteSource).toContain('releases/download/v${DESKTOP_VERSION}')
expect(siteSource).not.toContain('releases/download/v0.1.0')

const desktopShowcaseMatch = siteSource.match(/<section id="desktop"[\s\S]*?<\/section>/)
expect(desktopShowcaseMatch).not.toBeNull()
expect(desktopShowcaseMatch![0]).toContain('/brand/windows11.svg')
})

it('maps the staged Host node_modules directory as the copy root', () => {
expect(desktopPackage.build.extraResources).toEqual(expect.arrayContaining([
{ from: 'resources', to: 'desktop-resources' },
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/tests/window-appearance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Static check: no Windows host exists in CI or locally, so this test guards the
// window configuration rather than the rendered result.
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'

const desktopRoot = resolve(import.meta.dirname, '..')
const mainSource = readFileSync(resolve(desktopRoot, 'src', 'main.ts'), 'utf8')

describe('desktop window appearance configuration', () => {
it('keeps Windows opaque and non-Windows windows transparent', () => {
const backgroundMaterialMatches = [...mainSource.matchAll(/backgroundMaterial/gu)]
const win32BranchMatches = [...mainSource.matchAll(
/\.\.\.\(process\.platform === 'win32' \? \{([\s\S]*?)\} : \{\s*transparent: true,/gu,
)]
const nonWin32BranchMatches = [...mainSource.matchAll(
/\} : \{\s*transparent: true,[\s\S]*?\}\),\s*title:/gu,
)]

expect(backgroundMaterialMatches).toHaveLength(0)
expect(win32BranchMatches).toHaveLength(1)
expect(nonWin32BranchMatches).toHaveLength(1)

const win32Branch = win32BranchMatches[0]![1]!
const opaqueColorMatches = [...win32Branch.matchAll(/backgroundColor:\s*'#[0-9a-fA-F]{6}'/gu)]
const alphaColorMatches = [...win32Branch.matchAll(/#[0-9a-fA-F]{8}/gu)]
const hasShadowMatches = [...win32Branch.matchAll(/hasShadow:\s*true/gu)]
const roundedCornersMatches = [...win32Branch.matchAll(/roundedCorners:\s*true/gu)]
const thickFrameMatches = [...win32Branch.matchAll(/thickFrame:\s*true/gu)]

expect(opaqueColorMatches).toHaveLength(1)
expect(alphaColorMatches).toHaveLength(0)
expect(hasShadowMatches).toHaveLength(1)
expect(roundedCornersMatches).toHaveLength(1)
expect(thickFrameMatches).toHaveLength(1)

expect(win32Branch).toContain('backgroundColor')
expect(nonWin32BranchMatches[0]![0]).toContain('transparent: true')
expect(win32Branch).toContain('hasShadow')
expect(win32Branch).toContain('roundedCorners')
expect(win32Branch).toContain('thickFrame')
})
})
Loading
Loading