From bfcafc81fddd3683841f6bd931ca300787ee1a39 Mon Sep 17 00:00:00 2001 From: PerishFire <39043006+PerishCode@users.noreply.github.com> Date: Tue, 26 May 2026 14:14:44 +0800 Subject: [PATCH] feat(pack): add Windows portable zip target alongside NSIS installer (#2937) Adds a new `--to zip` (and `--to all`) tools-pack Windows build target that produces a portable `.zip` from the cached `win-unpacked` tree using the bundled 7z. The zip lays files at the archive root so users can extract it anywhere and launch `Open Design.exe` without going through the NSIS installer, addressing the no-install download request. Release plumbing is updated to publish the portable zip and its sha256 beside the existing installer on R2 for beta, preview, and stable channels (default on, gated by `WINDOWS_INCLUDE_ZIP`/`WIN_INCLUDE_ZIP`). The electron-updater `latest.yml` feed continues to point only at the installer; the zip is a manual-download convenience and is intentionally excluded from the in-app updater. Closes #1121 Generated-By: looper 0.0.0-dev (runner=worker, agent=claude-code) Co-authored-by: libertecode --- .github/scripts/release/assets/win.ps1 | 29 ++++++++++--- .../scripts/release/r2/publish-platform.ts | 12 +++++- .github/scripts/release/r2/publish.sh | 28 ++++++++++-- .github/scripts/release/r2/summary.sh | 6 ++- .github/scripts/release/r2/verify.sh | 3 ++ .github/workflows/release-beta.yml | 4 +- .github/workflows/release-preview.yml | 6 ++- .github/workflows/release-stable.yml | 6 ++- tools/pack/src/config.ts | 2 +- tools/pack/src/index.ts | 2 +- tools/pack/src/win/build.ts | 1 + tools/pack/src/win/builder.ts | 19 ++++++-- tools/pack/src/win/paths.ts | 1 + tools/pack/src/win/report.ts | 26 ++++++++++- tools/pack/src/win/types.ts | 5 ++- tools/pack/src/win/zip.ts | 41 ++++++++++++++++++ tools/pack/tests/config.test.ts | 9 ++++ tools/pack/tests/win-builder.test.ts | 1 + tools/pack/tests/win-targets.test.ts | 43 +++++++++++++++++++ 19 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 tools/pack/src/win/zip.ts create mode 100644 tools/pack/tests/win-targets.test.ts 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); + }); +});