From 7b0be7daa17bd0121ad56bc621dd8d6821be8ce1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 15 Aug 2026 23:26:50 -0400 Subject: [PATCH 1/5] feat(desktop): sign the Windows installer through Azure Artifact Signing Add an all-or-nothing Azure signing path to the Windows packaging step. When the seven signing variables are set the installer is signed; when none are set the build stays unsigned as before; a partial configuration fails loudly rather than silently shipping an unsigned installer. Arguments route through packageManagerInvocation so the X.500 publisher name survives the Windows shell, which concatenates argv without quoting. --- .changeset/desktop-windows-azure-signing.md | 5 ++ .github/workflows/desktop-release.yml | 18 +++- apps/desktop/README.md | 6 +- apps/desktop/scripts/package-win.ts | 87 +++++++++++++++++++ apps/desktop/scripts/release-win.ts | 5 +- apps/desktop/tests/package-win.spec.ts | 94 +++++++++++++++++++++ 6 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 .changeset/desktop-windows-azure-signing.md create mode 100644 apps/desktop/scripts/package-win.ts create mode 100644 apps/desktop/tests/package-win.spec.ts diff --git a/.changeset/desktop-windows-azure-signing.md b/.changeset/desktop-windows-azure-signing.md new file mode 100644 index 00000000..8b0ec27f --- /dev/null +++ b/.changeset/desktop-windows-azure-signing.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-desktop': patch +--- + +Sign the Windows installer through Azure Artifact Signing when the signing environment is configured diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index fdeafbe7..2d2120ba 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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 }} diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3e4f20cc..1e10a31e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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--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--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 diff --git a/apps/desktop/scripts/package-win.ts b/apps/desktop/scripts/package-win.ts new file mode 100644 index 00000000..131be4f3 --- /dev/null +++ b/apps/desktop/scripts/package-win.ts @@ -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 + } +} diff --git a/apps/desktop/scripts/release-win.ts b/apps/desktop/scripts/release-win.ts index 97c3834b..c6d08c61 100644 --- a/apps/desktop/scripts/release-win.ts +++ b/apps/desktop/scripts/release-win.ts @@ -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 { @@ -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') @@ -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) } diff --git a/apps/desktop/tests/package-win.spec.ts b/apps/desktop/tests/package-win.spec.ts new file mode 100644 index 00000000..8b49f43e --- /dev/null +++ b/apps/desktop/tests/package-win.spec.ts @@ -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') + }) +}) From b8d8fe7ee95ff2e6333de2e2f29bd504b770916a Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 15 Aug 2026 23:26:58 -0400 Subject: [PATCH 2/5] fix(web): reserve the Windows title-bar area and paint the sidebar solid Every desktop chrome rule was gated on darwin, so Windows reserved no space for the 44px Window Controls Overlay and the controls covered the chat header's branch label. Add a shell-owned drag strip anchored to the titlebar-area safe area, which stays correct under RTL and while the preview panel animates, and give Windows a solid sidebar instead of the shared translucent fill. Also change the VS Code extension display name, which the Marketplace rejects as taken, and run the pythinker-web suite in the root vitest projects so the new contract test executes in CI. --- .../windows-titlebar-and-display-name.md | 5 ++ apps/pythinker-web/src/App.vue | 32 ++++++++-- .../test/windows-titlebar.test.ts | 58 +++++++++++++++++++ apps/vscode/package.json | 2 +- vitest.config.ts | 2 +- 5 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 .changeset/windows-titlebar-and-display-name.md create mode 100644 apps/pythinker-web/test/windows-titlebar.test.ts diff --git a/.changeset/windows-titlebar-and-display-name.md b/.changeset/windows-titlebar-and-display-name.md new file mode 100644 index 00000000..31b959a4 --- /dev/null +++ b/.changeset/windows-titlebar-and-display-name.md @@ -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. diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index 03f7214e..584a0d76 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -871,6 +871,7 @@ function openPr(url: string): void {