diff --git a/packages/targets/desktop-win/src/index.test.ts b/packages/targets/desktop-win/src/index.test.ts index 55b87094..7b1702b4 100644 --- a/packages/targets/desktop-win/src/index.test.ts +++ b/packages/targets/desktop-win/src/index.test.ts @@ -1,4 +1,87 @@ -import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { fakeBuildContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'desktop', requireKind: true }); + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('Windows desktop package planning', () => { + it('writes a dry-run plan for Microsoft Store and MSI outputs', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-desktop-win-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ + outDir, + version: '2.4.6', + dryRun: true, + }) as any, { + appId: 'Acme.MyApp', + publisherId: 'CN=12345678-90ab-cdef', + distribution: 'both', + signingCertThumbprint: 'ABCDEF123456', + architectures: ['x64', 'arm64'], + }); + + expect(result.artifact).toBe(join(outDir, 'windows', 'Acme.MyApp-2.4.6.package-plan.json')); + + const plan = JSON.parse(await readFile(result.artifact, 'utf8')) as { + appId: string; + publisherId: string; + version: string; + distribution: string; + architectures: string[]; + artifacts: Array<{ kind: string; path: string }>; + commands: Array<{ tool: string; args: string[]; needsSigningCert: boolean }>; + }; + + expect(plan).toMatchObject({ + appId: 'Acme.MyApp', + publisherId: 'CN=12345678-90ab-cdef', + version: '2.4.6', + distribution: 'both', + architectures: ['x64', 'arm64'], + }); + expect(plan.artifacts).toEqual([ + { kind: 'msixbundle', path: join(outDir, 'windows', 'Acme.MyApp-2.4.6.msixbundle') }, + { kind: 'msi', path: join(outDir, 'windows', 'Acme.MyApp-2.4.6.msi') }, + ]); + expect(plan.commands.map((command) => command.tool)).toEqual(['makeappx', 'signtool', 'wix', 'signtool']); + expect(plan.commands.every((command) => command.args.includes('ABCDEF123456') || command.tool !== 'signtool')).toBe(true); + expect(plan.commands.every((command) => command.needsSigningCert === false)).toBe(true); + }); + + it('marks signing cert follow-up when no thumbprint is configured', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-desktop-win-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ + outDir, + version: '1.0.0', + dryRun: true, + }) as any, { + appId: 'Acme.Tool', + publisherId: 'CN=publisher', + distribution: 'msi', + }); + + const plan = JSON.parse(await readFile(result.artifact, 'utf8')) as { + artifacts: Array<{ kind: string; path: string }>; + commands: Array<{ tool: string; args: string[]; needsSigningCert: boolean }>; + }; + + expect(plan.artifacts).toEqual([ + { kind: 'msi', path: join(outDir, 'windows', 'Acme.Tool-1.0.0.msi') }, + ]); + const signCommand = plan.commands.find((command) => command.tool === 'signtool'); + expect(signCommand?.needsSigningCert).toBe(true); + expect(signCommand?.args).toContain(''); + }); +}); diff --git a/packages/targets/desktop-win/src/index.ts b/packages/targets/desktop-win/src/index.ts index e32a942a..41f97971 100644 --- a/packages/targets/desktop-win/src/index.ts +++ b/packages/targets/desktop-win/src/index.ts @@ -1,4 +1,6 @@ import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; interface Config { appId: string; // Partner Center app identity (e.g. Acme.MyApp) @@ -9,13 +11,112 @@ interface Config { architectures?: ('x64' | 'arm64' | 'x86')[]; } +type WindowsArtifactKind = 'msixbundle' | 'msi'; + +interface WindowsPackagePlan { + appId: string; + publisherId: string; + version: string; + distribution: Config['distribution']; + architectures: Array[number]>; + artifacts: Array<{ + kind: WindowsArtifactKind; + path: string; + }>; + commands: Array<{ + tool: string; + args: string[]; + needsSigningCert: boolean; + }>; +} + +function safeFileStem(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-|-$/g, '') || 'windows-app'; +} + +function artifactKinds(distribution: Config['distribution']): WindowsArtifactKind[] { + if (distribution === 'both') return ['msixbundle', 'msi']; + return [distribution === 'msi' ? 'msi' : 'msixbundle']; +} + +function buildPlan(ctx: { outDir: string; version: string }, config: Config): WindowsPackagePlan { + const architectures = config.architectures ?? ['x64', 'arm64']; + const baseName = `${safeFileStem(config.appId)}-${safeFileStem(ctx.version)}`; + const artifacts = artifactKinds(config.distribution).map((kind) => ({ + kind, + path: join(ctx.outDir, 'windows', `${baseName}.${kind}`), + })); + const needsSigningCert = !config.signingCertThumbprint; + const commands = artifacts.flatMap((artifact) => { + if (artifact.kind === 'msixbundle') { + return [ + { + tool: 'makeappx', + args: ['pack', '/d', join(ctx.outDir, 'windows', 'msix'), '/p', artifact.path], + needsSigningCert: false, + }, + { + tool: 'signtool', + args: [ + 'sign', + '/fd', + 'SHA256', + ...(config.signingCertThumbprint ? ['/sha1', config.signingCertThumbprint] : ['']), + artifact.path, + ], + needsSigningCert, + }, + ]; + } + return [ + { + tool: 'wix', + args: ['build', join(ctx.outDir, 'windows', `${baseName}.wxs`), '-arch', architectures.join(','), '-out', artifact.path], + needsSigningCert: false, + }, + { + tool: 'signtool', + args: [ + 'sign', + '/fd', + 'SHA256', + ...(config.signingCertThumbprint ? ['/sha1', config.signingCertThumbprint] : ['']), + artifact.path, + ], + needsSigningCert, + }, + ]; + }); + + return { + appId: config.appId, + publisherId: config.publisherId, + version: ctx.version, + distribution: config.distribution, + architectures, + artifacts, + commands, + }; +} + +function planPath(ctx: { outDir: string; version: string }, appId: string): string { + return join(ctx.outDir, 'windows', `${safeFileStem(appId)}-${safeFileStem(ctx.version)}.package-plan.json`); +} + export default defineTarget({ id: 'desktop-win', kind: 'desktop', label: 'Windows (Microsoft Store / MSIX / MSI)', async build(ctx, config) { - const arches = config.architectures ?? ['x64', 'arm64']; - ctx.log(`build ${config.distribution} · arches=${arches.join(',')}`); + const plan = buildPlan(ctx, config); + ctx.log(`build ${config.distribution} · arches=${plan.architectures.join(',')}`); + if (ctx.dryRun) { + const artifact = planPath(ctx, config.appId); + await mkdir(join(ctx.outDir, 'windows'), { recursive: true }); + await writeFile(artifact, `${JSON.stringify(plan, null, 2)}\n`, 'utf8'); + ctx.log(`windows: dry-run package plan written to ${artifact}`); + return { artifact, meta: { artifacts: plan.artifacts, commands: plan.commands } }; + } // TODO: // - MSIX: makeappx pack + signtool sign using signingCertThumbprint // - MSI: WiX toolset → .msi → signtool sign