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/.changeset/site-windows-download.md b/.changeset/site-windows-download.md new file mode 100644 index 00000000..f2d309db --- /dev/null +++ b/.changeset/site-windows-download.md @@ -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. diff --git a/.changeset/windows-opaque-window.md b/.changeset/windows-opaque-window.md new file mode 100644 index 00000000..8e5186ec --- /dev/null +++ b/.changeset/windows-opaque-window.md @@ -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 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/.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/src/main.ts b/apps/desktop/src/main.ts index 30522f41..e954392b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -201,8 +201,9 @@ async function createMainWindow(): Promise { 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, 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') + }) +}) diff --git a/apps/desktop/tests/packaging-config.spec.ts b/apps/desktop/tests/packaging-config.spec.ts index 6c87e46c..0ec1013d 100644 --- a/apps/desktop/tests/packaging-config.spec.ts +++ b/apps/desktop/tests/packaging-config.spec.ts @@ -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(/
/) + 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' }, diff --git a/apps/desktop/tests/window-appearance.spec.ts b/apps/desktop/tests/window-appearance.spec.ts new file mode 100644 index 00000000..0a3b5fc9 --- /dev/null +++ b/apps/desktop/tests/window-appearance.spec.ts @@ -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') + }) +}) 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 {