feat(desktop): build Windows arm64 alongside x64

Multi-arch Windows build in one electron-builder pass, with the website
self-healing across the rename. Asset names go from
`Paseo-Setup-${version}.exe` to `Paseo-Setup-${version}-{x64,arm64}.exe`.

The website now reads the actual installer filename off the latest
GitHub release, so legacy single-arch releases keep rendering a single
"Download" pill while dual-arch releases promote to "Intel / x64" +
"ARM64" automatically. No homepage code change needed when ARM ships.
This commit is contained in:
Mohamed Boudra
2026-04-30 11:39:56 +07:00
parent a7e1753834
commit 9c3c0f9140
7 changed files with 77 additions and 20 deletions

View File

@@ -355,7 +355,7 @@ jobs:
PASEO_DESKTOP_SMOKE: "1" PASEO_DESKTOP_SMOKE: "1"
run: | run: |
set -euo pipefail set -euo pipefail
build_args=(-- --publish never --win --x64) build_args=(-- --publish never --win --x64 --arm64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL") build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"

View File

@@ -49,13 +49,20 @@ linux:
- rpm - rpm
- tar.gz - tar.gz
win: win:
artifactName: "Paseo-Setup-${version}-${arch}.${ext}"
icon: assets/icon.ico icon: assets/icon.ico
extraResources: extraResources:
- from: bin/paseo.cmd - from: bin/paseo.cmd
to: bin/paseo.cmd to: bin/paseo.cmd
target: target:
- nsis - target: nsis
- zip arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
nsis: nsis:
oneClick: false oneClick: false
perMachine: false perMachine: false

View File

@@ -1030,9 +1030,9 @@ function GetStarted() {
} }
function DownloadButton() { function DownloadButton() {
const { version } = useRelease(); const release = useRelease();
const detectedPlatform = useDetectedPlatform(); const detectedPlatform = useDetectedPlatform();
const primary = getDownloadOptions(version).find((o) => o.platform === detectedPlatform)!; const primary = getDownloadOptions(release).find((o) => o.platform === detectedPlatform)!;
const PrimaryIcon = primary.icon; const PrimaryIcon = primary.icon;
return ( return (

View File

@@ -4,7 +4,14 @@ export function releaseBase(version: string) {
return `https://github.com/getpaseo/paseo/releases/download/v${version}`; return `https://github.com/getpaseo/paseo/releases/download/v${version}`;
} }
export function downloadUrls(version: string) { export interface ReleaseAssetInfo {
version: string;
windowsX64Asset: string | null;
windowsArm64Asset: string | null;
}
export function downloadUrls(release: ReleaseAssetInfo) {
const { version, windowsX64Asset, windowsArm64Asset } = release;
const base = releaseBase(version); const base = releaseBase(version);
return { return {
macAppleSilicon: `${base}/Paseo-${version}-arm64.dmg`, macAppleSilicon: `${base}/Paseo-${version}-arm64.dmg`,
@@ -12,7 +19,8 @@ export function downloadUrls(version: string) {
linuxAppImage: `${base}/Paseo-${version}-x86_64.AppImage`, linuxAppImage: `${base}/Paseo-${version}-x86_64.AppImage`,
linuxDeb: `${base}/Paseo-${version}-amd64.deb`, linuxDeb: `${base}/Paseo-${version}-amd64.deb`,
linuxRpm: `${base}/Paseo-${version}-x86_64.rpm`, linuxRpm: `${base}/Paseo-${version}-x86_64.rpm`,
windowsExe: `${base}/Paseo-Setup-${version}.exe`, windowsExeX64: `${base}/${windowsX64Asset ?? `Paseo-Setup-${version}.exe`}`,
windowsExeArm64: windowsArm64Asset ? `${base}/${windowsArm64Asset}` : null,
androidApk: `${base}/paseo-v${version}-android.apk`, androidApk: `${base}/paseo-v${version}-android.apk`,
}; };
} }
@@ -30,8 +38,8 @@ export interface DownloadOption {
icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement; icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement;
} }
export function getDownloadOptions(version: string): DownloadOption[] { export function getDownloadOptions(release: ReleaseAssetInfo): DownloadOption[] {
const urls = downloadUrls(version); const urls = downloadUrls(release);
return [ return [
{ {
platform: "mac-silicon", platform: "mac-silicon",
@@ -48,7 +56,7 @@ export function getDownloadOptions(version: string): DownloadOption[] {
{ {
platform: "windows", platform: "windows",
label: "Windows", label: "Windows",
href: urls.windowsExe, href: urls.windowsExeX64,
icon: WindowsIcon, icon: WindowsIcon,
}, },
{ {

View File

@@ -15,7 +15,7 @@ interface GitHubRelease {
const REQUIRED_ASSET_PATTERNS = [ const REQUIRED_ASSET_PATTERNS = [
/Paseo-.*-arm64\.dmg$/, // Mac Apple Silicon /Paseo-.*-arm64\.dmg$/, // Mac Apple Silicon
/Paseo-.*-x86_64\.AppImage$/, // Linux AppImage /Paseo-.*-x86_64\.AppImage$/, // Linux AppImage
/Paseo-Setup-.*\.exe$/, // Windows /Paseo-Setup-.*\.exe$/, // Windows (any arch)
]; ];
function hasRequiredAssets(release: GitHubRelease): boolean { function hasRequiredAssets(release: GitHubRelease): boolean {
@@ -24,14 +24,40 @@ function hasRequiredAssets(release: GitHubRelease): boolean {
); );
} }
function pickWindowsAssets(assets: GitHubAsset[]) {
const x64Suffixed = assets.find((a) => /Paseo-Setup-.*-x64\.exe$/.test(a.name));
const arm64 = assets.find((a) => /Paseo-Setup-.*-arm64\.exe$/.test(a.name));
const legacy = assets.find(
(a) =>
/Paseo-Setup-.*\.exe$/.test(a.name) &&
!a.name.endsWith("-x64.exe") &&
!a.name.endsWith("-arm64.exe"),
);
return {
x64: (x64Suffixed ?? legacy)?.name ?? null,
arm64: arm64?.name ?? null,
};
}
function versionFromTag(tag: string): string { function versionFromTag(tag: string): string {
return tag.replace(/^v/, ""); return tag.replace(/^v/, "");
} }
interface ReleaseInfo {
version: string;
windowsX64Asset: string | null;
windowsArm64Asset: string | null;
}
const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10";
async function fetchLatestReadyRelease(): Promise<string> { async function fetchLatestReadyRelease(): Promise<ReleaseInfo> {
const fallback = websitePackage.version.replace(/-.*$/, ""); const fallbackVersion = websitePackage.version.replace(/-.*$/, "");
const fallback: ReleaseInfo = {
version: fallbackVersion,
windowsX64Asset: `Paseo-Setup-${fallbackVersion}.exe`,
windowsArm64Asset: null,
};
try { try {
const res = await fetch(GITHUB_RELEASES_URL, { const res = await fetch(GITHUB_RELEASES_URL, {
@@ -52,13 +78,18 @@ async function fetchLatestReadyRelease(): Promise<string> {
const releases = (await res.json()) as GitHubRelease[]; const releases = (await res.json()) as GitHubRelease[];
const ready = releases.find((r) => !r.prerelease && !r.draft && hasRequiredAssets(r)); const ready = releases.find((r) => !r.prerelease && !r.draft && hasRequiredAssets(r));
return ready ? versionFromTag(ready.tag_name) : fallback; if (!ready) return fallback;
const win = pickWindowsAssets(ready.assets);
return {
version: versionFromTag(ready.tag_name),
windowsX64Asset: win.x64,
windowsArm64Asset: win.arm64,
};
} catch { } catch {
return fallback; return fallback;
} }
} }
export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => { export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => {
const version = await fetchLatestReadyRelease(); return fetchLatestReadyRelease();
return { version };
}); });

View File

@@ -6,13 +6,19 @@ import { getStarCount } from "~/stars";
interface ReleaseContext { interface ReleaseContext {
version: string; version: string;
windowsX64Asset: string | null;
windowsArm64Asset: string | null;
} }
interface StarsContext { interface StarsContext {
stars: string; stars: string;
} }
const ReleaseCtx = createContext<ReleaseContext>({ version: "" }); const ReleaseCtx = createContext<ReleaseContext>({
version: "",
windowsX64Asset: null,
windowsArm64Asset: null,
});
const StarsCtx = createContext<StarsContext>({ stars: "" }); const StarsCtx = createContext<StarsContext>({ stars: "" });
const PLAUSIBLE_INIT_SCRIPT = { const PLAUSIBLE_INIT_SCRIPT = {

View File

@@ -33,8 +33,9 @@ const homebrewTrigger = (
); );
function Download() { function Download() {
const { version } = useRelease(); const release = useRelease();
const urls = downloadUrls(version); const { version } = release;
const urls = downloadUrls(release);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -130,7 +131,11 @@ function Download() {
<span className="font-medium">Windows</span> <span className="font-medium">Windows</span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<DownloadPill href={urls.windowsExe} label="Download" /> <DownloadPill
href={urls.windowsExeX64}
label={urls.windowsExeArm64 ? "Intel / x64" : "Download"}
/>
{urls.windowsExeArm64 && <DownloadPill href={urls.windowsExeArm64} label="ARM64" />}
</div> </div>
</div> </div>