diff --git a/.github/scripts/release/assets/win.ps1 b/.github/scripts/release/assets/win.ps1 index 3a7ca0bd7..a12726929 100644 --- a/.github/scripts/release/assets/win.ps1 +++ b/.github/scripts/release/assets/win.ps1 @@ -8,21 +8,37 @@ foreach ($name in @("CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN", "RELEASE_CHANNEL", " $assetSuffix = if ($null -eq $env:WINDOWS_ASSET_SUFFIX) { "" } else { $env:WINDOWS_ASSET_SUFFIX } $versionPathSuffix = if ($null -eq $env:ASSET_VERSION_SUFFIX) { "" } else { $env:ASSET_VERSION_SUFFIX } +$includeZip = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_INCLUDE_ZIP)) { $true } else { $env:WINDOWS_INCLUDE_ZIP -ne "false" } $releaseDir = Join-Path $env:RUNNER_TEMP "release-assets" New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null -$sourceInstaller = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/${env:TOOLS_PACK_NAMESPACE}/builder/Open Design-${env:TOOLS_PACK_NAMESPACE}-setup.exe" +$builderDir = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/${env:TOOLS_PACK_NAMESPACE}/builder" +$sourceInstaller = Join-Path $builderDir "Open Design-${env:TOOLS_PACK_NAMESPACE}-setup.exe" +$sourceZip = Join-Path $builderDir "Open Design-${env:TOOLS_PACK_NAMESPACE}-portable.zip" if (!(Test-Path $sourceInstaller)) { throw "expected installer not found at $sourceInstaller" } +if ($includeZip -and !(Test-Path $sourceZip)) { + throw "expected portable zip not found at $sourceZip (build with --to all or --to zip, or set WINDOWS_INCLUDE_ZIP=false to skip)" +} $versionedInstaller = "open-design-${env:RELEASE_VERSION}$assetSuffix-win-x64-setup.exe" -$checksumFile = "$versionedInstaller.sha256" -Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller) +$versionedZip = "open-design-${env:RELEASE_VERSION}$assetSuffix-win-x64-portable.zip" +$installerChecksumFile = "$versionedInstaller.sha256" +$zipChecksumFile = "$versionedZip.sha256" +Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller) $installerPath = Join-Path $releaseDir $versionedInstaller -$hash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant() -"$hash $versionedInstaller" | Set-Content -Path (Join-Path $releaseDir $checksumFile) +$installerHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant() +"$installerHash $versionedInstaller" | Set-Content -Path (Join-Path $releaseDir $installerChecksumFile) + +if ($includeZip) { + Copy-Item $sourceZip (Join-Path $releaseDir $versionedZip) + $zipPath = Join-Path $releaseDir $versionedZip + $zipHash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + "$zipHash $versionedZip" | Set-Content -Path (Join-Path $releaseDir $zipChecksumFile) +} + $installerBytes = [System.IO.File]::ReadAllBytes($installerPath) $installerSha512 = [System.Convert]::ToBase64String([System.Security.Cryptography.SHA512]::Create().ComputeHash($installerBytes)) $installerSize = (Get-Item $installerPath).Length @@ -39,6 +55,9 @@ $releaseNotes = if ([string]::IsNullOrWhiteSpace($env:RELEASE_NOTES)) { } else { $env:RELEASE_NOTES } +# latest.yml is the electron-updater auto-update feed; it only references the +# NSIS installer because the portable zip is a manual-download convenience and +# is not consumed by the in-app updater. @( "version: `"${env:RELEASE_VERSION}`"" 'files:' diff --git a/.github/scripts/release/r2/publish-platform.ts b/.github/scripts/release/r2/publish-platform.ts index 4acf56828..caff13bd0 100644 --- a/.github/scripts/release/r2/publish-platform.ts +++ b/.github/scripts/release/r2/publish-platform.ts @@ -175,10 +175,18 @@ if (platform === "mac") { } else if (platform === "win") { const suffix = optional("WIN_ASSET_SUFFIX", assetVersionSuffix); const installer = `open-design-${releaseVersion}${suffix}-win-x64-setup.exe`; + const portableZip = `open-design-${releaseVersion}${suffix}-win-x64-portable.zip`; + const includeZip = optional("WIN_INCLUDE_ZIP", "true") !== "false"; + const artifacts = { installer: fileEntry(installer, contentType(installer)) }; + const assetNames = [installer, `${installer}.sha256`, "latest.yml"]; + if (includeZip) { + artifacts.portableZip = fileEntry(portableZip, contentType(portableZip)); + assetNames.push(portableZip, `${portableZip}.sha256`); + } config = { arch: "x64", - artifacts: { installer: fileEntry(installer, contentType(installer)) }, - assetNames: [installer, `${installer}.sha256`, "latest.yml"], + artifacts, + assetNames, feed: { latestUrl: publicUrl(latestPrefix, "latest.yml"), name: "latest.yml", diff --git a/.github/scripts/release/r2/publish.sh b/.github/scripts/release/r2/publish.sh index 442e4973c..93f43442c 100644 --- a/.github/scripts/release/r2/publish.sh +++ b/.github/scripts/release/r2/publish.sh @@ -109,8 +109,17 @@ mac_zip="open-design-$RELEASE_VERSION$asset_version_suffix-mac-arm64.zip" mac_intel_dmg="open-design-$RELEASE_VERSION$mac_intel_asset_suffix-mac-x64.dmg" mac_intel_zip="open-design-$RELEASE_VERSION$mac_intel_asset_suffix-mac-x64.zip" win_installer="open-design-$RELEASE_VERSION$win_asset_suffix-win-x64-setup.exe" +win_portable_zip="open-design-$RELEASE_VERSION$win_asset_suffix-win-x64-portable.zip" linux_appimage="open-design-$RELEASE_VERSION$linux_asset_suffix-linux-x64.AppImage" metadata_path="$release_root/metadata.json" +win_include_zip="${WIN_INCLUDE_ZIP:-true}" +case "$win_include_zip" in + true | false) ;; + *) + echo "unsupported WIN_INCLUDE_ZIP: $win_include_zip" >&2 + exit 1 + ;; +esac if [ "$ENABLE_MAC" = "true" ]; then upload "$release_root/mac/$mac_dmg" "$version_prefix/$mac_dmg" "application/x-apple-diskimage" "public, max-age=31536000, immutable" @@ -139,6 +148,13 @@ if [ "$ENABLE_WIN" = "true" ]; then echo "win_installer_url=$public_origin/$version_prefix/$win_installer" echo "win_feed_url=$public_origin/$latest_prefix/latest.yml" } >> "$GITHUB_OUTPUT" + if [ "$win_include_zip" = "true" ]; then + upload "$release_root/win/$win_portable_zip" "$version_prefix/$win_portable_zip" "application/zip" "public, max-age=31536000, immutable" + upload "$release_root/win/$win_portable_zip.sha256" "$version_prefix/$win_portable_zip.sha256" "text/plain; charset=utf-8" "public, max-age=31536000, immutable" + { + echo "win_portable_zip_url=$public_origin/$version_prefix/$win_portable_zip" + } >> "$GITHUB_OUTPUT" + fi fi if [ "$ENABLE_MAC_INTEL" = "true" ]; then @@ -177,6 +193,8 @@ MAC_ZIP="$mac_zip" \ MAC_INTEL_DMG="$mac_intel_dmg" \ MAC_INTEL_ZIP="$mac_intel_zip" \ WIN_INSTALLER="$win_installer" \ +WIN_PORTABLE_ZIP="$win_portable_zip" \ +WIN_INCLUDE_ZIP="$win_include_zip" \ LINUX_APPIMAGE="$linux_appimage" \ MAC_ARTIFACT_MODE="$mac_artifact_mode" \ METADATA_PATH="$metadata_path" \ @@ -236,6 +254,12 @@ if (enabled("ENABLE_MAC")) { }; } if (enabled("ENABLE_WIN")) { + const winArtifacts = { + installer: fileEntry("win", env.WIN_INSTALLER, "application/vnd.microsoft.portable-executable"), + }; + if (env.WIN_INCLUDE_ZIP !== "false") { + winArtifacts.portableZip = fileEntry("win", env.WIN_PORTABLE_ZIP, "application/zip"); + } platforms.win = { arch: "x64", enabled: true, @@ -245,9 +269,7 @@ if (enabled("ENABLE_WIN")) { url: url(versionPrefix, "latest.yml"), }, signed: false, - artifacts: { - installer: fileEntry("win", env.WIN_INSTALLER, "application/vnd.microsoft.portable-executable"), - }, + artifacts: winArtifacts, }; } if (enabled("ENABLE_LINUX")) { diff --git a/.github/scripts/release/r2/summary.sh b/.github/scripts/release/r2/summary.sh index 5bc28a7fd..97327d4d8 100644 --- a/.github/scripts/release/r2/summary.sh +++ b/.github/scripts/release/r2/summary.sh @@ -65,6 +65,7 @@ if (platforms.mac.enabled) { if (platforms.win.enabled) { platforms.win.artifacts = { installer: optional("R2_WIN_INSTALLER_URL"), + portableZip: optional("R2_WIN_PORTABLE_ZIP_URL"), }; platforms.win.feed = optional("R2_WIN_FEED_URL"); platforms.win.e2e = platformReport("win"); @@ -142,7 +143,10 @@ const platformRows = [ [ "Windows x64", platformStatus(platforms.win, "Published"), - linkList([{ label: "Installer", url: platforms.win.artifacts?.installer }]), + linkList([ + { label: "Installer", url: platforms.win.artifacts?.installer }, + { label: "Portable ZIP", url: platforms.win.artifacts?.portableZip }, + ]), link("latest.yml", platforms.win.feed), ], [ diff --git a/.github/scripts/release/r2/verify.sh b/.github/scripts/release/r2/verify.sh index ecc72428f..9635b0baa 100644 --- a/.github/scripts/release/r2/verify.sh +++ b/.github/scripts/release/r2/verify.sh @@ -134,6 +134,9 @@ if [ "$ENABLE_WIN" = "true" ]; then grep -F "version: \"$RELEASE_VERSION\"" "$downloaded_feed" grep -F "$R2_WIN_INSTALLER_URL" "$downloaded_feed" curl -fsSI "$R2_WIN_INSTALLER_URL" >/dev/null + if [ -n "${R2_WIN_PORTABLE_ZIP_URL:-}" ]; then + curl -fsSI "$R2_WIN_PORTABLE_ZIP_URL" >/dev/null + fi require_report_file "win/manifest.json" require_report_file "win/screenshots/open-design-win-smoke.png" require_report_file "win/suite-result.json" diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 375f30993..e9ceab7fd 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -459,7 +459,7 @@ jobs: "--namespace", "release-beta-win", "--portable", "--app-version", "${{ needs.metadata.outputs.beta_version }}", - "--to", "nsis", + "--to", "all", "--json" ) "cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append @@ -477,7 +477,7 @@ jobs: --namespace release-beta-win ` --portable ` --app-version "${{ needs.metadata.outputs.beta_version }}" ` - --to nsis ` + --to all ` --json if ($LASTEXITCODE -ne 0) { throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE" diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index 72342f6e6..710088ccc 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -456,7 +456,7 @@ jobs: "--namespace", "release-preview-win", "--portable", "--app-version", "${{ needs.metadata.outputs.release_version }}", - "--to", "nsis", + "--to", "all", "--json" ) "cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append @@ -474,7 +474,7 @@ jobs: --namespace release-preview-win ` --portable ` --app-version "${{ needs.metadata.outputs.release_version }}" ` - --to nsis ` + --to all ` --json if ($LASTEXITCODE -ne 0) { throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE" @@ -727,6 +727,7 @@ jobs: R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }} R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} + R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }} run: bash .github/scripts/release/r2/verify.sh - name: Publish summary @@ -743,6 +744,7 @@ jobs: R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }} R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} + R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }} run: bash .github/scripts/release/r2/summary.sh - name: Cleanup workflow artifacts diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index e32bb8776..f79b8ccf6 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -503,7 +503,7 @@ jobs: "--namespace", "${{ needs.metadata.outputs.win_namespace }}", "--portable", "--app-version", "${{ needs.metadata.outputs.release_version }}", - "--to", "nsis", + "--to", "all", "--json" ) "cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append @@ -521,7 +521,7 @@ jobs: --namespace "${{ needs.metadata.outputs.win_namespace }}" ` --portable ` --app-version "${{ needs.metadata.outputs.release_version }}" ` - --to nsis ` + --to all ` --json if ($LASTEXITCODE -ne 0) { throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE" @@ -888,6 +888,7 @@ jobs: R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }} R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} + R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }} run: bash .github/scripts/release/r2/verify.sh - name: Promote draft to published latest @@ -921,6 +922,7 @@ jobs: R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }} R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} + R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }} run: bash .github/scripts/release/r2/summary.sh - name: Cleanup workflow artifacts diff --git a/tools/pack/src/config.ts b/tools/pack/src/config.ts index 2bf8625f7..2b922eb62 100644 --- a/tools/pack/src/config.ts +++ b/tools/pack/src/config.ts @@ -120,7 +120,7 @@ export type ToolPackConfig = { function resolveToolPackBuildOutput(platform: ToolPackPlatform, value: string | undefined): ToolPackBuildOutput { if (value == null || value.length === 0) return platform === "win" ? "nsis" : "all"; if (platform === "mac" && (value === "all" || value === "app" || value === "dmg" || value === "zip")) return value; - if (platform === "win" && (value === "all" || value === "dir" || value === "nsis")) return value; + if (platform === "win" && (value === "all" || value === "dir" || value === "nsis" || value === "zip")) return value; if (platform === "linux" && (value === "all" || value === "appimage" || value === "dir")) return value; throw new Error(`unsupported ${platform} --to target: ${value}`); } diff --git a/tools/pack/src/index.ts b/tools/pack/src/index.ts index adbccc89b..98f0d9e5c 100644 --- a/tools/pack/src/index.ts +++ b/tools/pack/src/index.ts @@ -77,7 +77,7 @@ function addSharedOptions(command: CacCommand) { const TO_HELP_BY_PLATFORM: Record = { linux: "build target: all|appimage|dir (default: all)", mac: "build target: all|app|dmg|zip (default: all)", - win: "build target: all|dir|nsis (default: nsis)", + win: "build target: all|dir|nsis|zip (default: nsis). `zip` produces a portable zip from the unpacked build; `all` produces dir+nsis+zip.", }; function addBuildOptions(command: CacCommand, platform: ToolPackPlatform) { diff --git a/tools/pack/src/win/build.ts b/tools/pack/src/win/build.ts index def6d2576..3beca5540 100644 --- a/tools/pack/src/win/build.ts +++ b/tools/pack/src/win/build.ts @@ -89,6 +89,7 @@ export async function packWin(config: ToolPackConfig): Promise { installerPath: (await pathExists(paths.setupPath)) ? paths.setupPath : null, latestYmlPath: (await pathExists(paths.latestYmlPath)) ? paths.latestYmlPath : null, outputRoot: config.roots.output.namespaceRoot, + portableZipPath: (await pathExists(paths.setupZipPath)) ? paths.setupZipPath : null, resourceRoot: builtApp == null ? paths.resourceRoot : join(builtApp.unpackedRoot, "resources", "open-design"), runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, cacheReport: cache.report(), diff --git a/tools/pack/src/win/builder.ts b/tools/pack/src/win/builder.ts index 524c944d4..1c6857c13 100644 --- a/tools/pack/src/win/builder.ts +++ b/tools/pack/src/win/builder.ts @@ -33,8 +33,13 @@ import { } from "./manifest.js"; import { ensureNsisPersianLanguageAlias, writeNsisInclude } from "./nsis.js"; import { sanitizeNamespace } from "./paths.js"; -import { resolveWinTargets } from "./report.js"; +import { + resolveElectronBuilderWinTargets, + shouldBuildWinNsisInstaller, + shouldBuildWinPortableZip, +} from "./report.js"; import type { ResourceTreeResult } from "./resources.js"; +import { buildWinPortableZip } from "./zip.js"; import type { ElectronBuilderDirCacheMetadata, WinBuiltAppManifest, @@ -141,7 +146,7 @@ async function runElectronBuilderRaw(config: ToolPackConfig, paths: WinPaths, pr win: { artifactName: `${PRODUCT_NAME}-${namespaceToken}.\${ext}`, icon: paths.winIconPath, - target: resolveWinTargets(config.to).map((target) => ({ arch: ["x64"], target })), + target: resolveElectronBuilderWinTargets(config.to).map((target) => ({ arch: ["x64"], target })), }, }; @@ -337,7 +342,13 @@ export async function runElectronBuilder( unpackedRoot: cachedUnpackedRoot, webStandaloneHookAuditPath: (await pathExists(paths.webStandaloneHookAuditPath)) ? paths.webStandaloneHookAuditPath : null, }); - if (config.to === "nsis" || config.to === "all") { - await buildCustomWinNsisInstaller(config, paths, await materializeCachedUnpackedForInstaller(cachedUnpackedRoot, paths, packagedVersion)); + if (shouldBuildWinNsisInstaller(config.to) || shouldBuildWinPortableZip(config.to)) { + const materialized = await materializeCachedUnpackedForInstaller(cachedUnpackedRoot, paths, packagedVersion); + if (shouldBuildWinNsisInstaller(config.to)) { + await buildCustomWinNsisInstaller(config, paths, materialized); + } + if (shouldBuildWinPortableZip(config.to)) { + await buildWinPortableZip(config, paths, materialized); + } } } diff --git a/tools/pack/src/win/paths.ts b/tools/pack/src/win/paths.ts index 2ce8e4577..75f7eaede 100644 --- a/tools/pack/src/win/paths.ts +++ b/tools/pack/src/win/paths.ts @@ -56,6 +56,7 @@ export function resolveWinPaths(config: ToolPackConfig): WinPaths { packagedMainPrebundlePath: join(namespaceRoot, "assembled", WIN_PREBUNDLED_PACKAGED_MAIN_RELATIVE_PATH), resourceRoot: join(namespaceRoot, "resources", "open-design"), setupPath: join(namespaceRoot, "builder", `${PRODUCT_NAME}-${namespaceToken}-setup.exe`), + setupZipPath: join(namespaceRoot, "builder", `${PRODUCT_NAME}-${namespaceToken}-portable.zip`), startMenuShortcutPath: join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", identity.shortcutName), tarballsRoot: join(namespaceRoot, "tarballs"), userDesktopShortcutPath: join(homedir(), "Desktop", identity.shortcutName), diff --git a/tools/pack/src/win/report.ts b/tools/pack/src/win/report.ts index 98f9c5741..0633d225b 100644 --- a/tools/pack/src/win/report.ts +++ b/tools/pack/src/win/report.ts @@ -22,19 +22,40 @@ function isBetterSqlite3SourceResidue(path: string): boolean { ); } -export function resolveWinTargets(to: ToolPackConfig["to"]): Array<"dir" | "nsis"> { +export function resolveWinTargets(to: ToolPackConfig["to"]): Array<"dir" | "nsis" | "zip"> { switch (to) { case "dir": return ["dir"]; case "all": - return ["dir", "nsis"]; + return ["dir", "nsis", "zip"]; case "nsis": return ["nsis"]; + case "zip": + return ["zip"]; default: throw new Error(`unsupported win target: ${to}`); } } +// electron-builder only knows how to produce the unpacked `dir` and the NSIS +// installer; the portable zip is assembled afterwards from the same unpacked +// content using bundled 7z, so we filter the zip target out before handing the +// list to electron-builder. When the user asks for `zip` alone we still need +// the unpacked dir, so the helper substitutes `dir` in its place. +export function resolveElectronBuilderWinTargets(to: ToolPackConfig["to"]): Array<"dir" | "nsis"> { + const filtered = resolveWinTargets(to).filter((target): target is "dir" | "nsis" => target !== "zip"); + if (filtered.length === 0) return ["dir"]; + return filtered; +} + +export function shouldBuildWinNsisInstaller(to: ToolPackConfig["to"]): boolean { + return resolveWinTargets(to).includes("nsis"); +} + +export function shouldBuildWinPortableZip(to: ToolPackConfig["to"]): boolean { + return resolveWinTargets(to).includes("zip"); +} + export async function collectWinSizeReport( config: ToolPackConfig, paths: WinPaths, @@ -68,6 +89,7 @@ export async function collectWinSizeReport( generatedAt: new Date().toISOString(), installerBytes: await sizeExistingFileBytes(paths.setupPath), outputRootBytes: namespaceSizeIndex.sizePathBytes(config.roots.output.namespaceRoot), + portableZipBytes: await sizeExistingFileBytes(paths.setupZipPath), resourceRootBytes: sizeIndex.sizePathBytes(join(appResourcesRoot, "open-design")), runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, topLevel: { diff --git a/tools/pack/src/win/types.ts b/tools/pack/src/win/types.ts index 5eeb0da47..7d535334a 100644 --- a/tools/pack/src/win/types.ts +++ b/tools/pack/src/win/types.ts @@ -76,6 +76,7 @@ export type WinPaths = { packagedMainPrebundlePath: string; resourceRoot: string; setupPath: string; + setupZipPath: string; startMenuShortcutPath: string; tarballsRoot: string; userDesktopShortcutPath: string; @@ -96,6 +97,7 @@ export type WinPackResult = { installerPath: string | null; latestYmlPath: string | null; outputRoot: string; + portableZipPath: string | null; resourceRoot: string; runtimeNamespaceRoot: string; cacheReport: CacheReport; @@ -123,12 +125,13 @@ export type WinSizeReport = { }; nodeGypRebuild: boolean; npmRebuild: boolean; - targets: Array<"dir" | "nsis">; + targets: Array<"dir" | "nsis" | "zip">; webOutputMode: ToolPackConfig["webOutputMode"]; }; generatedAt: string; installerBytes: number | null; outputRootBytes: number; + portableZipBytes: number | null; resourceRootBytes: number; runtimeNamespaceRoot: string; topLevel: { diff --git a/tools/pack/src/win/zip.ts b/tools/pack/src/win/zip.ts new file mode 100644 index 000000000..5dedd0680 --- /dev/null +++ b/tools/pack/src/win/zip.ts @@ -0,0 +1,41 @@ +import { execFile } from "node:child_process"; +import { mkdir, rm, stat } from "node:fs/promises"; +import { dirname } from "node:path"; +import { promisify } from "node:util"; + +import type { ToolPackConfig } from "../config.js"; +import { winResources } from "../resources.js"; +import type { WinBuiltAppManifest, WinPaths } from "./types.js"; + +const execFileAsync = promisify(execFile); + +// Produces a portable zip from the unpacked Electron build using the same 7z +// binary that ships with tools-pack for the NSIS payload. The zip lays files +// flat at the archive root so that users can extract it anywhere on Windows +// and run `Open Design.exe` without going through the NSIS installer. +// +// We deliberately do not delegate this to electron-builder's native `zip` +// target: the existing tools-pack flow forces electron-builder to `to: "dir"` +// so the cached `win-unpacked` output can be shared across cache hits and +// post-processed into the custom NSIS installer. Producing the zip from that +// same cached unpacked tree keeps the build deterministic and avoids a +// second electron-builder pass. +export async function buildWinPortableZip( + _config: ToolPackConfig, + paths: WinPaths, + builtApp: WinBuiltAppManifest, +): Promise { + if (process.platform !== "win32") throw new Error("Windows portable zip build must run on Windows"); + + await mkdir(dirname(paths.setupZipPath), { recursive: true }); + await rm(paths.setupZipPath, { force: true }); + await execFileAsync( + winResources.sevenZipExe, + ["a", "-tzip", "-mx=5", paths.setupZipPath, ".\\*"], + { + cwd: builtApp.unpackedRoot, + windowsHide: true, + }, + ); + await stat(paths.setupZipPath); +} diff --git a/tools/pack/tests/config.test.ts b/tools/pack/tests/config.test.ts index 30ce4238c..b0c40ad38 100644 --- a/tools/pack/tests/config.test.ts +++ b/tools/pack/tests/config.test.ts @@ -24,6 +24,15 @@ afterEach(() => { } }); +describe("resolveToolPackConfig win build target", () => { + it("accepts the portable zip target and rejects unsupported values", () => { + expect(resolveToolPackConfig("win", { to: "zip" }).to).toBe("zip"); + expect(resolveToolPackConfig("win", { to: "all" }).to).toBe("all"); + expect(resolveToolPackConfig("win", { to: "nsis" }).to).toBe("nsis"); + expect(() => resolveToolPackConfig("win", { to: "dmg" })).toThrow(/unsupported win --to target: dmg/); + }); +}); + describe("resolveToolPackConfig namespace defaults", () => { it("keeps ordinary local builds on the default namespace", () => { expect(resolveToolPackConfig("mac").namespace).toBe("default"); diff --git a/tools/pack/tests/win-builder.test.ts b/tools/pack/tests/win-builder.test.ts index 3235c163f..9ba28cc39 100644 --- a/tools/pack/tests/win-builder.test.ts +++ b/tools/pack/tests/win-builder.test.ts @@ -40,6 +40,7 @@ function createPaths(root: string): WinPaths { packagedMainPrebundlePath: join(namespaceRoot, "assembled", "app", "prebundled", "packaged-main.mjs"), resourceRoot: join(namespaceRoot, "resources", "open-design"), setupPath: join(namespaceRoot, "builder", "Open Design-second-setup.exe"), + setupZipPath: join(namespaceRoot, "builder", "Open Design-second-portable.zip"), startMenuShortcutPath: join(namespaceRoot, "start-menu.lnk"), tarballsRoot: join(namespaceRoot, "tarballs"), userDesktopShortcutPath: join(namespaceRoot, "desktop", "user.lnk"), diff --git a/tools/pack/tests/win-targets.test.ts b/tools/pack/tests/win-targets.test.ts new file mode 100644 index 000000000..7c8e7d740 --- /dev/null +++ b/tools/pack/tests/win-targets.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveElectronBuilderWinTargets, + resolveWinTargets, + shouldBuildWinNsisInstaller, + shouldBuildWinPortableZip, +} from "../src/win/report.js"; + +describe("resolveWinTargets", () => { + it("returns the full target set including the portable zip for `all`", () => { + expect(resolveWinTargets("all")).toEqual(["dir", "nsis", "zip"]); + }); + + it("returns only the requested single target", () => { + expect(resolveWinTargets("dir")).toEqual(["dir"]); + expect(resolveWinTargets("nsis")).toEqual(["nsis"]); + expect(resolveWinTargets("zip")).toEqual(["zip"]); + }); +}); + +describe("resolveElectronBuilderWinTargets", () => { + it("hides the portable zip from electron-builder because it is built from the cached unpacked dir", () => { + expect(resolveElectronBuilderWinTargets("zip")).toEqual(["dir"]); + expect(resolveElectronBuilderWinTargets("all")).toEqual(["dir", "nsis"]); + expect(resolveElectronBuilderWinTargets("nsis")).toEqual(["nsis"]); + expect(resolveElectronBuilderWinTargets("dir")).toEqual(["dir"]); + }); +}); + +describe("shouldBuildWinNsisInstaller / shouldBuildWinPortableZip", () => { + it("flags the NSIS installer and portable zip independently", () => { + expect(shouldBuildWinNsisInstaller("nsis")).toBe(true); + expect(shouldBuildWinNsisInstaller("all")).toBe(true); + expect(shouldBuildWinNsisInstaller("zip")).toBe(false); + expect(shouldBuildWinNsisInstaller("dir")).toBe(false); + + expect(shouldBuildWinPortableZip("zip")).toBe(true); + expect(shouldBuildWinPortableZip("all")).toBe(true); + expect(shouldBuildWinPortableZip("nsis")).toBe(false); + expect(shouldBuildWinPortableZip("dir")).toBe(false); + }); +});