// SPDX-License-Identifier: AGPL-3.0-or-later use crate::common::{ CalverEnv, CommandSpec, append_github_env, append_github_output, append_github_path, capture, collect_files, command_succeeds, copy_dir_contents, count_files, download_file, env_bool, env_string, output_bytes, output_text, parse_bool, path_to_s3_key, remove_dir_if_exists, remove_file_if_exists, require_any_env, require_env, require_home, resolve_calver, run_command, runner_temp, title_case, trim_option, }; use crate::functions::write_json_pretty; use crate::release::{ DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, DesktopReleaseAsset, DesktopReleaseDescriptor, desktop_release_asset_name, desktop_release_coordinates, desktop_release_descriptor_filename, desktop_release_product, desktop_release_shipped_formats, desktop_release_update_payload_suffix, desktop_release_updater_feeds, validate_desktop_release_descriptor, }; use anyhow::{Context, Result, anyhow, bail, ensure}; use chrono::Utc; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::ffi::{OsStr, OsString}; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; use tempfile::TempDir; use walkdir::WalkDir; use zip::write::SimpleFileOptions; const PACKAGE_ORIGIN_BASE: &str = "https://pkgs.fluxer.com"; const PNPM_VERSION: &str = "12.4.2"; const RUST_TOOLCHAIN: &str = "1.98.1"; const LINUX_PIPEWIRE_VERSION: &str = "0.3.65"; const LINUX_PIPEWIRE_SOURCE_SHA256: &str = "bb76f938136d0ce8c35bffa99e002dc2dbaeab5e14c6c34154e7f750013d1d6b"; const LINUX_LIBFIDO2_VERSION: &str = "1.16.0"; const LINUX_LIBFIDO2_SOURCE_SHA256: &str = "8c2b6fb279b5b42e9ac92ade71832e485852647b53607c43baaafbbcecea04e4"; pub(crate) const MACOS_UNIVERSAL_ARCH: &str = "universal"; const MACOS_MINIMUM_SYSTEM_VERSION: &str = "13.0"; const DESKTOP_PAYLOAD_PREFIX: &str = "desktop"; #[derive(Debug, Args, Clone)] pub struct BuildDesktopArgs { #[arg(long, value_enum)] step: DesktopStep, #[arg(long)] channel: Option, #[arg(long)] skip_targets: Option, #[arg(long)] skip_windows: Option, #[arg(long)] skip_windows_x64: Option, #[arg(long)] skip_windows_arm64: Option, #[arg(long)] skip_macos: Option, #[arg(long)] skip_linux: Option, #[arg(long)] skip_linux_x64: Option, #[arg(long)] skip_linux_arm64: Option, } #[derive(Debug, Clone, Copy, ValueEnum)] #[clap(rename_all = "snake_case")] enum DesktopStep { SetMetadata, SetMatrix, WindowsPaths, SetWorkdirUnix, EnsurePython3Windows, SetupPnpm, ResolvePnpmStoreWindows, ResolvePnpmStoreUnix, InstallSetuptoolsWindowsArm64, InstallSetuptoolsMacos, InstallLinuxDeps, InstallMsvcArm64Tools, InstallRustWindowsTargets, InstallDependencies, UpdateVersion, SetBuildChannel, BuildElectronMain, InstallVelopackCli, BuildAppMacos, VerifyBundleId, BuildAppWindows, ValidateWindowsSigningInputs, WriteWindowsSigningMetadata, ResolveWindowsUnpackedDir, VerifyWindowsUnpackedSignatures, PackageAppWindowsVelopack, AnalyseVelopackPaths, BuildAppLinux, BuildAppimageUpdateFeed, CreatePortableZipWindows, VerifyWindowsSignedArtifacts, PrepareArtifactsWindows, PrepareArtifactsUnix, NormaliseUpdaterYaml, GenerateChecksumsUnix, GenerateChecksumsWindows, StageHandoff, BuildPayload, PrepareReleaseAssets, BuildSummary, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Platform { platform: &'static str, arch: &'static str, os: &'static str, electron_arch: &'static str, } const PLATFORMS: &[Platform] = &[ Platform { platform: "windows", arch: "x64", os: "windows-2025", electron_arch: "x64", }, Platform { platform: "windows", arch: "arm64", os: "windows-2025", electron_arch: "arm64", }, Platform { platform: "macos", arch: MACOS_UNIVERSAL_ARCH, os: "fluxer-desktop-macos-arm64", electron_arch: MACOS_UNIVERSAL_ARCH, }, Platform { platform: "linux", arch: "x64", os: "ubuntu-22.04", electron_arch: "x64", }, Platform { platform: "linux", arch: "arm64", os: "ubuntu-22.04-arm", electron_arch: "arm64", }, ]; pub async fn run(args: BuildDesktopArgs) -> Result<()> { match args.step { DesktopStep::SetMetadata => { let channel = args .channel .clone() .filter(|value| !value.is_empty()) .or_else(|| env_string("CHANNEL")) .unwrap_or_else(|| "stable".to_string()); set_metadata_step(&channel) } DesktopStep::SetMatrix => set_matrix_step(&args), DesktopStep::WindowsPaths => windows_paths_step().await, DesktopStep::SetWorkdirUnix => set_workdir_unix_step(), DesktopStep::EnsurePython3Windows => ensure_python3_windows_step(), DesktopStep::SetupPnpm => setup_pnpm_step(), DesktopStep::ResolvePnpmStoreWindows | DesktopStep::ResolvePnpmStoreUnix => { resolve_pnpm_store_step() } DesktopStep::InstallSetuptoolsWindowsArm64 => install_setuptools_windows_arm64_step(), DesktopStep::InstallSetuptoolsMacos => install_setuptools_macos_step(), DesktopStep::InstallLinuxDeps => install_linux_deps_step().await, DesktopStep::InstallMsvcArm64Tools => install_msvc_arm64_tools_step(), DesktopStep::InstallRustWindowsTargets => install_rust_windows_targets_step(), DesktopStep::InstallDependencies => { run_command(pnpm_command()?.args(["install", "--frozen-lockfile"])) } DesktopStep::UpdateVersion => run_command(pnpm_command()?.args([ "version", &require_env("VERSION")?, "--no-git-tag-version", "--allow-same-version", ])), DesktopStep::SetBuildChannel => set_build_channel_step(), DesktopStep::BuildElectronMain => build_electron_main_step(), DesktopStep::InstallVelopackCli => install_velopack_cli_step(), DesktopStep::BuildAppMacos => build_app_step(DesktopBuildPlatform::Macos), DesktopStep::VerifyBundleId => verify_bundle_id_step(), DesktopStep::BuildAppWindows => build_app_step(DesktopBuildPlatform::Windows), DesktopStep::ValidateWindowsSigningInputs => validate_windows_signing_inputs_step(), DesktopStep::WriteWindowsSigningMetadata => write_windows_signing_metadata_step(), DesktopStep::ResolveWindowsUnpackedDir => resolve_windows_unpacked_dir_step(), DesktopStep::VerifyWindowsUnpackedSignatures => verify_windows_unpacked_signatures_step(), DesktopStep::PackageAppWindowsVelopack => package_app_windows_velopack_step(), DesktopStep::AnalyseVelopackPaths => analyse_velopack_paths_step(), DesktopStep::BuildAppLinux => build_app_step(DesktopBuildPlatform::Linux), DesktopStep::BuildAppimageUpdateFeed => crate::appimage::build_update_feed_step(), DesktopStep::CreatePortableZipWindows => create_portable_zip_windows_step(), DesktopStep::VerifyWindowsSignedArtifacts => verify_windows_signed_artifacts_step(), DesktopStep::PrepareArtifactsWindows => prepare_artifacts_windows_step(), DesktopStep::PrepareArtifactsUnix => prepare_artifacts_unix_step(), DesktopStep::NormaliseUpdaterYaml => normalise_updater_yaml_step(), DesktopStep::GenerateChecksumsUnix => generate_checksums_step(&[ ArtifactChecksumKind::Extension("exe"), ArtifactChecksumKind::Extension("dmg"), ArtifactChecksumKind::Extension("zip"), ArtifactChecksumKind::Extension("AppImage"), ArtifactChecksumKind::Extension("deb"), ArtifactChecksumKind::Extension("rpm"), ArtifactChecksumKind::Suffix(".tar.gz"), ]), DesktopStep::GenerateChecksumsWindows => generate_checksums_step(&[ ArtifactChecksumKind::Extension("exe"), ArtifactChecksumKind::Extension("nupkg"), ArtifactChecksumKind::Extension("zip"), ]), DesktopStep::StageHandoff => stage_handoff_step(), DesktopStep::BuildPayload => build_payload_step(), DesktopStep::PrepareReleaseAssets => prepare_release_assets_step(), DesktopStep::BuildSummary => build_summary_step(), } } fn calver_env_from_process() -> CalverEnv { CalverEnv { build_version: trim_option(env::var("BUILD_VERSION").ok()), fluxer_build_version: trim_option(env::var("FLUXER_BUILD_VERSION").ok()), fluxer_build_date: trim_option(env::var("FLUXER_BUILD_DATE").ok()), } } fn set_metadata_step(channel: &str) -> Result<()> { let version = resolve_calver(&calver_env_from_process(), Utc::now())?; let pub_date = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let build_channel = if channel == "canary" { "canary" } else { "stable" }; let source_sha = resolve_source_sha()?; append_github_output(&[ ("version", version.as_str()), ("pub_date", pub_date.as_str()), ("channel", channel), ("build_channel", build_channel), ("source_sha", source_sha.as_str()), ]) } fn set_build_channel_step() -> Result<()> { let channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); write_build_channel_file(&resolve_desktop_dir()?, &channel) } fn resolve_desktop_dir() -> Result { let cwd = env::current_dir().context("Failed to resolve current directory")?; if cwd.file_name().and_then(|value| value.to_str()) == Some("fluxer_desktop") { return Ok(cwd); } if cwd.join("fluxer_desktop").is_dir() { return Ok(cwd.join("fluxer_desktop")); } Err(anyhow!( "Could not resolve fluxer_desktop directory from {}", cwd.display() )) } pub(crate) fn write_build_channel_file(root: &Path, channel: &str) -> Result<()> { ensure!( matches!(channel, "stable" | "canary"), "Invalid BUILD_CHANNEL: {channel}. Must be 'stable' or 'canary'." ); let path = root.join("src/common/BuildChannel.ts"); let content = build_channel_content(channel); if path .exists() .then(|| fs::read_to_string(&path)) .transpose() .with_context(|| format!("Failed to read {}", path.display()))? .as_deref() == Some(content.as_str()) { println!("Build channel already set to: {channel}"); return Ok(()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("Failed to create {}", parent.display()))?; } fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; println!("Set build channel to: {channel}"); Ok(()) } fn build_channel_content(channel: &str) -> String { format!( "// SPDX-License-Identifier: AGPL-3.0-or-later\n\n\ export type BuildChannel = 'stable' | 'canary';\n\n\ export const BUILD_CHANNEL = '{channel}' as BuildChannel;\n\ export const IS_CANARY = BUILD_CHANNEL === 'canary';\n\ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" ) } fn resolve_source_sha() -> Result { let workspace = env::var("GITHUB_WORKSPACE").unwrap_or_else(|_| ".".to_string()); let workspace = PathBuf::from(workspace); if workspace.join(".git").exists() { output_text(CommandSpec::new("git").args([ "-C", workspace.to_string_lossy().as_ref(), "rev-parse", "HEAD", ])) } else { output_text(CommandSpec::new("git").args(["rev-parse", "HEAD"])) } } fn set_matrix_step(args: &BuildDesktopArgs) -> Result<()> { let platforms = selected_platforms(args)?; let include = platforms .iter() .copied() .map(platform_json) .collect::>() .join(","); let matrix = format!("{{\"include\":[{include}]}}"); append_github_output(&[("matrix", matrix.as_str())]) } fn selected_platforms(args: &BuildDesktopArgs) -> Result> { let skip_targets = skip_target_set(args)?; Ok(PLATFORMS .iter() .copied() .filter(|platform| !skip_platform(*platform, args, &skip_targets)) .collect()) } fn skip_target_set(args: &BuildDesktopArgs) -> Result> { let raw = args .skip_targets .clone() .filter(|value| !value.is_empty()) .or_else(|| env_string("SKIP_TARGETS")) .unwrap_or_default(); let valid = BTreeSet::from([ "windows", "windows-x64", "windows-arm64", "macos", "macos-universal", "linux", "linux-x64", "linux-arm64", ]); let mut targets = BTreeSet::new(); for token in raw.split(|character: char| character == ',' || character.is_whitespace()) { let target = token.trim().to_ascii_lowercase().replace('_', "-"); if target.is_empty() { continue; } ensure!( valid.contains(target.as_str()), "Unknown desktop skip target: {target}. Expected one of: {}", valid.iter().copied().collect::>().join(", ") ); targets.insert(target); } Ok(targets) } fn skip_platform( platform: Platform, args: &BuildDesktopArgs, skip_targets: &BTreeSet, ) -> bool { let flag = |arg: &Option, env_name: &str| { arg.as_deref() .map(parse_bool) .unwrap_or_else(|| env_bool(env_name)) }; let platform_arch = format!("{}-{}", platform.platform, platform.arch); if skip_targets.contains(platform.platform) || skip_targets.contains(platform_arch.as_str()) { return true; } match platform.platform { "windows" => { flag(&args.skip_windows, "SKIP_WINDOWS") || (platform.arch == "x64" && flag(&args.skip_windows_x64, "SKIP_WINDOWS_X64")) || (platform.arch == "arm64" && flag(&args.skip_windows_arm64, "SKIP_WINDOWS_ARM64")) } "macos" => flag(&args.skip_macos, "SKIP_MACOS"), "linux" => { flag(&args.skip_linux, "SKIP_LINUX") || (platform.arch == "x64" && flag(&args.skip_linux_x64, "SKIP_LINUX_X64")) || (platform.arch == "arm64" && flag(&args.skip_linux_arm64, "SKIP_LINUX_ARM64")) } _ => false, } } fn platform_json(platform: Platform) -> String { format!( "{{\"platform\":\"{}\",\"arch\":\"{}\",\"os\":\"{}\",\"electron_arch\":\"{}\"}}", platform.platform, platform.arch, platform.os, platform.electron_arch ) } fn workspace_dir() -> PathBuf { env::var("GITHUB_WORKSPACE") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(".")) } fn workdir() -> PathBuf { env::var("WORKDIR") .map(PathBuf::from) .unwrap_or_else(|_| workspace_dir()) } fn desktop_dist_dir() -> PathBuf { workdir().join("fluxer_desktop").join("dist-electron") } async fn windows_paths_step() -> Result<()> { let github_workspace = require_env("GITHUB_WORKSPACE")?; let target = env::var("SUBST_TARGET").unwrap_or(github_workspace.clone()); run_command(CommandSpec::new("subst").args(["W:", target.as_str()]))?; let temp = Path::new(r"C:\t"); let eb_cache = Path::new(r"C:\ebcache"); fs::create_dir_all(temp).context("Failed to create C:\\t")?; fs::create_dir_all(eb_cache).context("Failed to create C:\\ebcache")?; let arch = require_env("ARCH")?; let store_dir = PathBuf::from(&github_workspace).join(format!("pnpm-store-{arch}")); fs::create_dir_all(&store_dir) .with_context(|| format!("Failed to create {}", store_dir.display()))?; append_github_env(&[ ("WORKDIR", "W:"), ("TEMP", r"C:\t"), ("TMP", r"C:\t"), ("ELECTRON_BUILDER_CACHE", r"C:\ebcache"), ( "PNPM_CONFIG_STORE_DIR", store_dir.to_string_lossy().as_ref(), ), ( "pnpm_config_store_dir", store_dir.to_string_lossy().as_ref(), ), ])?; run_command(CommandSpec::new("git").args(["config", "--global", "core.longpaths", "true"]))?; let git_link = Path::new(r"C:\Program Files\Git\usr\bin\link.exe"); if git_link.exists() { let disabled = git_link.with_file_name("link.exe.disabled"); remove_file_if_exists(&disabled)?; fs::rename(git_link, &disabled) .with_context(|| format!("Failed to rename {}", git_link.display()))?; } let llvm_bin = Path::new(r"C:\Program Files\LLVM\bin"); let clang = llvm_bin.join("clang.exe"); if !clang.exists() { println!("Installing LLVM..."); let installer = runner_temp().join("LLVM-win64.exe"); download_file( "https://github.com/llvm/llvm-project/releases/download/llvmorg-19.1.5/LLVM-19.1.5-win64.exe", &installer, ).await?; run_command(CommandSpec::new(&installer).arg("/S"))?; } ensure!( clang.exists(), "clang.exe not available at {}", clang.display() ); append_github_path(llvm_bin)?; println!("Clang: {}", llvm_bin.display()); Ok(()) } fn set_workdir_unix_step() -> Result<()> { let workspace = env::var("SUBST_TARGET") .or_else(|_| env::var("GITHUB_WORKSPACE")) .unwrap_or_else(|_| ".".to_string()); let mut env_pairs = vec![("WORKDIR", workspace.clone())]; if env::consts::OS == "macos" { let arch = require_env("ARCH")?; let home = require_home()?; let store_dir = home .join("Library") .join("pnpm") .join(format!("store-{arch}")); fs::create_dir_all(&store_dir) .with_context(|| format!("Failed to create {}", store_dir.display()))?; env_pairs.push(( "PNPM_CONFIG_STORE_DIR", store_dir.to_string_lossy().to_string(), )); env_pairs.push(( "pnpm_config_store_dir", store_dir.to_string_lossy().to_string(), )); } let pairs = env_pairs .iter() .map(|(key, value)| (*key, value.as_str())) .collect::>(); append_github_env(&pairs) } fn ensure_python3_windows_step() -> Result<()> { let python = output_text(CommandSpec::new("python").args(["-c", "import sys; print(sys.executable)"]))?; let python = PathBuf::from(python); let target = python .parent() .ok_or_else(|| anyhow!("python executable has no parent: {}", python.display()))? .join("python3.exe"); if !target.exists() { fs::copy(&python, &target).with_context(|| { format!( "Failed to copy {} to {}", python.display(), target.display() ) })?; } Ok(()) } fn setup_pnpm_step() -> Result<()> { let npm = npm_program()?; let pnpm_package = format!("pnpm@{PNPM_VERSION}"); run_command(CommandSpec::new(npm.clone()).args(["install", "--global", &pnpm_package]))?; let npm_prefix = output_text(CommandSpec::new(npm).args(["prefix", "--global"])) .context("Failed to resolve global npm prefix after installing pnpm")?; let npm_bin = if cfg!(windows) { PathBuf::from(npm_prefix) } else { PathBuf::from(npm_prefix).join("bin") }; append_github_path(&npm_bin)?; for file_name in ["pnpm.cmd", "pnpm.exe", "pnpm"] { let candidate = npm_bin.join(file_name); if candidate.exists() { return run_command(CommandSpec::new(candidate.into_os_string()).arg("--version")); } } bail!( "pnpm not found in {} after installing {pnpm_package}", npm_bin.display() ) } fn pnpm_command() -> Result { Ok(CommandSpec::new(pnpm_program()?)) } fn pnpm_program() -> Result { if command_succeeds(CommandSpec::new("pnpm").arg("--version")) { return Ok(OsString::from("pnpm")); } if cfg!(windows) { for candidate in pnpm_windows_candidates() { if candidate.exists() { return Ok(candidate.into_os_string()); } } } bail!("pnpm not found on PATH") } fn pnpm_windows_candidates() -> Vec { let mut candidates = Vec::new(); if let Ok(npm) = npm_program() && let Ok(prefix) = output_text(CommandSpec::new(npm).args(["prefix", "--global"])) { push_windows_command_candidates(&mut candidates, Path::new(&prefix), "pnpm"); } if let Ok(node_dir) = node_executable_dir() { push_windows_command_candidates(&mut candidates, &node_dir, "pnpm"); } candidates } fn push_windows_command_candidates(candidates: &mut Vec, dir: &Path, command: &str) { for extension in ["cmd", "exe", ""] { let file_name = if extension.is_empty() { command.to_string() } else { format!("{command}.{extension}") }; candidates.push(dir.join(file_name)); } } fn npm_program() -> Result { if command_succeeds(CommandSpec::new("npm").arg("--version")) { return Ok(OsString::from("npm")); } if cfg!(windows) { let node_dir = node_executable_dir().context("Failed to locate Node.js while resolving npm")?; for file_name in ["npm.cmd", "npm.exe", "npm"] { let candidate = node_dir.join(file_name); if candidate.exists() { return Ok(candidate.into_os_string()); } } bail!( "npm not found on PATH or next to Node.js at {}", node_dir.display() ); } bail!("npm not found on PATH") } fn node_executable_dir() -> Result { let node = output_text(CommandSpec::new("node").args(["-p", "process.execPath"]))?; let node = PathBuf::from(node); node.parent() .map(Path::to_path_buf) .ok_or_else(|| anyhow!("Node.js executable has no parent: {}", node.display())) } fn resolve_pnpm_store_step() -> Result<()> { let store = output_text(pnpm_command()?.args(["store", "path", "--silent"]))?; fs::create_dir_all(&store).with_context(|| format!("Failed to create pnpm store {store}"))?; append_github_env(&[("PNPM_STORE_PATH", store.as_str())]) } fn install_setuptools_windows_arm64_step() -> Result<()> { run_command(CommandSpec::new("python").args(["-m", "pip", "install", "--upgrade", "pip"]))?; run_command(CommandSpec::new("python").args([ "-m", "pip", "install", "setuptools>=69", "wheel", ])) } fn install_setuptools_macos_step() -> Result<()> { let brew = if command_succeeds(CommandSpec::new("brew").arg("--version")) { PathBuf::from("brew") } else if Path::new("/opt/homebrew/bin/brew").exists() { PathBuf::from("/opt/homebrew/bin/brew") } else { PathBuf::from("/usr/local/bin/brew") }; run_command(CommandSpec::new(brew).args(["install", "python-setuptools"])) } async fn install_linux_deps_step() -> Result<()> { let apt_conf = runner_temp().join("99fluxer-ci-network"); fs::write( &apt_conf, r#"Acquire::Retries "6"; Acquire::ForceIPv4 "true"; Acquire::http::Timeout "30"; Acquire::https::Timeout "30"; DPkg::Lock::Timeout "120"; "#, ) .with_context(|| format!("Failed to write {}", apt_conf.display()))?; run_command(CommandSpec::new("sudo").args([ "cp", apt_conf.to_string_lossy().as_ref(), "/etc/apt/apt.conf.d/99fluxer-ci-network", ]))?; rewrite_ubuntu_ports_sources()?; apt_get(&["update"])?; let _ = apt_get(&["remove", "-y", "--purge", "liboss4-salsa-asound2"]); apt_get(&[ "install", "-y", "--no-install-recommends", "libx11-dev", "libxtst-dev", "libxt-dev", "libxinerama-dev", "libxkbcommon-dev", "libxrandr-dev", "ruby", "ruby-dev", "build-essential", "binutils", "cmake", "meson", "ninja-build", "nasm", "rpm", "desktop-file-utils", "appstream", "libpixman-1-dev", "libcairo2-dev", "libpango1.0-dev", "libjpeg-dev", "libgif-dev", "librsvg2-dev", "libpipewire-0.3-dev", "libspa-0.2-dev", "libdbus-1-dev", "libudev-dev", "libhunspell-dev", "libcbor-dev", "libssl-dev", "zlib1g-dev", "pkg-config", "libegl-dev", "libclang-dev", "clang", "libpulse-dev", ])?; install_linux_pipewire_headers().await?; install_linux_libfido2().await?; run_command(CommandSpec::new("sudo").args(["gem", "install", "--no-document", "fpm"])) } async fn install_linux_pipewire_headers() -> Result<()> { let temp = TempDir::new().context("Failed to create PipeWire header build directory")?; let archive_name = format!("pipewire-{LINUX_PIPEWIRE_VERSION}.tar.gz"); let archive_path = temp.path().join(&archive_name); let source_dir = temp.path().join("source"); let build_dir = temp.path().join("build"); let staging_dir = temp.path().join("stage"); let source_url = format!( "https://gitlab.freedesktop.org/pipewire/pipewire/-/archive/{LINUX_PIPEWIRE_VERSION}/{archive_name}" ); let multiarch = linux_multiarch()?; let install_library_dir = Path::new("/usr/local/lib").join(&multiarch); let install_pkgconfig_dir = install_library_dir.join("pkgconfig"); let libdir_arg = format!("--libdir=lib/{multiarch}"); let system_pipewire_library = fs::canonicalize(linux_loader_path("libpipewire-0.3.so.0")?) .context("Failed to resolve the system PipeWire library")?; ensure!( !system_pipewire_library.starts_with("/usr/local"), "Expected the system PipeWire runtime, got {}", system_pipewire_library.display() ); download_file(&source_url, &archive_path).await?; let archive_sha256 = sha256_file(&archive_path)?; ensure!( archive_sha256 == LINUX_PIPEWIRE_SOURCE_SHA256, "PipeWire source checksum mismatch: expected {}, got {}", LINUX_PIPEWIRE_SOURCE_SHA256, archive_sha256 ); fs::create_dir_all(&source_dir) .with_context(|| format!("Failed to create {}", source_dir.display()))?; run_command(CommandSpec::new("tar").args([ "-xzf", archive_path.to_string_lossy().as_ref(), "-C", source_dir.to_string_lossy().as_ref(), "--strip-components=1", ]))?; run_command(CommandSpec::new("meson").args([ "setup", build_dir.to_string_lossy().as_ref(), source_dir.to_string_lossy().as_ref(), "--buildtype=release", "--default-library=shared", "--prefix=/usr/local", &libdir_arg, "--auto-features=disabled", "-Dexamples=disabled", "-Dtests=disabled", "-Dinstalled_tests=disabled", "-Ddocs=disabled", "-Dman=disabled", "-Dspa-plugins=enabled", "-Dpipewire-alsa=disabled", "-Dpipewire-jack=disabled", "-Dpipewire-v4l2=disabled", "-Dsystemd=disabled", "-Ddbus=disabled", "-Dflatpak=disabled", "-Dsession-managers=[]", "-Dlegacy-rtkit=false", ]))?; run_command(CommandSpec::new("meson").args([ "compile", "-C", build_dir.to_string_lossy().as_ref(), ]))?; run_command( CommandSpec::new("meson") .args([ "install", "-C", build_dir.to_string_lossy().as_ref(), "--no-rebuild", ]) .env("DESTDIR", staging_dir.as_os_str()), )?; let staged_prefix = staging_dir.join("usr/local"); let staged_pipewire_headers = staged_prefix.join("include/pipewire-0.3"); let staged_spa_headers = staged_prefix.join("include/spa-0.2"); let staged_pkgconfig_dir = staged_prefix.join(format!("lib/{multiarch}/pkgconfig")); let install_include_dir = Path::new("/usr/local/include"); let install_pipewire_headers = install_include_dir.join("pipewire-0.3"); let install_spa_headers = install_include_dir.join("spa-0.2"); ensure!( !install_pipewire_headers.exists() && !install_spa_headers.exists(), "PipeWire header overlay already exists under {}", install_include_dir.display() ); run_command(CommandSpec::new("sudo").args([ "install", "-d", install_include_dir.to_string_lossy().as_ref(), install_pkgconfig_dir.to_string_lossy().as_ref(), ]))?; run_command(CommandSpec::new("sudo").args([ "cp", "-a", staged_pipewire_headers.to_string_lossy().as_ref(), staged_spa_headers.to_string_lossy().as_ref(), install_include_dir.to_string_lossy().as_ref(), ]))?; for pkgconfig_name in ["libpipewire-0.3.pc", "libspa-0.2.pc"] { run_command( CommandSpec::new("sudo").args([ "install", "-m", "0644", staged_pkgconfig_dir .join(pkgconfig_name) .to_string_lossy() .as_ref(), install_pkgconfig_dir .join(pkgconfig_name) .to_string_lossy() .as_ref(), ]), )?; } let installed_version = output_text(CommandSpec::new("pkg-config").args(["--modversion", "libpipewire-0.3"]))?; ensure!( installed_version == LINUX_PIPEWIRE_VERSION, "Expected PipeWire headers {}, got {}", LINUX_PIPEWIRE_VERSION, installed_version ); for (package, expected_include_flag) in [ ("libpipewire-0.3", "-I/usr/local/include/pipewire-0.3"), ("libspa-0.2", "-I/usr/local/include/spa-0.2"), ] { let pkgconfig_dir = output_text(CommandSpec::new("pkg-config").args(["--variable=pcfiledir", package]))?; ensure!( Path::new(&pkgconfig_dir) == install_pkgconfig_dir, "Expected {package} metadata in {}, got {}", install_pkgconfig_dir.display(), pkgconfig_dir ); let include_dir = output_text(CommandSpec::new("pkg-config").args(["--variable=includedir", package]))?; ensure!( Path::new(&include_dir) == install_include_dir, "Expected {package} headers in {}, got {}", install_include_dir.display(), include_dir ); let include_flags = output_text(CommandSpec::new("pkg-config").args(["--cflags-only-I", package]))?; ensure!( include_flags .split_whitespace() .any(|flag| flag == expected_include_flag), "Expected {package} include flag {expected_include_flag}, got {include_flags}" ); } ensure!( install_pipewire_headers .join("pipewire/pipewire.h") .is_file() && install_spa_headers.join("spa/buffer/meta.h").is_file() && install_spa_headers.join("spa/param/video/raw.h").is_file(), "PipeWire {} header overlay is incomplete", LINUX_PIPEWIRE_VERSION ); let mut local_pipewire_library = None; for entry in fs::read_dir(&install_library_dir) .with_context(|| format!("Failed to read {}", install_library_dir.display()))? { let entry = entry .with_context(|| format!("Failed to inspect {}", install_library_dir.display()))?; if entry .file_name() .to_string_lossy() .starts_with("libpipewire-0.3.") { local_pipewire_library = Some(entry.path()); break; } } if let Some(path) = local_pipewire_library { bail!( "PipeWire header overlay found unexpected library {}", path.display() ); } let loaded_pipewire_library = fs::canonicalize(linux_loader_path("libpipewire-0.3.so.0")?) .context("Failed to resolve the selected PipeWire library")?; ensure!( loaded_pipewire_library == system_pipewire_library, "PipeWire header overlay changed the runtime from {} to {}", system_pipewire_library.display(), loaded_pipewire_library.display() ); println!( "Installed PipeWire {LINUX_PIPEWIRE_VERSION} headers for {}.", system_pipewire_library.display() ); Ok(()) } async fn install_linux_libfido2() -> Result<()> { let temp = TempDir::new().context("Failed to create libfido2 build directory")?; let archive_name = format!("libfido2-{LINUX_LIBFIDO2_VERSION}.tar.gz"); let archive_path = temp.path().join(&archive_name); let source_dir = temp.path().join("source"); let build_dir = temp.path().join("build"); let source_url = format!("https://developers.yubico.com/libfido2/Releases/{archive_name}"); let multiarch = linux_multiarch()?; let install_library_dir = Path::new("/usr/local/lib").join(&multiarch); let install_library_arg = format!("-DCMAKE_INSTALL_LIBDIR=lib/{multiarch}"); download_file(&source_url, &archive_path).await?; let archive_sha256 = sha256_file(&archive_path)?; ensure!( archive_sha256 == LINUX_LIBFIDO2_SOURCE_SHA256, "libfido2 source checksum mismatch: expected {}, got {}", LINUX_LIBFIDO2_SOURCE_SHA256, archive_sha256 ); fs::create_dir_all(&source_dir) .with_context(|| format!("Failed to create {}", source_dir.display()))?; run_command(CommandSpec::new("tar").args([ "-xzf", archive_path.to_string_lossy().as_ref(), "-C", source_dir.to_string_lossy().as_ref(), "--strip-components=1", ]))?; run_command(CommandSpec::new("cmake").args([ "-S", source_dir.to_string_lossy().as_ref(), "-B", build_dir.to_string_lossy().as_ref(), "-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_INSTALL_PREFIX=/usr/local", &install_library_arg, "-DBUILD_SHARED_LIBS=ON", "-DBUILD_STATIC_LIBS=OFF", "-DBUILD_MANPAGES=OFF", "-DBUILD_EXAMPLES=OFF", "-DBUILD_TOOLS=OFF", "-DBUILD_TESTS=OFF", "-DFUZZ=OFF", "-DNFC_LINUX=OFF", "-DUSE_PCSC=OFF", "-DUSE_HIDAPI=OFF", "-DUSE_WINHELLO=OFF", ]))?; run_command(CommandSpec::new("cmake").args([ "--build", build_dir.to_string_lossy().as_ref(), "--config", "Release", "--parallel", ]))?; run_command(CommandSpec::new("sudo").args([ "cmake", "--install", build_dir.to_string_lossy().as_ref(), "--config", "Release", ]))?; run_command(CommandSpec::new("sudo").arg("ldconfig"))?; let installed_version = output_text(CommandSpec::new("pkg-config").args(["--modversion", "libfido2"]))?; ensure!( installed_version.trim() == LINUX_LIBFIDO2_VERSION, "Expected libfido2 {}, got {}", LINUX_LIBFIDO2_VERSION, installed_version.trim() ); let include_dir = output_text(CommandSpec::new("pkg-config").args(["--variable=includedir", "libfido2"]))?; ensure!( Path::new(include_dir.trim()).join("fido/es384.h").is_file(), "libfido2 {} did not install fido/es384.h", LINUX_LIBFIDO2_VERSION ); let library_dir = output_text(CommandSpec::new("pkg-config").args(["--variable=libdir", "libfido2"]))?; ensure!( Path::new(library_dir.trim()) == install_library_dir, "Expected libfido2 library directory {}, got {}", install_library_dir.display(), library_dir.trim() ); let installed_soname = install_library_dir.join("libfido2.so.1"); ensure!( installed_soname.exists(), "libfido2 {} did not install {}", LINUX_LIBFIDO2_VERSION, installed_soname.display() ); let installed_library = fs::canonicalize(&installed_soname) .with_context(|| format!("Failed to resolve {}", installed_soname.display()))?; let loader_path = linux_loader_path("libfido2.so.1")?; let loaded_library = fs::canonicalize(&loader_path) .with_context(|| format!("Failed to resolve {}", loader_path.display()))?; ensure!( loaded_library == installed_library, "Expected the loader to select {}, got {}", installed_library.display(), loaded_library.display() ); println!("Installed libfido2 {LINUX_LIBFIDO2_VERSION} from verified source."); Ok(()) } fn linux_multiarch() -> Result { let multiarch = output_text(CommandSpec::new("gcc").arg("-print-multiarch"))?; ensure!( !multiarch.is_empty() && multiarch .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'), "gcc returned invalid multiarch tuple: {multiarch:?}" ); Ok(multiarch) } fn linux_loader_path(library_name: &str) -> Result { let loader_cache = output_text(CommandSpec::new("ldconfig").arg("-p"))?; loader_cache .lines() .find_map(|line| { let (library, path) = line.trim().split_once("=>")?; (library.split_whitespace().next() == Some(library_name)) .then(|| PathBuf::from(path.trim())) }) .with_context(|| format!("ldconfig did not resolve {library_name}")) } fn rewrite_ubuntu_ports_sources() -> Result<()> { let apt = Path::new("/etc/apt"); if !apt.exists() { return Ok(()); } for entry in WalkDir::new(apt) .into_iter() .filter_map(std::result::Result::ok) .filter(|entry| entry.file_type().is_file()) { let path = entry.path(); let extension = path.extension().and_then(OsStr::to_str); if !matches!(extension, Some("list" | "sources")) { continue; } run_command(CommandSpec::new("sudo").args([ "sed", "-i", "s|http://ports.ubuntu.com/ubuntu-ports|https://ports.ubuntu.com/ubuntu-ports|g", path.to_string_lossy().as_ref(), ]))?; } Ok(()) } fn apt_get(args: &[&str]) -> Result<()> { let mut last_error: Option = None; for attempt in 1..=4 { let mut full_args = vec![ "env", "DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a", "timeout", "--kill-after=30s", "600s", "apt-get", "-o", "Dpkg::Use-Pty=0", "-o", "Acquire::Retries=6", "-o", "Acquire::ForceIPv4=true", "-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30", ]; full_args.extend_from_slice(args); match run_command(CommandSpec::new("sudo").args(full_args)) { Ok(()) => return Ok(()), Err(error) if attempt < 4 => { last_error = Some(error); thread::sleep(Duration::from_secs(attempt * 20)); } Err(error) => return Err(error), } } Err(last_error.unwrap_or_else(|| anyhow!("apt-get failed"))) } fn install_msvc_arm64_tools_step() -> Result<()> { let program_files_x86 = env::var_os("ProgramFiles(x86)") .map(PathBuf::from) .ok_or_else(|| anyhow!("ProgramFiles(x86) is not set on the Windows runner"))?; let installer_dir = program_files_x86 .join("Microsoft Visual Studio") .join("Installer"); let installer = installer_dir.join("setup.exe"); let vswhere = installer_dir.join("vswhere.exe"); ensure!( vswhere.is_file(), "Visual Studio locator not found: {}", vswhere.display() ); let install_path = resolve_visual_studio_install_path(&vswhere)?; if let Some(linker) = find_msvc_arm64_linker(&install_path)? { println!("ARM64 cross link.exe: {}", linker.display()); return Ok(()); } ensure!( installer.is_file(), "Visual Studio installer not found: {}", installer.display() ); run_command(CommandSpec::new(&installer).args([ "modify", "--installPath", install_path.to_string_lossy().as_ref(), "--add", "Microsoft.VisualStudio.Component.VC.Tools.ARM64", "--quiet", "--norestart", "--nocache", ]))?; let deadline = Instant::now() + Duration::from_secs(20 * 60); thread::sleep(Duration::from_secs(10)); while Instant::now() < deadline { if !windows_installer_process_running()? { break; } thread::sleep(Duration::from_secs(10)); } ensure!( Instant::now() < deadline, "VS installer did not finish within the timeout." ); let linker = find_msvc_arm64_linker(&install_path)?.ok_or_else(|| { anyhow!( "ARM64 cross-build tools were not installed under {}\\VC\\Tools\\MSVC\\*\\bin\\HostX64\\arm64", install_path.display() ) })?; println!("ARM64 cross link.exe: {}", linker.display()); Ok(()) } fn resolve_visual_studio_install_path(vswhere: &Path) -> Result { let output = output_text(CommandSpec::new(vswhere).args([ "-products", "*", "-latest", "-prerelease", "-property", "installationPath", ]))?; let paths = output .lines() .map(str::trim) .filter(|line| !line.is_empty()) .collect::>(); ensure!( paths.len() == 1, "vswhere returned {} Visual Studio installation paths; expected exactly one", paths.len() ); let install_path = PathBuf::from(paths[0]); ensure!( install_path.is_dir(), "vswhere returned a missing Visual Studio installation: {}", install_path.display() ); println!("Visual Studio installation: {}", install_path.display()); Ok(install_path) } fn find_msvc_arm64_linker(install_path: &Path) -> Result> { let msvc_root = install_path.join("VC").join("Tools").join("MSVC"); if !msvc_root.is_dir() { return Ok(None); } let mut linkers = Vec::new(); for entry in fs::read_dir(&msvc_root) .with_context(|| format!("Failed to read {}", msvc_root.display()))? { let candidate = entry? .path() .join("bin") .join("HostX64") .join("arm64") .join("link.exe"); if candidate.is_file() { linkers.push(candidate); } } linkers.sort(); Ok(linkers.pop()) } fn windows_installer_process_running() -> Result { let output = output_text(CommandSpec::new("tasklist").args(["/FO", "CSV", "/NH"]))?; let names = [ "setup.exe", "vs_installer.exe", "vs_installershell.exe", "vs_installerservice.exe", "vctip.exe", ]; Ok(output.lines().any(|line| { let first = line .trim_start_matches('"') .split('"') .next() .unwrap_or_default() .to_ascii_lowercase(); names.contains(&first.as_str()) })) } fn install_rust_windows_targets_step() -> Result<()> { let arch = require_env("ARCH")?; let target = if arch == "arm64" { "aarch64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" }; run_command(CommandSpec::new("rustup").args([ "toolchain", "install", RUST_TOOLCHAIN, "--profile", "minimal", ]))?; run_command(CommandSpec::new("rustup").args([ "target", "add", "--toolchain", RUST_TOOLCHAIN, target, ]))?; if let Ok(user_profile) = env::var("USERPROFILE") { let cargo_bin = PathBuf::from(user_profile).join(".cargo").join("bin"); if cargo_bin.exists() && env::var("GITHUB_PATH").is_ok() { append_github_path(&cargo_bin)?; } } run_command(CommandSpec::new("cargo").arg("--version")) } fn build_electron_main_step() -> Result<()> { run_command( pnpm_command()? .arg("build") .env("NODE_ENV", "production") .env("FLUXER_DESKTOP_PRODUCTION", "true"), ) } fn install_velopack_cli_step() -> Result<()> { let tool_dir = env::current_dir() .context("Failed to resolve current directory")? .join(".velopack"); fs::create_dir_all(&tool_dir) .with_context(|| format!("Failed to create {}", tool_dir.display()))?; run_command(CommandSpec::new("dotnet").args([ "tool", "install", "--tool-path", tool_dir.to_string_lossy().as_ref(), "vpk", "--version", "1.2.0", ])) } #[derive(Debug, Clone, Copy)] enum DesktopBuildPlatform { Macos, Windows, Linux, } impl DesktopBuildPlatform { fn electron_builder_target(self) -> &'static str { match self { Self::Macos => "--mac", Self::Windows => "--win", Self::Linux => "--linux", } } fn transient_patterns(self) -> &'static [&'static str] { match self { Self::Windows => &[ "RCX", ".tmp", "EOF", "status code 5", "cannot resolve", "i/o timeout", "connection reset", "TLS handshake", ], Self::Macos | Self::Linux => &[ "EOF", "status code 5", "cannot resolve", "i/o timeout", "connection reset", "TLS handshake", ], } } fn retry_sleep(self) -> Duration { match self { Self::Windows => Duration::from_secs(5), Self::Macos | Self::Linux => Duration::from_secs(10), } } } fn build_app_step(platform: DesktopBuildPlatform) -> Result<()> { let macos_keychain = if matches!(platform, DesktopBuildPlatform::Macos) { Some(validate_macos_signing_env()?) } else { None }; if let Some(keychain) = &macos_keychain { println!( "Using macOS signing keychain for electron-builder: {}", keychain.display() ); } let electron_arch = require_env("ELECTRON_ARCH")?; for attempt in 1..=3 { println!( "::group::electron-builder {:?} attempt {attempt}/3", platform ); let mut command = pnpm_command()? .args([ "exec", "electron-builder", "--config", "electron-builder.config.cjs", platform.electron_builder_target(), &format!("--{electron_arch}"), ]) .env("ELECTRON_ARCH", &electron_arch); if let Some(keychain) = &macos_keychain { command = command .env("CSC_KEYCHAIN", keychain.as_os_str()) .env_remove("CSC_LINK") .env_remove("CSC_KEY_PASSWORD"); } let result = capture(command); println!("::endgroup::"); match result { Ok(output) if output.status == 0 => return Ok(()), Ok(output) => { let log = format!( "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); if attempt < 3 && is_transient_failure(&log, platform.transient_patterns()) { println!("Detected transient build failure; cleaning and retrying."); clean_electron_builder_outputs(platform)?; thread::sleep(platform.retry_sleep()); continue; } bail!("electron-builder failed with exit code {}", output.status); } Err(error) if attempt < 3 => { println!("electron-builder failed to start: {error:?}; retrying."); clean_electron_builder_outputs(platform)?; thread::sleep(platform.retry_sleep()); } Err(error) => return Err(error), } } bail!("electron-builder failed after retries") } fn validate_macos_signing_env() -> Result { let missing = ["APPLE_ID", "APPLE_APP_SPECIFIC_PASSWORD", "APPLE_TEAM_ID"] .into_iter() .filter(|name| env_string(name).is_none()) .collect::>(); ensure!( missing.is_empty(), "Missing macOS notarization environment variables: {}. APPLE_ID maps to repo secret APPLE_ID; APPLE_APP_SPECIFIC_PASSWORD maps to APPLE_PASSWORD; APPLE_TEAM_ID maps to APPLE_TEAM_ID.", missing.join(" ") ); let keychain = require_home()?.join("Library/Keychains/fluxer-build.keychain-db"); ensure!( keychain.exists(), "Signing keychain {} not found on runner host. Run the runner's keychain bootstrap to import the Developer ID cert.", keychain.display() ); run_command(CommandSpec::new("security").args([ "unlock-keychain", "-p", "", keychain.to_string_lossy().as_ref(), ]))?; let identities = output_text(CommandSpec::new("security").args([ "find-identity", "-v", "-p", "codesigning", keychain.to_string_lossy().as_ref(), ]))?; ensure!( identities.contains("Developer ID Application"), "No valid Developer ID Application identity in {}.", keychain.display() ); Ok(keychain) } fn is_transient_failure(log: &str, patterns: &[&str]) -> bool { patterns.iter().any(|pattern| log.contains(pattern)) } fn clean_electron_builder_outputs(platform: DesktopBuildPlatform) -> Result<()> { let dist = Path::new("dist-electron"); if !dist.exists() { return Ok(()); } match platform { DesktopBuildPlatform::Macos => { remove_dir_if_exists(&dist.join("mac"))?; remove_dir_if_exists(&dist.join("mac-arm64"))?; } DesktopBuildPlatform::Windows => { remove_dir_if_exists(&dist.join("win-unpacked"))?; } DesktopBuildPlatform::Linux => { remove_dir_if_exists(&dist.join("linux-unpacked"))?; } } for entry in fs::read_dir(dist).with_context(|| format!("Failed to read {}", dist.display()))? { let path = entry?.path(); if path.is_dir() && path .file_name() .and_then(OsStr::to_str) .is_some_and(|name| name.ends_with("-unpacked")) { remove_dir_if_exists(&path)?; } } if matches!(platform, DesktopBuildPlatform::Windows) { for entry in WalkDir::new(dist) .into_iter() .filter_map(std::result::Result::ok) .filter(|entry| entry.file_type().is_file()) { let name = entry.file_name().to_string_lossy(); if name.starts_with("RCX") && name.ends_with(".tmp") { remove_file_if_exists(entry.path())?; } } } Ok(()) } fn verify_bundle_id_step() -> Result<()> { let electron_arch = require_env("ELECTRON_ARCH")?; let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); let zip_path = find_dist_file(Path::new("dist-electron"), |name| { name.ends_with(".zip") && name.contains(&electron_arch) }) .or_else(|| find_dist_file(Path::new("dist-electron"), |name| name.ends_with(".zip"))) .ok_or_else(|| anyhow!("No macOS zip artifact found in dist-electron"))?; let temp = TempDir::new().context("Failed to create temp directory")?; run_command(CommandSpec::new("ditto").args([ "-xk", zip_path.to_string_lossy().as_ref(), temp.path().to_string_lossy().as_ref(), ]))?; let app = find_first(temp.path(), |path| { path.extension().and_then(OsStr::to_str) == Some("app") }) .ok_or_else(|| { anyhow!( "No .app bundle found after extracting {}", zip_path.display() ) })?; let info_plist = app.join("Contents").join("Info.plist"); let profile = app.join("Contents").join("embedded.provisionprofile"); let bid = output_text(CommandSpec::new("/usr/libexec/PlistBuddy").args([ "-c", "Print :CFBundleIdentifier", info_plist.to_string_lossy().as_ref(), ]))?; let expected = if build_channel == "canary" { "app.fluxer.canary" } else { "app.fluxer" }; let expected_profile = if build_channel == "canary" { "3G5837T29K.app.fluxer.canary" } else { "3G5837T29K.app.fluxer" }; println!("Bundle id in zip: {bid} (expected: {expected})"); ensure!(bid == expected, "Unexpected bundle id: {bid}"); ensure!( profile.exists(), "Missing provisioning profile: {}", profile.display() ); let decoded_profile = temp.path().join("embedded.provisionprofile.plist"); let decoded = output_bytes(CommandSpec::new("security").args([ "cms", "-D", "-i", profile.to_string_lossy().as_ref(), ]))?; fs::write(&decoded_profile, decoded) .with_context(|| format!("Failed to write {}", decoded_profile.display()))?; let profile_app_id = output_text(CommandSpec::new("/usr/libexec/PlistBuddy").args([ "-c", "Print :Entitlements:com.apple.application-identifier", decoded_profile.to_string_lossy().as_ref(), ]))?; println!("Provisioning profile app id: {profile_app_id} (expected: {expected_profile})"); ensure!( profile_app_id == expected_profile, "Unexpected provisioning profile app id: {profile_app_id}" ); for (rel, expected_macho_arch) in macos_native_runtime_targets(&electron_arch) { let native_file = app .join("Contents") .join("Resources") .join("app.asar.unpacked") .join("node_modules") .join(rel); ensure!( native_file.exists(), "Missing native runtime artifact: {}", native_file.display() ); println!("Found native runtime artifact: {}", native_file.display()); check_macho_arch(&native_file, expected_macho_arch)?; } run_command(CommandSpec::new("codesign").args([ "--verify", "--deep", "--strict", "--verbose=4", app.to_string_lossy().as_ref(), ]))?; run_command(CommandSpec::new("xcrun").args([ "stapler", "validate", app.to_string_lossy().as_ref(), ]))?; run_command(CommandSpec::new("spctl").args([ "--assess", "--type", "execute", "--verbose=4", app.to_string_lossy().as_ref(), ])) } fn macos_native_runtime_targets(electron_arch: &str) -> Vec<(String, &'static str)> { if electron_arch == MACOS_UNIVERSAL_ARCH { let mut targets = macos_native_runtime_targets("arm64"); targets.extend(macos_native_runtime_targets("x64")); return targets; } let expected_macho_arch = if electron_arch == "arm64" { "arm64" } else { "x86_64" }; [ "@fluxer/webauthn/webauthn", "@fluxer/mac-app-audio/mac-app-audio", "@fluxer/mac-clipboard/mac-clipboard", "@fluxer/mac-sysctl/mac-sysctl", "@fluxer/mac-tcc/mac-tcc", "@fluxer/macos-input-hook/macos-input-hook", "@fluxer/platform-info/platform-info", ] .into_iter() .map(|prefix| { ( format!("{prefix}.darwin-{electron_arch}.node"), expected_macho_arch, ) }) .collect() } fn check_macho_arch(file: &Path, expected: &str) -> Result<()> { let archs = output_text(CommandSpec::new("lipo").args(["-archs", file.to_string_lossy().as_ref()]))?; println!("Mach-O archs for {}: {archs}", file.display()); let arch_list = archs.split_whitespace().collect::>(); ensure!( arch_list.contains(&expected), "{} has Mach-O archs '{archs}', expected '{expected}'", file.display() ); ensure!( !(expected == "x86_64" && arch_list.contains(&"x86_64h") && !arch_list.contains(&"x86_64")), "{} is x86_64h-only; x64 desktop artifacts must use baseline x86_64", file.display() ); Ok(()) } const WINDOWS_SIGNING_ENV: &[&str] = &[ "AZURE_CLIENT_ID", "AZURE_TENANT_ID", "AZURE_SUBSCRIPTION_ID", "AZURE_ARTIFACT_SIGNING_ENDPOINT", "AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME", "AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME", ]; const VELOPACK_TRUSTED_SIGN_FILE_ENV: &str = "VELOPACK_TRUSTED_SIGN_FILE"; const TRUSTED_SIGNING_EXCLUDED_CREDENTIALS: &[&str] = &[ "ManagedIdentityCredential", "WorkloadIdentityCredential", "SharedTokenCacheCredential", "VisualStudioCredential", "VisualStudioCodeCredential", "AzurePowerShellCredential", "AzureDeveloperCliCredential", "InteractiveBrowserCredential", ]; fn validate_windows_signing_inputs_step() -> Result<()> { let missing = WINDOWS_SIGNING_ENV .iter() .copied() .filter(|name| env_string(name).is_none()) .collect::>(); ensure!( missing.is_empty(), "Missing Windows code signing environment variables: {}. Windows releases are always signed; every Azure Trusted Signing input is mandatory and there is no unsigned fallback.", missing.join(" ") ); println!( "Windows code signing inputs present: {}", WINDOWS_SIGNING_ENV.join(" ") ); Ok(()) } #[derive(Debug, Serialize)] struct TrustedSigningMetadata { #[serde(rename = "Endpoint")] endpoint: String, #[serde(rename = "CodeSigningAccountName")] code_signing_account_name: String, #[serde(rename = "CertificateProfileName")] certificate_profile_name: String, #[serde(rename = "ExcludeCredentials")] exclude_credentials: Vec<&'static str>, } fn windows_trusted_signing_metadata_path() -> PathBuf { runner_temp().join("velopack-trusted-signing.json") } fn write_windows_signing_metadata_step() -> Result<()> { validate_windows_signing_inputs_step()?; let metadata = TrustedSigningMetadata { endpoint: require_env("AZURE_ARTIFACT_SIGNING_ENDPOINT")?, code_signing_account_name: require_env("AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME")?, certificate_profile_name: require_env("AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME")?, exclude_credentials: TRUSTED_SIGNING_EXCLUDED_CREDENTIALS.to_vec(), }; let path = windows_trusted_signing_metadata_path(); write_json_pretty(&path, &metadata)?; println!( "Wrote Velopack Trusted Signing metadata to {} (never staged for upload).", path.display() ); append_github_env(&[( VELOPACK_TRUSTED_SIGN_FILE_ENV, path.to_string_lossy().as_ref(), )]) } fn resolve_windows_unpacked_dir_step() -> Result<()> { let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); let arch = require_env("ARCH")?; let config = windows_package_config(&build_channel, &arch); let pack_dir = resolve_windows_unpacked_dir(&arch, &config.main_exe)?; println!( "Resolved unpacked Windows app directory: {}", pack_dir.display() ); append_github_output(&[("unpacked_dir", pack_dir.to_string_lossy().as_ref())]) } fn resolve_windows_unpacked_dir(arch: &str, main_exe: &str) -> Result { let pack_dir = find_windows_unpacked_app(arch, main_exe) .ok_or_else(|| anyhow!("Unable to find unpacked Windows app containing {main_exe}"))?; let absolute = env::current_dir() .context("Failed to resolve current directory")? .join(pack_dir); ensure!( absolute.is_dir(), "Unpacked Windows app directory does not exist: {}", absolute.display() ); Ok(absolute) } #[derive(Debug, Clone, PartialEq, Eq)] struct WindowsPackageConfig { pack_id: &'static str, pack_title: &'static str, artifact_prefix: &'static str, icon_dir: &'static str, runtime: &'static str, main_exe: String, output_dir: PathBuf, } #[derive(Debug, Serialize, Deserialize)] struct VelopackAssetIndexEntry { #[serde(rename = "RelativeFileName")] relative_file_name: String, #[serde(rename = "Type")] asset_type: String, #[serde(flatten)] extra: BTreeMap, } fn windows_package_config(build_channel: &str, arch: &str) -> WindowsPackageConfig { let canary = build_channel == "canary"; let pack_title = if canary { "Fluxer Canary" } else { "Fluxer" }; WindowsPackageConfig { pack_id: if canary { "fluxer_desktop_canary" } else { "fluxer_desktop" }, pack_title, artifact_prefix: if canary { "Fluxer-Canary" } else { "Fluxer" }, icon_dir: if canary { "icons-canary" } else { "icons-stable" }, runtime: if arch == "arm64" { "win-arm64" } else { "win-x64" }, main_exe: format!("{pack_title}.exe"), output_dir: PathBuf::from("dist-electron").join(format!("velopack-windows-{arch}")), } } fn package_app_windows_velopack_step() -> Result<()> { let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); let arch = require_env("ARCH")?; let version = require_env("VERSION")?; let config = windows_package_config(&build_channel, &arch); remove_dir_if_exists(&config.output_dir)?; let pack_dir = find_windows_unpacked_app(&arch, &config.main_exe).ok_or_else(|| { anyhow!( "Unable to find unpacked Windows app containing {}", config.main_exe ) })?; let vpk = find_velopack_cli()?; let trusted_sign_file = PathBuf::from(require_env(VELOPACK_TRUSTED_SIGN_FILE_ENV).context( "Velopack packaging requires the Trusted Signing metadata written by the write_windows_signing_metadata step. Windows packages are never produced unsigned.", )?); ensure!( trusted_sign_file.is_file(), "Velopack Trusted Signing metadata file is missing: {}", trusted_sign_file.display() ); let packaged = pack_and_validate_windows_velopack( &vpk, &config, &version, &arch, &pack_dir, &trusted_sign_file, ); let metadata_removed = remove_file_if_exists(&trusted_sign_file); packaged?; metadata_removed?; print_directory(&config.output_dir) } fn pack_and_validate_windows_velopack( vpk: &Path, config: &WindowsPackageConfig, version: &str, arch: &str, pack_dir: &Path, trusted_sign_file: &Path, ) -> Result<()> { ensure_velopack_pack_supports(vpk, &["--azureTrustedSignFile"])?; run_command(CommandSpec::new(vpk).args([ "--yes", "pack", "--packId", config.pack_id, "--packVersion", version, "--packDir", pack_dir.to_string_lossy().as_ref(), "--mainExe", config.main_exe.as_str(), "--packTitle", config.pack_title, "--packAuthors", "Fluxer Platform AB", "--shortcuts", "Desktop,StartMenu", "--runtime", config.runtime, "--icon", &format!("build_resources/{}/icon.ico", config.icon_dir), "--outputDir", config.output_dir.to_string_lossy().as_ref(), "--delta", "None", "--azureTrustedSignFile", trusted_sign_file.to_string_lossy().as_ref(), ]))?; validate_velopack_output(config, version, arch)?; remove_velopack_portable_archives(&config.output_dir) } fn remove_velopack_portable_archives(output_dir: &Path) -> Result<()> { for path in collect_files(output_dir)? { if !extension_is(&path, "zip") { continue; } fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?; println!( "Removed Velopack portable archive {}. Fluxer publishes its own portable ZIP built from the signed application tree.", path.display() ); } Ok(()) } fn ensure_velopack_pack_supports(vpk: &Path, options: &[&str]) -> Result<()> { let help = capture(CommandSpec::new(vpk).args(["pack", "--help"])) .context("Failed to read `vpk pack --help` from the pinned Velopack CLI")?; let text = format!( "{}{}", String::from_utf8_lossy(&help.stdout), String::from_utf8_lossy(&help.stderr) ); let missing = options .iter() .filter(|option| !text.contains(**option)) .copied() .collect::>(); ensure!( missing.is_empty(), "The pinned Velopack CLI does not support {}. Update the pin or rework the packaging step before releasing.", missing.join(", ") ); Ok(()) } fn validate_velopack_output( config: &WindowsPackageConfig, version: &str, arch: &str, ) -> Result<()> { let legacy_releases = config.output_dir.join("RELEASES"); let velopack_releases = config.output_dir.join("releases.win.json"); let full_nupkg = first_file_matching(&config.output_dir, |name| name.ends_with("-full.nupkg")); let delta_nupkgs = collect_files(&config.output_dir)? .into_iter() .filter(|path| { path.file_name() .and_then(OsStr::to_str) .is_some_and(|name| name.ends_with("-delta.nupkg")) }) .map(|path| path.display().to_string()) .collect::>(); ensure!( legacy_releases.exists(), "Velopack did not produce the legacy Squirrel RELEASES file. Do not pass --channel to vpk pack for Windows, or old Squirrel clients cannot migrate." ); ensure!( velopack_releases.exists(), "Velopack did not produce releases.win.json for Windows updates." ); ensure!( delta_nupkgs.is_empty(), "Velopack produced {} disabled delta package(s), which cannot be independently verified against the signed full package:\n{}", delta_nupkgs.len(), delta_nupkgs.join("\n") ); let full_nupkg = full_nupkg.ok_or_else(|| { anyhow!("Velopack did not produce a full nupkg payload for Windows updates.") })?; let full_nupkg = rename_windows_update_package(config, version, arch, &full_nupkg)?; let release_feed = fs::read_to_string(&legacy_releases) .with_context(|| format!("Failed to read {}", legacy_releases.display()))?; let nupkg_name = file_name_string(&full_nupkg)?; ensure!( release_feed.contains(&nupkg_name), "The legacy Squirrel RELEASES file does not reference {nupkg_name}." ); let setup_exe = first_file_matching(&config.output_dir, |name| name.ends_with("-Setup.exe")) .ok_or_else(|| { anyhow!( "Velopack did not produce a Setup.exe in {}.", config.output_dir.display() ) })?; let desired_setup_name = format!("{}-{version}-win-{arch}.exe", config.artifact_prefix); if file_name_string(&setup_exe)? != desired_setup_name { fs::rename(&setup_exe, config.output_dir.join(desired_setup_name)) .with_context(|| format!("Failed to rename {}", setup_exe.display()))?; } Ok(()) } fn rename_windows_update_package( config: &WindowsPackageConfig, version: &str, arch: &str, source: &Path, ) -> Result { let source_name = file_name_string(source)?; let target_name = format!("{}-{version}-win-{arch}-full.nupkg", config.artifact_prefix); let setup_name = format!("{}-{version}-win-{arch}.exe", config.artifact_prefix); let portable_name = format!( "{}-{version}-portable-win-{arch}.zip", config.artifact_prefix ); ensure!( source_name != target_name, "Velopack unexpectedly emitted the canonical package name {target_name:?} before feed normalization" ); let legacy_path = config.output_dir.join("RELEASES"); let legacy = fs::read_to_string(&legacy_path) .with_context(|| format!("Failed to read {}", legacy_path.display()))?; ensure!( legacy.matches(&source_name).count() == 1, "{} must reference Velopack package {source_name:?} exactly once", legacy_path.display() ); fs::write(&legacy_path, legacy.replace(&source_name, &target_name)) .with_context(|| format!("Failed to rewrite {}", legacy_path.display()))?; let releases_path = config.output_dir.join("releases.win.json"); let mut releases: Value = serde_json::from_slice( &fs::read(&releases_path) .with_context(|| format!("Failed to read {}", releases_path.display()))?, ) .with_context(|| format!("Failed to parse {}", releases_path.display()))?; let replacements = replace_json_string(&mut releases, &source_name, &target_name); ensure!( replacements == 1, "{} must reference Velopack package {source_name:?} exactly once, found {replacements}", releases_path.display() ); write_json_pretty(&releases_path, &releases)?; let assets_path = config.output_dir.join("assets.win.json"); let mut assets: Vec = serde_json::from_slice( &fs::read(&assets_path) .with_context(|| format!("Failed to read {}", assets_path.display()))?, ) .with_context(|| format!("Failed to parse {}", assets_path.display()))?; ensure!( assets.len() == 3, "{} must contain exactly three Velopack assets, found {}", assets_path.display(), assets.len() ); let mut asset_types = BTreeSet::new(); for asset in &mut assets { ensure!( asset_types.insert(asset.asset_type.as_str()), "{} contains duplicate asset type {:?}", assets_path.display(), asset.asset_type ); asset.relative_file_name = match asset.asset_type.as_str() { "Installer" => setup_name.clone(), "Portable" => portable_name.clone(), "Full" => { ensure!( asset.relative_file_name == source_name, "{} Full asset references {:?}, expected {source_name:?}", assets_path.display(), asset.relative_file_name ); target_name.clone() } other => bail!( "{} contains unsupported Velopack asset type {other:?}", assets_path.display() ), }; } ensure!( asset_types == BTreeSet::from(["Full", "Installer", "Portable"]), "{} contains an incomplete Velopack asset inventory", assets_path.display() ); write_json_pretty(&assets_path, &assets)?; let target = config.output_dir.join(&target_name); fs::rename(source, &target).with_context(|| { format!( "Failed to rename {} to {}", source.display(), target.display() ) })?; Ok(target) } fn replace_json_string(value: &mut Value, source: &str, target: &str) -> usize { match value { Value::String(current) if current == source => { *current = target.to_string(); 1 } Value::Array(values) => values .iter_mut() .map(|value| replace_json_string(value, source, target)) .sum(), Value::Object(values) => values .values_mut() .map(|value| replace_json_string(value, source, target)) .sum(), _ => 0, } } fn find_windows_unpacked_app(arch: &str, main_exe: &str) -> Option { windows_unpacked_candidates(arch) .into_iter() .find(|candidate| candidate.join(main_exe).exists()) } fn windows_unpacked_candidates(arch: &str) -> Vec { let mut candidates = Vec::new(); if arch == "arm64" { candidates.push(PathBuf::from("dist-electron/win-arm64-unpacked")); } candidates.push(PathBuf::from("dist-electron/win-unpacked")); candidates } fn find_velopack_cli() -> Result { let candidates = [".velopack/vpk.exe", ".velopack/vpk"]; candidates .into_iter() .map(PathBuf::from) .find(|path| path.exists()) .ok_or_else(|| anyhow!("Velopack CLI was not installed under .velopack")) } fn analyse_velopack_paths_step() -> Result<()> { let arch = require_env("ARCH")?; let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); let config = windows_package_config(&build_channel, &arch); let nupkg = first_file_matching(&config.output_dir, |name| name.ends_with("-full.nupkg")) .ok_or_else(|| { anyhow!( "No Velopack full nupkg found in: {}", config.output_dir.display() ) })?; println!("Analyzing Velopack package {}", nupkg.display()); let local_app_data = require_env("LOCALAPPDATA")?; let prefix = PathBuf::from(local_app_data) .join(config.pack_id) .join("current") .join("resources") .join("app.asar.unpacked"); let max_len = env::var("MAX_WINDOWS_PATH_LEN") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(260); let headroom = env::var("PATH_HEADROOM") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(10); let limit = max_len.saturating_sub(headroom); let entries = velopack_path_lengths(&nupkg, &prefix)?; ensure!(!entries.is_empty(), "nupkg archive contains no entries"); println!( "Assumed install prefix: {} ({} chars). Maximum allowed path length: {limit} (total reserve {max_len}, headroom {headroom}).", prefix.display(), prefix.to_string_lossy().len() ); println!("Top 20 longest archived paths (length includes prefix):"); for entry in entries.iter().take(20) { println!("{:4} {}", entry.length, entry.name); } let longest = entries.first().expect("entries not empty"); ensure!( longest.length <= limit, "Longest path {} for {} exceeds limit {limit}", longest.length, longest.name ); println!( "Longest archived path {} is within the limit of {limit}.", longest.length ); Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] struct ArchivePathLength { length: usize, name: String, } fn velopack_path_lengths(nupkg: &Path, prefix: &Path) -> Result> { let file = File::open(nupkg).with_context(|| format!("Failed to open {}", nupkg.display()))?; let mut archive = zip::ZipArchive::new(file) .with_context(|| format!("Failed to read zip {}", nupkg.display()))?; let mut entries = Vec::new(); for index in 0..archive.len() { let entry = archive.by_index(index)?; let normalized = entry .name() .trim_start_matches(['/', '\\']) .replace('\\', "/"); let full = if normalized.is_empty() { prefix.to_path_buf() } else { prefix.join(&normalized) }; entries.push(ArchivePathLength { length: full.to_string_lossy().len(), name: entry.name().to_string(), }); } entries.sort_by(|a, b| b.length.cmp(&a.length).then_with(|| a.name.cmp(&b.name))); Ok(entries) } fn create_portable_zip_windows_step() -> Result<()> { let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); let arch = require_env("ARCH")?; let version = require_env("VERSION")?; let config = windows_package_config(&build_channel, &arch); let Some(pack_dir) = find_windows_unpacked_app(&arch, &config.main_exe) else { println!("No unpacked Windows app found; skipping portable ZIP."); return Ok(()); }; let portable_marker = pack_dir.join(".portable"); fs::write(&portable_marker, "") .with_context(|| format!("Failed to write {}", portable_marker.display()))?; let zip_name = format!( "{}-{version}-portable-win-{arch}.zip", config.artifact_prefix ); let zip_path = PathBuf::from("dist-electron").join(zip_name); create_zip_from_dir(&pack_dir, &zip_path)?; remove_file_if_exists(&portable_marker)?; let size_mb = fs::metadata(&zip_path)?.len() as f64 / 1024.0 / 1024.0; println!( "Created portable ZIP: {} ({size_mb:.1} MB); removed {} so installed builds are not marked portable.", zip_path.display(), portable_marker.display() ); Ok(()) } const FLUXER_WINDOWS_SIGNER_COMMON_NAME: &str = "Fluxer Platform AB"; const THIRD_PARTY_WINDOWS_SIGNATURE_ALLOWLIST: &[(&str, &str)] = &[ ( "d3dcompiler_47.dll", "CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", ), ( "dxil.dll", "CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", ), ]; const KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY: &[&str] = &[]; const FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES: &[&str] = &[ "fluxer-game-hook.", "fluxer-inject-helper.", "fluxer-vulkan-layer.", "fluxer_game_hook.", "fluxer_inject_helper.", "fluxer_vulkan_layer.", ]; const WINDOWS_NATIVE_ADDON_STEMS: &[&str] = &[ "hardware-encoder", "webauthn", "win-process-loopback", "win-clipboard", "win-shell", "win-toast", "windows-input-hook", "platform-info", ]; fn expected_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec { let tag = format!("win32-{arch}-msvc"); let mut names = vec![ main_exe.to_string(), format!("velopack_nodeffi_win_{arch}_msvc.node"), format!("win-game-capture.{tag}.node"), ]; names.extend( WINDOWS_NATIVE_ADDON_STEMS .iter() .map(|stem| format!("{stem}.{tag}.node")), ); names.sort(); names.dedup(); names } fn read_pe_machine(path: &Path) -> Result> { let mut file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; let mut dos_header = [0u8; 0x40]; if !read_exact_or_eof(&mut file, &mut dos_header, path)? { return Ok(None); } if &dos_header[0..2] != b"MZ" { return Ok(None); } let e_lfanew = u32::from_le_bytes([ dos_header[0x3c], dos_header[0x3d], dos_header[0x3e], dos_header[0x3f], ]); file.seek(SeekFrom::Start(u64::from(e_lfanew))) .with_context(|| format!("Failed to seek in {}", path.display()))?; let mut signature = [0u8; 4]; if !read_exact_or_eof(&mut file, &mut signature, path)? { return Ok(None); } if &signature != b"PE\0\0" { return Ok(None); } let mut machine = [0u8; 2]; if !read_exact_or_eof(&mut file, &mut machine, path)? { return Ok(None); } Ok(Some(u16::from_le_bytes(machine))) } fn is_pe_file(path: &Path) -> Result { Ok(read_pe_machine(path)?.is_some()) } fn read_exact_or_eof(file: &mut File, buffer: &mut [u8], path: &Path) -> Result { match file.read_exact(buffer) { Ok(()) => Ok(true), Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(false), Err(error) => Err(error).with_context(|| format!("Failed to read {}", path.display())), } } fn collect_pe_files(root: &Path) -> Result> { let mut files = Vec::new(); for path in collect_files(root)? { if is_pe_file(&path)? { files.push(path); } } Ok(files) } fn absolute_path(path: &Path) -> Result { if path.is_absolute() { return Ok(path.to_path_buf()); } Ok(env::current_dir() .context("Failed to resolve current directory")? .join(path)) } fn relative_display(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) .to_string_lossy() .replace('\\', "/") } fn percent_decode_archive_name(name: &str) -> String { if !name.contains('%') { return name.to_string(); } let bytes = name.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { if bytes[index] == b'%' && index + 2 < bytes.len() { let high = (bytes[index + 1] as char).to_digit(16); let low = (bytes[index + 2] as char).to_digit(16); if let (Some(high), Some(low)) = (high, low) { decoded.push((high * 16 + low) as u8); index += 3; continue; } } decoded.push(bytes[index]); index += 1; } String::from_utf8(decoded).unwrap_or_else(|_| name.to_string()) } fn windows_pe_machine_arch(machine: u16) -> Option<&'static str> { match machine { 0x014c => Some("ia32"), 0x8664 => Some("x64"), 0xaa64 => Some("arm64"), _ => None, } } fn assert_windows_native_pe_machines( root: &Path, files: &[PathBuf], expected_arch: &str, main_exe: &str, ) -> Result<()> { let mut violations = Vec::new(); let normalized_main_exe = main_exe.to_ascii_lowercase(); for path in files { let Some(file_name) = path.file_name().and_then(OsStr::to_str) else { continue; }; let normalized_name = percent_decode_archive_name(file_name).to_ascii_lowercase(); let file_arch = if normalized_name == normalized_main_exe { Some(expected_arch) } else { windows_native_pe_arch(&normalized_name) }; let Some(file_arch) = file_arch else { continue; }; let machine = read_pe_machine(path)?.ok_or_else(|| { anyhow!( "{} was collected as a PE file but no COFF Machine field was readable", path.display() ) })?; let actual_arch = windows_pe_machine_arch(machine); if actual_arch != Some(file_arch) || actual_arch != Some(expected_arch) { violations.push(format!( "{}: file policy expects {file_arch}, COFF Machine is 0x{machine:04x} ({}) and package architecture is {expected_arch}", relative_display(root, path), actual_arch.unwrap_or("unknown") )); } } ensure!( violations.is_empty(), "{} contains {} Windows native binary/binaries with invalid machine architecture:\n{}", root.display(), violations.len(), violations.join("\n") ); Ok(()) } fn assert_expected_windows_pe_inventory( root: &Path, files: &[PathBuf], arch: &str, main_exe: &str, ) -> Result<()> { let present = files .iter() .filter_map(|path| path.file_name().and_then(OsStr::to_str)) .map(percent_decode_archive_name) .collect::>(); let expected = expected_windows_pe_inventory(arch, main_exe); let missing = expected .iter() .filter(|name| !present.contains(*name)) .cloned() .collect::>(); ensure!( missing.is_empty(), "{} is missing {} expected Windows binaries:\n{}", root.display(), missing.len(), missing.join("\n") ); let forbidden = present .iter() .filter_map(|name| windows_pe_inventory_violation(name, arch)) .collect::>(); ensure!( forbidden.is_empty(), "{} contains {} forbidden Windows native binaries:\n{}", root.display(), forbidden.len(), forbidden.join("\n") ); let contradictory = contradictory_optional_windows_pe_inventory(arch, main_exe); ensure!( contradictory.is_empty(), "KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY lists {} binary/binaries that {arch} also requires, so the inventory contradicts itself:\n{}", contradictory.len(), contradictory.join("\n") ); for name in KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY { println!( "Known-optional Windows binary {name}: {}", if present.contains(*name) { "present" } else { "absent" } ); } let unlisted = present .iter() .filter(|name| { !expected.iter().any(|value| value == *name) && !KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY.contains(&name.as_str()) }) .cloned() .collect::>(); println!( "{}: {} expected, {} unlisted PE(s) shipped by glob (Electron runtime and third-party binaries). Every one of them is signature-classified below; none may be unsigned or signed by an unknown publisher.", root.display(), expected.len(), unlisted.len() ); for name in &unlisted { println!("Unlisted Windows PE pending signature classification: {name}"); } Ok(()) } const ASAR_HEADER_LIMIT: usize = 64 * 1024 * 1024; const ASAR_ENTRY_LIMIT: usize = 1_000_000; const ASAR_NESTING_LIMIT: usize = 256; fn windows_package_file_policy_violation(relative: &str, expected_arch: &str) -> bool { let normalized_relative = relative .replace('\\', "/") .split('/') .map(percent_decode_archive_name) .collect::>() .join("/") .replace('\\', "/") .to_ascii_lowercase(); let normalized_name = normalized_relative .rsplit('/') .next() .unwrap_or_default() .to_string(); if FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES .iter() .any(|prefix| normalized_name.starts_with(prefix)) { return true; } if normalized_name == "compatibility.json" && normalized_relative .split('/') .any(|component| component == "win-game-capture") { return true; } windows_native_pe_arch(&normalized_name).is_some_and(|arch| arch != expected_arch) } fn read_u32_le(bytes: &[u8], offset: usize) -> Result { let end = offset .checked_add(4) .ok_or_else(|| anyhow!("ASAR header offset overflow"))?; let value = bytes .get(offset..end) .ok_or_else(|| anyhow!("ASAR header is truncated at byte {offset}"))?; let value = <[u8; 4]>::try_from(value) .map_err(|_| anyhow!("ASAR header field at byte {offset} is not four bytes"))?; Ok(u32::from_le_bytes(value)) } fn collect_asar_policy_violations( node: &Value, expected_arch: &str, violations: &mut Vec, ) -> Result<()> { let mut stack = vec![(node, String::new(), 0usize)]; let mut entry_count = 0usize; while let Some((current, prefix, depth)) = stack.pop() { ensure!( depth <= ASAR_NESTING_LIMIT, "ASAR header nesting exceeds {ASAR_NESTING_LIMIT} levels under {prefix:?}" ); let files = current .get("files") .and_then(Value::as_object) .ok_or_else(|| anyhow!("ASAR header node {prefix:?} has no files object"))?; for (name, entry) in files { entry_count = entry_count .checked_add(1) .ok_or_else(|| anyhow!("ASAR entry count overflow"))?; ensure!( entry_count <= ASAR_ENTRY_LIMIT, "ASAR header contains more than {ASAR_ENTRY_LIMIT} entries" ); let decoded_name = percent_decode_archive_name(name); ensure!( !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\') && decoded_name != "." && decoded_name != ".." && !decoded_name.contains('/') && !decoded_name.contains('\\'), "ASAR header contains invalid entry name {name:?} under {prefix:?}" ); let relative = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") }; if windows_package_file_policy_violation(&relative, expected_arch) { violations.push(relative.clone()); } if entry.get("files").is_some() { stack.push((entry, relative, depth + 1)); } } } Ok(()) } fn asar_policy_violations(path: &Path, expected_arch: &str) -> Result> { let mut file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; let file_size = file .metadata() .with_context(|| format!("Failed to stat {}", path.display()))? .len(); let mut prefix = [0u8; 16]; file.read_exact(&mut prefix) .with_context(|| format!("Failed to read ASAR header prefix from {}", path.display()))?; let size_pickle_payload = read_u32_le(&prefix, 0)?; let header_pickle_size = read_u32_le(&prefix, 4)?; let header_pickle_payload = read_u32_le(&prefix, 8)?; let header_json_size = read_u32_le(&prefix, 12)?; let padded_header_json_size = header_json_size .checked_add(3) .map(|size| size & !3) .ok_or_else(|| anyhow!("{} ASAR JSON header size overflow", path.display()))?; let expected_header_pickle_payload = padded_header_json_size .checked_add(4) .ok_or_else(|| anyhow!("{} ASAR pickle payload size overflow", path.display()))?; ensure!( size_pickle_payload == 4 && header_pickle_payload.checked_add(4) == Some(header_pickle_size) && header_pickle_payload == expected_header_pickle_payload, "{} has an invalid ASAR pickle header", path.display() ); let header_json_size = usize::try_from(header_json_size).context("ASAR header is too large")?; ensure!( header_json_size > 0 && header_json_size <= ASAR_HEADER_LIMIT, "{} ASAR JSON header size {} is outside 1..={ASAR_HEADER_LIMIT}", path.display(), header_json_size ); let header_pickle_size = usize::try_from(header_pickle_size).context("ASAR pickle header is too large")?; let archive_payload_offset = 8usize .checked_add(header_pickle_size) .ok_or_else(|| anyhow!("{} ASAR header size overflow", path.display()))?; ensure!( u64::try_from(archive_payload_offset).unwrap_or(u64::MAX) <= file_size, "{} ASAR header extends beyond the {}-byte archive", path.display(), file_size ); let mut header_json = vec![0u8; header_json_size]; file.read_exact(&mut header_json) .with_context(|| format!("Failed to read ASAR JSON header from {}", path.display()))?; let header: Value = serde_json::from_slice(&header_json) .with_context(|| format!("Failed to parse ASAR JSON header from {}", path.display()))?; let mut violations = Vec::new(); collect_asar_policy_violations(&header, expected_arch, &mut violations)?; Ok(violations) } fn assert_windows_package_file_policy(root: &Path, expected_arch: &str) -> Result<()> { let mut forbidden = Vec::new(); for path in collect_files(root)? { let relative = relative_display(root, &path); if windows_package_file_policy_violation(&relative, expected_arch) { forbidden.push(relative.clone()); } if path .file_name() .and_then(OsStr::to_str) .is_some_and(|name| { percent_decode_archive_name(name) .to_ascii_lowercase() .ends_with(".asar") }) { forbidden.extend( asar_policy_violations(&path, expected_arch)? .into_iter() .map(|entry| format!("{relative}!/{entry}")), ); } } forbidden.sort(); forbidden.dedup(); ensure!( forbidden.is_empty(), "{} contains {} forbidden Windows package file(s):\n{}", root.display(), forbidden.len(), forbidden.join("\n") ); Ok(()) } fn windows_pe_inventory_violation(name: &str, expected_arch: &str) -> Option { let normalized = name.to_ascii_lowercase(); if FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES .iter() .any(|prefix| normalized.starts_with(prefix)) { return Some(format!( "{name}: disabled game-capture hook sidecars must not ship" )); } let packaged_arch = windows_native_pe_arch(&normalized)?; if packaged_arch == expected_arch { return None; } Some(format!( "{name}: native architecture is {packaged_arch}, expected {expected_arch}" )) } fn windows_native_pe_arch(name: &str) -> Option<&'static str> { for (marker, arch) in [ (".win32-x64-msvc.", "x64"), (".win32-arm64-msvc.", "arm64"), (".win32-ia32-msvc.", "ia32"), ("_win_x64_msvc.", "x64"), ("_win_arm64_msvc.", "arm64"), ("_win_x86_msvc.", "ia32"), ] { if name.contains(marker) { return Some(arch); } } None } fn contradictory_optional_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec { let expected = expected_windows_pe_inventory(arch, main_exe); KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY .iter() .filter(|name| expected.iter().any(|value| value == *name)) .map(|name| (*name).to_string()) .collect() } #[derive(Debug, Clone, Deserialize)] struct SignatureRow { #[serde(rename = "Path")] path: String, #[serde(rename = "Status")] status: String, #[serde(rename = "Subject")] subject: Option, #[serde(rename = "Thumbprint")] thumbprint: Option, #[serde(rename = "TsSubject")] ts_subject: Option, } fn authenticode_report(files: &[PathBuf]) -> Result> { let temp = TempDir::new().context("Failed to create Authenticode report temp directory")?; let list_path = temp.path().join("paths.txt"); let mut list = String::new(); for file in files { list.push_str(file.to_string_lossy().as_ref()); list.push('\n'); } fs::write(&list_path, list) .with_context(|| format!("Failed to write {}", list_path.display()))?; let script_path = temp.path().join("authenticode-report.ps1"); fs::write(&script_path, authenticode_report_script(&list_path)) .with_context(|| format!("Failed to write {}", script_path.display()))?; let output = capture( CommandSpec::new(powershell_host()) .args([ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script_path.to_string_lossy().as_ref(), ]) .env_remove("PSModulePath"), )?; ensure!( output.status == 0, "Get-AuthenticodeSignature failed with exit code {}", output.status ); let stdout = String::from_utf8(output.stdout) .context("Get-AuthenticodeSignature output was not UTF-8")?; parse_authenticode_report(stdout.trim()) } fn powershell_host() -> &'static str { if which_in_path("pwsh.exe").is_some() || which_in_path("pwsh").is_some() { return "pwsh"; } "powershell" } fn which_in_path(program: &str) -> Option { let path = env::var_os("PATH")?; env::split_paths(&path) .map(|directory| directory.join(program)) .find(|candidate| candidate.is_file()) } fn authenticode_report_script(list_path: &Path) -> String { format!( "$ErrorActionPreference = 'Stop'\n\ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false\n\ $paths = @(Get-Content -LiteralPath '{}' -Encoding UTF8 | Where-Object {{ $_ -ne '' }})\n\ $rows = @(Get-AuthenticodeSignature -LiteralPath $paths | Select-Object \ @{{n='Path';e={{[string]$_.Path}}}}, \ @{{n='Status';e={{[string]$_.Status}}}}, \ @{{n='Subject';e={{if ($_.SignerCertificate) {{ [string]$_.SignerCertificate.Subject }} else {{ $null }}}}}}, \ @{{n='Thumbprint';e={{if ($_.SignerCertificate) {{ [string]$_.SignerCertificate.Thumbprint }} else {{ $null }}}}}}, \ @{{n='TsSubject';e={{if ($_.TimeStamperCertificate) {{ [string]$_.TimeStamperCertificate.Subject }} else {{ $null }}}}}})\n\ ConvertTo-Json -InputObject $rows -Depth 3 -Compress\n", list_path.display() ) } fn parse_authenticode_report(json: &str) -> Result> { let json = json.trim_start_matches('\u{feff}').trim(); ensure!( !json.is_empty(), "Get-AuthenticodeSignature produced no output." ); let value: Value = serde_json::from_str(json).context("Failed to parse Get-AuthenticodeSignature JSON")?; let rows = match value { Value::Array(items) => items, single => vec![single], }; rows.into_iter() .map(|row| { serde_json::from_value::(row) .context("Failed to parse Get-AuthenticodeSignature row") }) .collect() } fn certificate_common_name(subject: &str) -> Option<&str> { subject .split(", ") .find_map(|component| component.strip_prefix("CN=")) } fn assert_fluxer_signed(row: &SignatureRow) -> Result<()> { ensure!( row.status == "Valid", "Authenticode status is {} (expected Valid)", row.status ); ensure!( row.ts_subject.is_some(), "Authenticode signature has no RFC3161 timestamp" ); let subject = row .subject .as_deref() .ok_or_else(|| anyhow!("Authenticode signature has no signer certificate subject"))?; let common_name = certificate_common_name(subject) .ok_or_else(|| anyhow!("Signer subject has no CN= component: {subject}"))?; ensure!( common_name == FLUXER_WINDOWS_SIGNER_COMMON_NAME, "Signer CN is '{common_name}', expected '{}' (thumbprint {})", FLUXER_WINDOWS_SIGNER_COMMON_NAME, row.thumbprint.as_deref().unwrap_or("unknown") ); Ok(()) } fn assert_third_party_signed(row: &SignatureRow, relative: &str) -> Result<()> { ensure!( row.status == "Valid", "Authenticode status is {} (expected Valid)", row.status ); let subject = row .subject .as_deref() .ok_or_else(|| anyhow!("Authenticode signature has no signer certificate subject"))?; ensure!( THIRD_PARTY_WINDOWS_SIGNATURE_ALLOWLIST .iter() .any(|(allowed_path, allowed_subject)| { relative.eq_ignore_ascii_case(allowed_path) && subject == *allowed_subject }), "Signer subject '{subject}' is not allowlisted for {relative}" ); ensure!( row.ts_subject.is_some(), "Authenticode signature has no RFC3161 timestamp" ); Ok(()) } fn assert_signed_by_known_publisher(row: &SignatureRow, relative: &str) -> Result<()> { match assert_fluxer_signed(row) { Ok(()) => Ok(()), Err(fluxer_error) => assert_third_party_signed(row, relative) .map_err(|third_party_error| anyhow!("{fluxer_error}; {third_party_error}")), } } fn same_windows_path(reported: &str, expected: &Path) -> bool { fn normalise(value: &str) -> String { let replaced = value.replace('/', "\\"); let trimmed = replaced.trim_start_matches(r"\\?\"); trimmed.to_ascii_lowercase() } normalise(reported) == normalise(expected.to_string_lossy().as_ref()) } fn find_signtool() -> Result { if let Some(path) = env_string("SIGNTOOL_PATH") .map(PathBuf::from) .filter(|path| path.exists()) { return Ok(path); } let roots = [ PathBuf::from(r"C:\Program Files (x86)\Windows Kits\10\bin"), PathBuf::from(r"C:\Program Files\Windows Kits\10\bin"), ]; let host_leaf = signtool_host_arch_dir(); let mut best: Option<((u8, [u32; 4]), PathBuf)> = None; for root in &roots { if !root.exists() { continue; } for entry in WalkDir::new(root) .into_iter() .filter_map(std::result::Result::ok) .filter(|entry| entry.file_type().is_file()) { let path = entry.into_path(); if !path .file_name() .and_then(OsStr::to_str) .is_some_and(|name| name.eq_ignore_ascii_case("signtool.exe")) { continue; } let leaf_matches_host = path .parent() .and_then(Path::file_name) .and_then(OsStr::to_str) .is_some_and(|leaf| leaf.eq_ignore_ascii_case(host_leaf)); let rank = ( u8::from(leaf_matches_host), windows_sdk_version_from_path(&path), ); if best.as_ref().is_none_or(|(current, _)| rank > *current) { best = Some((rank, path)); } } } let (rank, path) = best.ok_or_else(|| { anyhow!( "Could not find signtool.exe under {} or {}. Install the Windows SDK Signing Tools on the runner, or set SIGNTOOL_PATH to an explicit signtool.exe.", roots[0].display(), roots[1].display() ) })?; let (host_arch_match, sdk_version) = rank; println!( "Using signtool {} (SDK {}.{}.{}.{}, host architecture match: {})", path.display(), sdk_version[0], sdk_version[1], sdk_version[2], sdk_version[3], host_arch_match == 1 ); Ok(path) } fn signtool_host_arch_dir() -> &'static str { match env::consts::ARCH { "aarch64" => "arm64", "x86" => "x86", _ => "x64", } } fn windows_sdk_version_from_path(path: &Path) -> [u32; 4] { let mut best = [0u32; 4]; for component in path.components() { let Some(text) = component.as_os_str().to_str() else { continue; }; let parts = text.split('.').collect::>(); if parts.len() < 2 || parts.len() > 4 { continue; } let mut version = [0u32; 4]; let mut parsed = true; for (index, part) in parts.iter().enumerate() { match part.parse::() { Ok(value) => version[index] = value, Err(_) => { parsed = false; break; } } } if parsed && version > best { best = version; } } best } fn verify_pe_signature(signtool: &Path, file: &Path) -> Result<()> { let output = capture(CommandSpec::new(signtool).args([ "verify", "/pa", "/all", "/tw", file.to_string_lossy().as_ref(), ]))?; ensure!( output.status == 0, "signtool verify /pa /all /tw failed with exit code {}", output.status ); Ok(()) } fn verify_windows_pe_signatures( signtool: &Path, label: &str, root: &Path, files: &[PathBuf], ) -> Result<()> { ensure!( !files.is_empty(), "{label}: no Windows PE files found under {}. Refusing to publish an unverified inventory.", root.display() ); let root = absolute_path(root)?; let files = files .iter() .map(|file| absolute_path(file)) .collect::>>()?; let rows = authenticode_report(&files)?; let mut failures = Vec::new(); for file in &files { let relative = relative_display(&root, file); if let Err(error) = verify_pe_signature(signtool, file) { failures.push(format!("{relative}: {error}")); continue; } let Some(row) = rows .iter() .find(|row| same_windows_path(&row.path, file.as_path())) else { failures.push(format!( "{relative}: Get-AuthenticodeSignature reported no row for this file" )); continue; }; if let Err(error) = assert_signed_by_known_publisher(row, &relative) { failures.push(format!("{relative}: {error}")); } } ensure!( failures.is_empty(), "{label}: {} of {} Windows binaries do not have an approved signature:\n{}", failures.len(), files.len(), failures.join("\n") ); println!( "{label}: verified {} Windows binary signatures.", files.len() ); Ok(()) } fn verify_windows_unpacked_signatures_step() -> Result<()> { let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); let arch = require_env("ARCH")?; let config = windows_package_config(&build_channel, &arch); let pack_dir = resolve_windows_unpacked_dir(&arch, &config.main_exe)?; let files = collect_pe_files(&pack_dir)?; assert_windows_package_file_policy(&pack_dir, &arch)?; assert_windows_native_pe_machines(&pack_dir, &files, &arch, &config.main_exe)?; assert_expected_windows_pe_inventory(&pack_dir, &files, &arch, &config.main_exe)?; ensure!( files.iter().any(|file| extension_is(file, "node")), "No .node addon was detected as a PE file under {}; the exe,dll,node signing filter would have been a silent no-op.", pack_dir.display() ); ensure!( files.iter().any(|file| extension_is(file, "dll")), "No .dll was detected as a PE file under {}; the exe,dll,node signing filter would have been a silent no-op.", pack_dir.display() ); let signtool = find_signtool()?; verify_windows_pe_signatures(&signtool, "win-unpacked", &pack_dir, &files) } fn short_extraction_root(key: &str) -> PathBuf { let drive = env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string()); let base = PathBuf::from(format!("{}\\fxv", drive.trim_end_matches(['\\', '/']))); let digest = hex::encode(Sha256::digest(key.as_bytes())); base.join(&digest[..12]) } fn extract_zip_safely(archive_path: &Path, destination: &Path) -> Result<()> { let file = File::open(archive_path) .with_context(|| format!("Failed to open {}", archive_path.display()))?; let mut archive = zip::ZipArchive::new(file) .with_context(|| format!("Failed to read zip {}", archive_path.display()))?; fs::create_dir_all(destination) .with_context(|| format!("Failed to create {}", destination.display()))?; for index in 0..archive.len() { let mut entry = archive.by_index(index)?; let is_dir = entry.is_dir(); let relative = entry.enclosed_name().ok_or_else(|| { anyhow!( "Refusing to extract unsafe archive path '{}' from {}", entry.name(), archive_path.display() ) })?; let target = destination.join(relative); if is_dir { fs::create_dir_all(&target) .with_context(|| format!("Failed to create {}", target.display()))?; continue; } if let Some(parent) = target.parent() { fs::create_dir_all(parent) .with_context(|| format!("Failed to create {}", parent.display()))?; } let mut output = File::create(&target) .with_context(|| format!("Failed to create {}", target.display()))?; io::copy(&mut entry, &mut output) .with_context(|| format!("Failed to extract {}", target.display()))?; } Ok(()) } fn verify_windows_signed_artifacts_step() -> Result<()> { let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); let arch = require_env("ARCH")?; let version = require_env("VERSION")?; let config = windows_package_config(&build_channel, &arch); let nupkg = first_file_matching(&config.output_dir, |name| name.ends_with("-full.nupkg")) .ok_or_else(|| { anyhow!( "No Velopack full nupkg found in {}", config.output_dir.display() ) })?; let setup_exe = config.output_dir.join(format!( "{}-{version}-win-{arch}.exe", config.artifact_prefix )); ensure!( setup_exe.is_file(), "Velopack Setup.exe not found: {}", setup_exe.display() ); let portable_zip = PathBuf::from("dist-electron").join(format!( "{}-{version}-portable-win-{arch}.zip", config.artifact_prefix )); ensure!( portable_zip.is_file(), "Portable ZIP not found: {}", portable_zip.display() ); let staged_nupkgs = collect_files(&config.output_dir)? .into_iter() .filter(|path| extension_is(path, "nupkg")) .collect::>(); let unclassified_nupkgs = staged_nupkgs .iter() .filter(|path| **path != nupkg) .map(|path| path.display().to_string()) .collect::>(); ensure!( unclassified_nupkgs.is_empty(), "{} stages {} nupkg(s) other than the verified full package, so they would be published unverified:\n{}", config.output_dir.display(), unclassified_nupkgs.len(), unclassified_nupkgs.join("\n") ); let unverified_zips = collect_files(&config.output_dir)? .into_iter() .filter(|path| extension_is(path, "zip") && *path != portable_zip) .map(|path| path.display().to_string()) .collect::>(); ensure!( unverified_zips.is_empty(), "{} stages {} zip(s) that are not the verified portable archive {}, so they would be published unverified:\n{}", config.output_dir.display(), unverified_zips.len(), portable_zip.display(), unverified_zips.join("\n") ); let signtool = find_signtool()?; let root = short_extraction_root(&format!("{}-{version}-{arch}", config.pack_id)); remove_dir_if_exists(&root)?; let nupkg_root = root.join("n"); extract_zip_safely(&nupkg, &nupkg_root)?; let lib_app = nupkg_root.join("lib").join("app"); ensure!( lib_app.is_dir(), "{} contains no lib/app tree.", nupkg.display() ); ensure!( lib_app.join("Squirrel.exe").is_file(), "{} contains no lib/app/Squirrel.exe.", nupkg.display() ); let execution_stub = format!("{}_ExecutionStub.exe", config.pack_title); ensure!( lib_app.join(&execution_stub).is_file(), "{} contains no lib/app/{execution_stub}.", nupkg.display() ); let nupkg_files = collect_pe_files(&lib_app)?; assert_windows_package_file_policy(&lib_app, &arch)?; assert_windows_native_pe_machines(&lib_app, &nupkg_files, &arch, &config.main_exe)?; assert_expected_windows_pe_inventory(&lib_app, &nupkg_files, &arch, &config.main_exe)?; verify_windows_pe_signatures(&signtool, "nupkg lib/app", &lib_app, &nupkg_files)?; let portable_root = root.join("p"); extract_zip_safely(&portable_zip, &portable_root)?; let portable_files = collect_pe_files(&portable_root)?; assert_windows_package_file_policy(&portable_root, &arch)?; assert_windows_native_pe_machines(&portable_root, &portable_files, &arch, &config.main_exe)?; assert_expected_windows_pe_inventory(&portable_root, &portable_files, &arch, &config.main_exe)?; verify_windows_pe_signatures(&signtool, "portable zip", &portable_root, &portable_files)?; let staged_installers = collect_pe_files(&config.output_dir)?; ensure!( staged_installers.contains(&setup_exe), "Velopack output directory does not contain the renamed Setup executable {}", setup_exe.display() ); verify_windows_pe_signatures(&signtool, "setup", &config.output_dir, &staged_installers)?; remove_dir_if_exists(&root) } fn prepare_artifacts_windows_step() -> Result<()> { let arch = require_env("ARCH")?; let staging = Path::new("upload_staging"); remove_dir_if_exists(staging)?; fs::create_dir_all(staging).context("Failed to create upload_staging")?; let dist = desktop_dist_dir(); let release_dir = dist.join(format!("velopack-windows-{arch}")); ensure!( release_dir.exists(), "Velopack release directory not found: {}", release_dir.display() ); copy_matching_files(&release_dir, staging, |name| { name.ends_with(".exe") || name.ends_with(".zip") || name.ends_with(".nupkg") || name.starts_with("RELEASES") || (name.starts_with("releases") && name.ends_with(".json")) || (name.starts_with("assets") && name.ends_with(".json")) })?; let portable_suffix = format!("-portable-win-{arch}.zip"); copy_matching_files(&dist, staging, |name| name.ends_with(&portable_suffix))?; ensure!( any_file_matching(staging, |name| name.ends_with(".exe"))?, "No installer .exe staged." ); ensure!( staging.join("RELEASES").exists(), "Legacy Squirrel RELEASES file was not staged." ); ensure!( staging.join("releases.win.json").exists(), "Velopack releases.win.json was not staged." ); ensure!( any_file_matching(staging, |name| name.ends_with("-full.nupkg"))?, "No Velopack full nupkg staged." ); print_directory(staging) } fn prepare_artifacts_unix_step() -> Result<()> { let staging = Path::new("upload_staging"); remove_dir_if_exists(staging)?; fs::create_dir_all(staging).context("Failed to create upload_staging")?; let dist = desktop_dist_dir(); copy_matching_files(&dist, staging, is_unix_upload_artifact)?; print_directory(staging) } fn is_unix_upload_artifact(name: &str) -> bool { name.ends_with(".dmg") || name.ends_with(".zip") || name.ends_with(".zip.blockmap") || name.ends_with(".yml") || name.ends_with(".AppImage") || name.ends_with(".deb") || name.ends_with(".rpm") || name.ends_with(".tar.gz") } fn normalise_updater_yaml_step() -> Result<()> { if env::var("PLATFORM").unwrap_or_default() == "macos" && env::var("ARCH").unwrap_or_default() == MACOS_UNIVERSAL_ARCH { let source = Path::new("upload_staging/latest-mac.yml"); let target = Path::new("upload_staging/latest-mac-arm64.yml"); if source.exists() && !target.exists() { fs::rename(source, target).with_context(|| { format!( "Failed to rename {} to {}", source.display(), target.display() ) })?; } } Ok(()) } #[derive(Debug, Clone, Copy)] enum ArtifactChecksumKind { Extension(&'static str), Suffix(&'static str), } fn generate_checksums_step(kinds: &[ArtifactChecksumKind]) -> Result<()> { let staging = Path::new("upload_staging"); let mut generated = Vec::new(); for entry in fs::read_dir(staging).with_context(|| format!("Failed to read {}", staging.display()))? { let path = entry?.path(); if !path.is_file() { continue; } let name = file_name_string(&path)?; if !kinds.iter().any(|kind| checksum_kind_matches(*kind, &name)) { continue; } let hash = sha256_file(&path)?; let output = path.with_file_name(format!("{name}.sha256")); fs::write(&output, &hash) .with_context(|| format!("Failed to write {}", output.display()))?; println!("Generated checksum for {name}"); generated.push(output); } if generated.is_empty() { println!("No checksum files generated"); } else { for path in generated { println!("{}", path.display()); } } Ok(()) } fn checksum_kind_matches(kind: ArtifactChecksumKind, name: &str) -> bool { match kind { ArtifactChecksumKind::Extension(extension) => name .rsplit_once('.') .is_some_and(|(_, ext)| ext == extension), ArtifactChecksumKind::Suffix(suffix) => name.ends_with(suffix), } } fn stage_handoff_step() -> Result<()> { let build_channel = require_env("BUILD_CHANNEL")?; let platform = require_any_env(&["DESKTOP_PLATFORM", "PLATFORM"])?; let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?; let staging = Path::new("upload_staging"); ensure!(staging.exists(), "upload_staging is missing."); let artifact_count = count_files(staging)?; ensure!(artifact_count > 0, "upload_staging is empty."); let artifact_name = handoff_artifact_name(&build_channel, &platform, &arch, false); println!("Staging {artifact_count} desktop artifact file(s) as {artifact_name}"); append_github_output(&[("artifact_name", artifact_name.as_str())]) } fn handoff_artifact_name( build_channel: &str, platform: &str, arch: &str, signed_windows_artifacts: bool, ) -> String { let signed_suffix = if signed_windows_artifacts && platform == "windows" { "-signed" } else { "" }; format!("fluxer-desktop-{build_channel}-{platform}-{arch}{signed_suffix}") } fn build_payload_step() -> Result<()> { let payload_root = Path::new("payload_tree").join(DESKTOP_PAYLOAD_PREFIX); remove_dir_if_exists(&payload_root)?; fs::create_dir_all(&payload_root)?; let channel = require_env("CHANNEL")?; let version = require_env("VERSION")?; let pub_date = require_env("PUB_DATE")?; let artifacts = Path::new("artifacts"); let artifact_dirs = payload_artifact_dirs(artifacts, &channel)?; ensure!( !artifact_dirs.is_empty(), "No desktop build artifacts were downloaded into {}", artifacts.display() ); for (dir, identity) in artifact_dirs { let platform = match identity.platform.as_str() { "windows" => "win32", "macos" => "darwin", "linux" => "linux", other => { println!("Unknown platform: {other}"); continue; } }; for published_arch in published_arches(platform, &identity.arch) { let dest = payload_root .join(&channel) .join(platform) .join(published_arch); fs::create_dir_all(&dest)?; copy_dir_contents(&dir, &dest)?; let manifest = build_desktop_manifest( &dest, &PayloadManifestInput { channel: channel.clone(), platform: platform.to_string(), arch: published_arch.to_string(), version: version.clone(), pub_date: pub_date.clone(), }, )?; if platform == "darwin" { write_macos_releases(&dest, &channel, &manifest)?; } write_json_pretty(&dest.join("manifest.json"), &manifest)?; } } println!("Payload tree:"); print_tree(&payload_root, 6) } fn prepare_release_assets_step() -> Result<()> { let channel = require_env("CHANNEL")?; let version = require_env("VERSION")?; let source_sha = require_env("SOURCE_SHA")?; let product = desktop_release_product(&channel)?; let payload_root = Path::new("payload_tree") .join(DESKTOP_PAYLOAD_PREFIX) .join(&channel); let release_assets = Path::new("release_assets"); remove_dir_if_exists(release_assets)?; fs::create_dir_all(release_assets)?; let mut release_builder = DesktopReleaseAssetBuilder::new(&channel, &version, product, release_assets); for (platform, arch) in desktop_release_coordinates() { let dir = payload_root.join(platform).join(arch); ensure!( dir.is_dir(), "Desktop release payload directory is missing: {}", dir.display() ); let manifest_path = dir.join("manifest.json"); let manifest: DesktopManifest = serde_json::from_slice( &fs::read(&manifest_path) .with_context(|| format!("Failed to read {}", manifest_path.display()))?, ) .with_context(|| format!("Failed to parse {}", manifest_path.display()))?; ensure!( manifest.channel == channel && manifest.platform == platform && manifest.arch == arch && manifest.version == version, "Desktop release manifest identity mismatch in {}", manifest_path.display() ); let expected_kinds = desktop_release_shipped_formats(platform)? .iter() .copied() .collect::>(); let actual_kinds = manifest .files .keys() .map(String::as_str) .collect::>(); ensure!( actual_kinds == expected_kinds, "Incomplete shipped artifact set for {platform}/{arch}: expected {:?}, found {:?}", expected_kinds, actual_kinds ); for entry in manifest.files.values() { release_builder.add(platform, arch, &dir.join(entry.filename()), false)?; } let updater_files = desktop_updater_release_files(&dir, platform)?; for updater_file in updater_files { release_builder.add(platform, arch, &updater_file, true)?; } } let mut descriptor_assets = release_builder.finish(); descriptor_assets.sort_by(|left, right| left.storage_key.cmp(&right.storage_key)); let descriptor = DesktopReleaseDescriptor { schema_version: DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, channel: channel.clone(), version: version.clone(), release_tag: format!("fluxer-desktop-{channel}@{version}"), source_sha, assets: descriptor_assets, }; validate_desktop_release_descriptor(&descriptor, &channel, &version, &descriptor.source_sha)?; let descriptor_path = release_assets.join(desktop_release_descriptor_filename(&channel, &version)?); write_json_pretty(&descriptor_path, &descriptor)?; println!("GitHub release asset tree:"); print_tree(release_assets, 2) } fn desktop_updater_release_files(dir: &Path, platform: &str) -> Result> { let mut files = desktop_release_updater_feeds(platform)? .iter() .map(|name| dir.join(name)) .collect::>(); if let Some(suffix) = desktop_release_update_payload_suffix(platform)? { let payloads = collect_files(dir)? .into_iter() .filter(|path| { path.file_name() .and_then(OsStr::to_str) .is_some_and(|name| name.ends_with(suffix)) }) .collect::>(); ensure!( payloads.len() == 1, "Expected one {suffix} desktop update payload in {}, found {}", dir.display(), payloads.len() ); files.push(payloads[0].clone()); } for path in &files { ensure!( path.is_file(), "Desktop updater release file is missing: {}", path.display() ); } Ok(files) } struct DesktopReleaseAssetBuilder<'a> { channel: &'a str, version: &'a str, product: &'a str, release_assets: &'a Path, descriptor_assets: Vec, storage_keys: BTreeSet, release_asset_content: BTreeMap, release_asset_names: BTreeMap, } impl<'a> DesktopReleaseAssetBuilder<'a> { fn new(channel: &'a str, version: &'a str, product: &'a str, release_assets: &'a Path) -> Self { Self { channel, version, product, release_assets, descriptor_assets: Vec::new(), storage_keys: BTreeSet::new(), release_asset_content: BTreeMap::new(), release_asset_names: BTreeMap::new(), } } fn add(&mut self, platform: &str, arch: &str, source: &Path, qualify_name: bool) -> Result<()> { ensure!( source.is_file(), "Release source is missing: {}", source.display() ); let source_name = file_name_string(source)?; let canonical_prefix = format!("{}-{}-", self.product, self.version); ensure!( qualify_name || source_name.starts_with(&canonical_prefix), "Shipped desktop artifact name is not canonical: {source_name:?}" ); let release_asset = desktop_release_asset_name(self.channel, self.version, platform, arch, &source_name)?; ensure!( release_asset.starts_with(&canonical_prefix) && release_asset.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_') }), "Desktop release asset name is not canonical and URL-safe: {release_asset:?}" ); if let Some(existing) = self .release_asset_names .insert(release_asset.to_ascii_lowercase(), release_asset.clone()) { ensure!( existing == release_asset, "Desktop release asset names differ only by case: {existing:?} and {release_asset:?}" ); } let storage_key = format!( "{DESKTOP_PAYLOAD_PREFIX}/{}/{platform}/{arch}/{source_name}", self.channel ); ensure!( self.storage_keys.insert(storage_key.clone()), "Duplicate desktop release storage key {storage_key:?}" ); let size = fs::metadata(source) .with_context(|| format!("Failed to inspect {}", source.display()))? .len(); ensure!( size > 0, "Desktop release source is empty: {}", source.display() ); let sha256 = sha256_file(source)?; if let Some((existing_sha256, existing_size)) = self.release_asset_content.get(&release_asset) { ensure!( existing_sha256 == &sha256 && *existing_size == size, "Desktop release asset {release_asset:?} has conflicting source content" ); } else { let destination = self.release_assets.join(&release_asset); let copied = fs::copy(source, &destination).with_context(|| { format!( "Failed to copy desktop release asset {} to {}", source.display(), destination.display() ) })?; ensure!( copied == size, "Desktop release asset copy size mismatch for {}", destination.display() ); self.release_asset_content .insert(release_asset.clone(), (sha256.clone(), size)); } self.descriptor_assets.push(DesktopReleaseAsset { storage_key, release_asset, sha256, size, }); Ok(()) } fn finish(self) -> Vec { self.descriptor_assets } } #[derive(Debug, Clone, PartialEq, Eq)] struct ArtifactIdentity { platform: String, arch: String, signed: bool, } fn parse_artifact_dir_name(base: &str, channel: &str) -> Option { let prefix = format!("fluxer-desktop-{channel}-"); let rest = base.strip_prefix(&prefix)?; let (rest, signed) = rest .strip_suffix("-signed") .map(|value| (value, true)) .unwrap_or((rest, false)); let (platform, arch) = rest.rsplit_once('-')?; Some(ArtifactIdentity { platform: platform.to_string(), arch: arch.to_string(), signed, }) } fn payload_artifact_dirs( artifacts: &Path, channel: &str, ) -> Result> { let mut selected = BTreeMap::<(String, String), (PathBuf, ArtifactIdentity)>::new(); if !artifacts.exists() { return Ok(Vec::new()); } let mut dirs = fs::read_dir(artifacts) .with_context(|| format!("Failed to read {}", artifacts.display()))? .map(|entry| entry.map(|entry| entry.path())) .collect::, _>>()?; dirs.sort(); for dir in dirs { if !dir.is_dir() { continue; } let base = file_name_string(&dir)?; let Some(identity) = parse_artifact_dir_name(&base, channel) else { println!("Skipping unrecognised artifact dir: {base}"); continue; }; let key = (identity.platform.clone(), identity.arch.clone()); match selected.get(&key) { Some((_, current)) if current.signed && !identity.signed => {} Some((_, current)) if !current.signed && identity.signed => { selected.insert(key, (dir, identity)); } Some(_) => {} None => { selected.insert(key, (dir, identity)); } } } Ok(selected.into_values().collect()) } #[derive(Debug, Clone, PartialEq, Eq)] struct PayloadManifestInput { channel: String, platform: String, arch: String, version: String, pub_date: String, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] struct DesktopManifest { channel: String, platform: String, arch: String, version: String, pub_date: String, #[serde(skip_serializing_if = "Option::is_none")] minimum_system_version: Option, files: BTreeMap, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(untagged)] enum DesktopManifestFile { Name(String), Detail { filename: String, sha256: String }, } impl DesktopManifestFile { fn filename(&self) -> &str { match self { Self::Name(filename) => filename, Self::Detail { filename, .. } => filename, } } } fn build_desktop_manifest(dest: &Path, input: &PayloadManifestInput) -> Result { let candidates = manifest_candidates(dest, &input.platform, &input.arch)?; let files = candidates .into_iter() .map(|(kind, path)| manifest_file_entry(&kind, &path).map(|entry| (kind, entry))) .collect::>>()?; Ok(DesktopManifest { channel: input.channel.clone(), platform: input.platform.clone(), arch: input.arch.clone(), version: input.version.clone(), pub_date: input.pub_date.clone(), minimum_system_version: if input.platform == "darwin" { Some(MACOS_MINIMUM_SYSTEM_VERSION.to_string()) } else { None }, files, }) } fn manifest_candidates(dest: &Path, platform: &str, arch: &str) -> Result> { let mut files = collect_files(dest)?; files.sort(); let mut candidates = Vec::new(); match platform { "win32" => { if let Some(path) = first_matching_path(&files, |name| { name.ends_with(".exe") && name.to_ascii_lowercase().contains("setup") }) .or_else(|| first_matching_path(&files, |name| name.ends_with(".exe"))) { candidates.push(("setup".to_string(), path)); } if let Some(path) = first_matching_path(&files, |name| { name.to_ascii_lowercase().contains("portable") && name.ends_with(".zip") }) { candidates.push(("portable".to_string(), path)); } } "darwin" => { if let Some(path) = first_matching_path(&files, |name| { name.ends_with(&format!("-{arch}.dmg")) || name.ends_with(".dmg") }) { candidates.push(("dmg".to_string(), path)); } if let Some(path) = first_matching_path(&files, |name| { name.ends_with(&format!("-{arch}.zip")) || name.ends_with(".zip") }) { candidates.push(("zip".to_string(), path)); } } "linux" => { for (kind, suffix) in [ ("appimage", ".AppImage"), ("deb", ".deb"), ("rpm", ".rpm"), ("tar_gz", ".tar.gz"), ] { if let Some(path) = first_matching_path(&files, |name| name.ends_with(suffix)) { candidates.push((kind.to_string(), path)); } } } _ => {} } Ok(candidates) } fn manifest_file_entry(kind: &str, file: &Path) -> Result { let filename = file_name_string(file)?; let checksum_path = file.with_file_name(format!("{filename}.sha256")); if checksum_path.exists() { let sha256 = fs::read_to_string(&checksum_path) .with_context(|| format!("Failed to read {}", checksum_path.display()))? .split_whitespace() .next() .unwrap_or_default() .to_string(); ensure!( !sha256.is_empty(), "{} checksum file is empty", checksum_path.display() ); Ok(DesktopManifestFile::Detail { filename, sha256 }) } else { println!("No checksum file found for {kind}: {}", file.display()); Ok(DesktopManifestFile::Name(filename)) } } fn published_arches(platform: &str, arch: &str) -> Vec<&'static str> { if platform == "darwin" && arch == MACOS_UNIVERSAL_ARCH { return vec!["x64", "arm64"]; } match arch { "x64" => vec!["x64"], "arm64" => vec!["arm64"], other => panic!("Unsupported desktop arch: {other}"), } } fn write_macos_releases(dest: &Path, channel: &str, manifest: &DesktopManifest) -> Result<()> { let Some(zip) = manifest.files.get("zip") else { println!( "No .zip found for macOS {} in {} (auto-update requires zip artifacts).", manifest.arch, dest.display() ); return Ok(()); }; let url = format!( "{PACKAGE_ORIGIN_BASE}/{DESKTOP_PAYLOAD_PREFIX}/{channel}/{}/{}/{}", manifest.platform, manifest.arch, zip.filename() ); let releases = json!({ "currentRelease": manifest.version, "releases": [{ "version": manifest.version, "updateTo": { "version": manifest.version, "pub_date": manifest.pub_date, "notes": "", "name": manifest.version, "url": url, }, }], }); write_json_pretty(&dest.join("RELEASES.json"), &releases)?; write_json_pretty(&dest.join("releases.json"), &releases)?; Ok(()) } fn build_summary_step() -> Result<()> { let summary = require_env("GITHUB_STEP_SUMMARY")?; let display_channel = env::var("DISPLAY_CHANNEL").unwrap_or_default(); let version = require_env("VERSION")?; let channel = require_env("CHANNEL")?; let mut file = OpenOptions::new() .create(true) .append(true) .open(&summary) .with_context(|| format!("Failed to open {summary}"))?; writeln!( file, "## Desktop {} Release Assets Ready", title_case(&display_channel) )?; writeln!( file, "\n**Version:** {version}\n\n**Download prefix:** {DESKTOP_PAYLOAD_PREFIX}/{channel}/\n\n**Redirect endpoint shape:** /dl/{DESKTOP_PAYLOAD_PREFIX}/{channel}/{{plat}}/{{arch}}/{{format}}" )?; Ok(()) } fn find_dist_file(dist: &Path, predicate: F) -> Option where F: Fn(&str) -> bool, { fs::read_dir(dist) .ok()? .filter_map(std::result::Result::ok) .find_map(|entry| { let path = entry.path(); let name = path.file_name()?.to_string_lossy(); (path.is_file() && predicate(&name)).then_some(path) }) } fn find_first(root: &Path, predicate: F) -> Option where F: Fn(&Path) -> bool, { WalkDir::new(root) .into_iter() .filter_map(std::result::Result::ok) .map(|entry| entry.into_path()) .find(|path| predicate(path)) } fn first_file_matching(dir: &Path, predicate: F) -> Option where F: Fn(&str) -> bool, { fs::read_dir(dir) .ok()? .filter_map(std::result::Result::ok) .find_map(|entry| { let path = entry.path(); let name = path.file_name()?.to_string_lossy(); (path.is_file() && predicate(&name)).then_some(path) }) } fn any_file_matching(dir: &Path, predicate: F) -> Result where F: Fn(&str) -> bool, { Ok(fs::read_dir(dir) .with_context(|| format!("Failed to read {}", dir.display()))? .filter_map(std::result::Result::ok) .any(|entry| { let path = entry.path(); path.is_file() && path .file_name() .and_then(OsStr::to_str) .is_some_and(&predicate) })) } fn copy_matching_files(source: &Path, dest: &Path, predicate: F) -> Result<()> where F: Fn(&str) -> bool, { if !source.exists() { return Ok(()); } for entry in fs::read_dir(source).with_context(|| format!("Failed to read {}", source.display()))? { let path = entry?.path(); if !path.is_file() { continue; } let name = file_name_string(&path)?; if predicate(&name) { fs::copy(&path, dest.join(&name)) .with_context(|| format!("Failed to copy {}", path.display()))?; } } Ok(()) } fn create_zip_from_dir(source: &Path, output: &Path) -> Result<()> { if let Some(parent) = output.parent() { fs::create_dir_all(parent) .with_context(|| format!("Failed to create {}", parent.display()))?; } let file = File::create(output).with_context(|| format!("Failed to create {}", output.display()))?; let mut zip = zip::ZipWriter::new(file); let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); for path in collect_files(source)? { let relative = path.strip_prefix(source)?; let name = path_to_s3_key(relative); zip.start_file(name, options)?; let mut file = File::open(&path)?; io::copy(&mut file, &mut zip)?; } zip.finish()?; Ok(()) } fn first_matching_path(paths: &[PathBuf], predicate: F) -> Option where F: Fn(&str) -> bool, { paths.iter().find_map(|path| { let name = path.file_name()?.to_string_lossy(); predicate(&name).then(|| path.clone()) }) } fn extension_is(path: &Path, extension: &str) -> bool { path.extension().and_then(OsStr::to_str) == Some(extension) } pub(crate) fn file_name_string(path: &Path) -> Result { path.file_name() .and_then(OsStr::to_str) .map(ToOwned::to_owned) .ok_or_else(|| anyhow!("Path has no UTF-8 file name: {}", path.display())) } fn sha256_file(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { let read = file .read(&mut buffer) .with_context(|| format!("Failed to read {}", path.display()))?; if read == 0 { break; } hasher.update(&buffer[..read]); } Ok(hex::encode(hasher.finalize())) } fn print_directory(dir: &Path) -> Result<()> { if !dir.exists() { println!("{} does not exist", dir.display()); return Ok(()); } for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? { let path = entry?.path(); let metadata = fs::metadata(&path)?; println!("{:>12} {}", metadata.len(), path.display()); } Ok(()) } fn print_tree(root: &Path, max_depth: usize) -> Result<()> { if !root.exists() { return Ok(()); } for entry in WalkDir::new(root) .max_depth(max_depth) .into_iter() .collect::, _>>()? .into_iter() .filter(|entry| entry.file_type().is_file()) { println!("{}", entry.path().display()); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::common::parse_version_instant; use chrono::{DateTime, TimeZone, Utc}; fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> DateTime { Utc.with_ymd_and_hms(year, month, day, hour, minute, second) .single() .unwrap() } fn matrix_args() -> BuildDesktopArgs { BuildDesktopArgs { step: DesktopStep::SetMatrix, channel: None, skip_targets: None, skip_windows: Some("false".to_string()), skip_windows_x64: Some("false".to_string()), skip_windows_arm64: Some("false".to_string()), skip_macos: Some("false".to_string()), skip_linux: Some("false".to_string()), skip_linux_x64: Some("false".to_string()), skip_linux_arm64: Some("false".to_string()), } } fn write_file(path: &Path, contents: &str) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(path, contents).unwrap(); } #[test] fn resolves_explicit_calver_with_precedence() { let calver_env = CalverEnv { build_version: Some("2026.520.1".to_string()), fluxer_build_version: Some("2026.521.2".to_string()), fluxer_build_date: Some("2026-05-22T03:04:05Z".to_string()), }; assert_eq!( resolve_calver(&calver_env, dt(2026, 5, 1, 0, 0, 0)).unwrap(), "2026.520.1" ); } #[test] fn resolves_generated_calver_from_date_override() { let calver_env = CalverEnv { fluxer_build_date: Some("2026-05-20T01:02:03Z".to_string()), ..CalverEnv::default() }; assert_eq!( resolve_calver(&calver_env, dt(2026, 1, 1, 0, 0, 0)).unwrap(), "2026.520.10203" ); } #[test] fn rejects_invalid_explicit_time() { let error = parse_version_instant("2026.520.246000").unwrap_err(); assert_eq!( error.to_string(), "Invalid build version date/time: 2026.520.246000" ); } #[test] fn matrix_skip_flags_filter_individual_arches() { let mut args = matrix_args(); args.skip_windows_x64 = Some("true".to_string()); args.skip_macos = Some("true".to_string()); let selected = selected_platforms(&args) .unwrap() .into_iter() .map(platform_json) .collect::>(); assert_eq!( selected, vec![ "{\"platform\":\"windows\",\"arch\":\"arm64\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", "{\"platform\":\"linux\",\"arch\":\"x64\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", "{\"platform\":\"linux\",\"arch\":\"arm64\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", ] ); } #[test] fn matrix_selects_one_row_per_platform_arch_by_default() { let selected = selected_platforms(&matrix_args()).unwrap(); assert_eq!(selected.len(), 5); assert_eq!( selected .iter() .filter(|platform| platform.platform == "windows") .count(), 2 ); } #[test] fn matrix_skip_targets_filter_platforms_and_arches() { let mut args = matrix_args(); args.skip_targets = Some("windows-x64, macos".to_string()); let selected = selected_platforms(&args) .unwrap() .into_iter() .map(platform_json) .collect::>(); assert_eq!( selected, vec![ "{\"platform\":\"windows\",\"arch\":\"arm64\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", "{\"platform\":\"linux\",\"arch\":\"x64\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", "{\"platform\":\"linux\",\"arch\":\"arm64\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", ] ); } #[test] fn matrix_skip_targets_drop_every_windows_row() { let mut args = matrix_args(); args.skip_targets = Some("windows".to_string()); let selected = selected_platforms(&args).unwrap(); assert!( selected .iter() .all(|platform| platform.platform != "windows") ); assert_eq!(selected.len(), 3); } #[test] fn matrix_skip_targets_reject_unknown_values() { let mut args = matrix_args(); args.skip_targets = Some("windows-riscv".to_string()); let error = selected_platforms(&args).unwrap_err(); assert!(error.to_string().contains("Unknown desktop skip target")); } const BUILD_DESKTOP_WORKFLOW: &str = include_str!("../../../.github/workflows/build-desktop.yaml"); fn workflow_job(job: &str) -> &'static str { let header = format!("\n {job}:\n"); let start = BUILD_DESKTOP_WORKFLOW .find(&header) .unwrap_or_else(|| panic!("build-desktop.yaml has no {job} job")) + header.len(); let body = &BUILD_DESKTOP_WORKFLOW[start..]; let end = body .match_indices('\n') .map(|(index, _)| index + 1) .find(|&index| body[index..].starts_with(" ") && !body[index..].starts_with(" ")) .unwrap_or(body.len()); &body[..end] } fn workflow_step_names(job: &str) -> Vec<&str> { job.lines() .filter_map(|line| line.strip_prefix(" - name: ")) .collect() } #[test] fn every_build_desktop_workflow_step_dispatches_to_a_desktop_step() { let steps = BUILD_DESKTOP_WORKFLOW .lines() .filter_map(|line| line.trim().strip_prefix("--step ")) .collect::>(); assert!(steps.contains(&"stage_handoff")); for step in steps { assert!( ::from_str(step, false).is_ok(), "build-desktop.yaml dispatches unknown desktop step {step}" ); } assert!(matches!( ::from_str("stage_handoff", false), Ok(DesktopStep::StageHandoff) )); } #[test] fn the_github_release_is_the_only_destination_for_built_artifacts() { for job in ["build", "upload", "publish_release"] { let body = workflow_job(job); for forbidden in [ "S3_BUCKET", "S3_ENDPOINT", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "DOWNLOADS_S3", "_handoff/", ] { assert!( !body.contains(forbidden), "{job} must not reference {forbidden} now that the downloads bucket is gone" ); } } assert_eq!( workflow_step_names(workflow_job("publish_release")), vec![ "Checkout source", "Set up Rust toolchain (CI helpers)", "Download GitHub release assets", "Create token", "Publish GitHub desktop release", ] ); } #[test] fn the_job_handoff_travels_as_github_actions_artifacts() { let build = workflow_job("build"); assert!(build.contains("--step stage_handoff")); assert!(build.contains("name: ${{ steps.handoff.outputs.artifact_name }}")); assert!(build.contains("path: upload_staging")); let upload = workflow_job("upload"); assert!( upload.contains("pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*"), "the upload job must collect every build leg for this channel" ); assert!(upload.contains("path: artifacts")); assert!(upload.contains("name: fluxer-desktop-release-assets")); let publish = workflow_job("publish_release"); assert!(publish.contains("name: fluxer-desktop-release-assets")); assert!(publish.contains("path: release_assets")); for job in ["build", "upload", "publish_release"] { let body = workflow_job(job); for action in ["actions/upload-artifact@", "actions/download-artifact@"] { for line in body.lines().filter(|line| line.contains(action)) { let pin = line.rsplit('@').next().unwrap_or_default(); assert!( pin.len() == 40 && pin.bytes().all(|byte| byte.is_ascii_hexdigit()), "{job} must pin {action} to a full commit sha, found {line:?}" ); } } for line in body .lines() .filter_map(|line| line.trim().strip_prefix("retention-days: ")) { let days = line .parse::() .expect("retention-days must be a number"); assert!( days <= 7, "{job} keeps a job relay artifact for {days} days, which is how staging piled up before" ); } } } #[test] fn parses_handoff_artifact_dir_names() { assert_eq!( parse_artifact_dir_name("fluxer-desktop-canary-windows-arm64", "canary").unwrap(), ArtifactIdentity { platform: "windows".to_string(), arch: "arm64".to_string(), signed: false, } ); assert!(parse_artifact_dir_name("fluxer-desktop-stable-linux-x64", "canary").is_none()); assert_eq!( parse_artifact_dir_name("fluxer-desktop-canary-windows-x64-signed", "canary").unwrap(), ArtifactIdentity { platform: "windows".to_string(), arch: "x64".to_string(), signed: true, } ); } #[test] fn handoff_artifact_name_only_marks_signed_windows_uploads() { assert_eq!( handoff_artifact_name("canary", "windows", "x64", true), "fluxer-desktop-canary-windows-x64-signed" ); assert_eq!( handoff_artifact_name("canary", "linux", "x64", true), "fluxer-desktop-canary-linux-x64" ); assert_eq!( handoff_artifact_name("stable", "windows", "arm64", false), "fluxer-desktop-stable-windows-arm64" ); } #[test] fn build_channel_content_matches_expected_typescript() { assert_eq!( build_channel_content("canary"), "// SPDX-License-Identifier: AGPL-3.0-or-later\n\n\ export type BuildChannel = 'stable' | 'canary';\n\n\ export const BUILD_CHANNEL = 'canary' as BuildChannel;\n\ export const IS_CANARY = BUILD_CHANNEL === 'canary';\n\ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" ); } #[test] fn write_build_channel_file_rejects_invalid_channels() { let temp = tempfile::tempdir().unwrap(); assert_eq!( write_build_channel_file(temp.path(), "nightly") .unwrap_err() .to_string(), "Invalid BUILD_CHANNEL: nightly. Must be 'stable' or 'canary'." ); } #[test] fn write_build_channel_file_creates_and_updates_file() { let temp = tempfile::tempdir().unwrap(); write_build_channel_file(temp.path(), "stable").unwrap(); let path = temp.path().join("src/common/BuildChannel.ts"); assert_eq!( fs::read_to_string(&path).unwrap(), build_channel_content("stable") ); write_build_channel_file(temp.path(), "canary").unwrap(); assert_eq!( fs::read_to_string(&path).unwrap(), build_channel_content("canary") ); } #[test] fn payload_artifact_dirs_prefer_signed_windows_artifacts() { let temp = tempfile::tempdir().unwrap(); let artifacts = temp.path(); fs::create_dir_all(artifacts.join("fluxer-desktop-canary-windows-x64")).unwrap(); fs::create_dir_all(artifacts.join("fluxer-desktop-canary-windows-x64-signed")).unwrap(); fs::create_dir_all(artifacts.join("fluxer-desktop-canary-linux-x64")).unwrap(); fs::create_dir_all(artifacts.join("unrelated")).unwrap(); let selected = payload_artifact_dirs(artifacts, "canary") .unwrap() .into_iter() .map(|(path, identity)| { ( path.file_name().unwrap().to_string_lossy().to_string(), identity, ) }) .collect::>(); assert_eq!( selected, vec![ ( "fluxer-desktop-canary-linux-x64".to_string(), ArtifactIdentity { platform: "linux".to_string(), arch: "x64".to_string(), signed: false, }, ), ( "fluxer-desktop-canary-windows-x64-signed".to_string(), ArtifactIdentity { platform: "windows".to_string(), arch: "x64".to_string(), signed: true, }, ), ] ); } #[test] fn desktop_manifest_uses_checksum_detail_when_present() { let temp = tempfile::tempdir().unwrap(); let root = temp.path(); write_file(&root.join("Fluxer-2026.520.1-x64.AppImage"), "app"); write_file( &root.join("Fluxer-2026.520.1-x64.AppImage.sha256"), "abc123\n", ); write_file(&root.join("Fluxer-2026.520.1-x64.deb"), "deb"); let manifest = build_desktop_manifest( root, &PayloadManifestInput { channel: "canary".to_string(), platform: "linux".to_string(), arch: "x64".to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), }, ) .unwrap(); assert_eq!( manifest.files.get("appimage"), Some(&DesktopManifestFile::Detail { filename: "Fluxer-2026.520.1-x64.AppImage".to_string(), sha256: "abc123".to_string(), }) ); assert_eq!( manifest.files.get("deb"), Some(&DesktopManifestFile::Name( "Fluxer-2026.520.1-x64.deb".to_string() )) ); } #[test] fn macos_releases_json_points_at_zip_filename() { let temp = tempfile::tempdir().unwrap(); let manifest = DesktopManifest { channel: "canary".to_string(), platform: "darwin".to_string(), arch: "arm64".to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), minimum_system_version: Some(MACOS_MINIMUM_SYSTEM_VERSION.to_string()), files: BTreeMap::from([( "zip".to_string(), DesktopManifestFile::Name("Fluxer-2026.520.1-arm64.zip".to_string()), )]), }; write_macos_releases(temp.path(), "canary", &manifest).unwrap(); let releases: Value = serde_json::from_str(&fs::read_to_string(temp.path().join("RELEASES.json")).unwrap()) .unwrap(); assert_eq!( releases["releases"][0]["updateTo"]["url"], "https://pkgs.fluxer.com/desktop/canary/darwin/arm64/Fluxer-2026.520.1-arm64.zip" ); assert!(temp.path().join("releases.json").exists()); } #[test] fn desktop_manifest_publishes_macos_minimum_only_for_darwin() { let temp = tempfile::tempdir().unwrap(); let darwin_root = temp.path().join("darwin"); write_file(&darwin_root.join("Fluxer-2026.520.1-arm64.zip"), "zip"); let darwin_manifest = build_desktop_manifest( &darwin_root, &PayloadManifestInput { channel: "stable".to_string(), platform: "darwin".to_string(), arch: "arm64".to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), }, ) .unwrap(); assert_eq!( darwin_manifest.minimum_system_version.as_deref(), Some(MACOS_MINIMUM_SYSTEM_VERSION) ); let linux_root = temp.path().join("linux"); write_file(&linux_root.join("Fluxer-2026.520.1-x64.deb"), "deb"); let linux_manifest = build_desktop_manifest( &linux_root, &PayloadManifestInput { channel: "stable".to_string(), platform: "linux".to_string(), arch: "x64".to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), }, ) .unwrap(); assert_eq!(linux_manifest.minimum_system_version, None); let windows_root = temp.path().join("win32"); write_file(&windows_root.join("Fluxer-Setup-2026.520.1-x64.exe"), "exe"); let windows_manifest = build_desktop_manifest( &windows_root, &PayloadManifestInput { channel: "stable".to_string(), platform: "win32".to_string(), arch: "x64".to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), }, ) .unwrap(); assert_eq!(windows_manifest.minimum_system_version, None); } #[test] fn desktop_macos_minimum_matches_electron_builder_config() { let config_path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../fluxer_desktop/electron-builder.config.cjs"); let config = fs::read_to_string(&config_path) .expect("the electron-builder config should be readable"); let declared = config .split_once("const macOSMinimumSystemVersion = '") .and_then(|(_, rest)| rest.split_once('\'')) .map(|(value, _)| value) .expect("the electron-builder config should declare macOSMinimumSystemVersion"); assert_eq!(declared, MACOS_MINIMUM_SYSTEM_VERSION); } #[test] fn velopack_path_lengths_include_install_prefix_and_sort_descending() { let temp = tempfile::tempdir().unwrap(); let archive_path = temp.path().join("test.nupkg"); { let file = File::create(&archive_path).unwrap(); let mut zip = zip::ZipWriter::new(file); let options = SimpleFileOptions::default(); zip.start_file("short.txt", options).unwrap(); zip.write_all(b"short").unwrap(); zip.start_file("deep/path/with/long/file.txt", options) .unwrap(); zip.write_all(b"long").unwrap(); zip.finish().unwrap(); } let entries = velopack_path_lengths(&archive_path, Path::new(r"C:\Users\a\AppData\Local\Fluxer")) .unwrap(); assert_eq!(entries[0].name, "deep/path/with/long/file.txt"); assert!(entries[0].length > entries[1].length); } #[test] fn windows_package_config_tracks_channel_and_arch() { let stable = windows_package_config("stable", "x64"); assert_eq!(stable.pack_id, "fluxer_desktop"); assert_eq!(stable.runtime, "win-x64"); assert_eq!(stable.main_exe, "Fluxer.exe"); let canary = windows_package_config("canary", "arm64"); assert_eq!(canary.pack_id, "fluxer_desktop_canary"); assert_eq!(canary.runtime, "win-arm64"); assert_eq!(canary.main_exe, "Fluxer Canary.exe"); } #[test] fn create_zip_from_dir_preserves_relative_paths() { let temp = tempfile::tempdir().unwrap(); let source = temp.path().join("source"); write_file(&source.join(".portable"), ""); write_file(&source.join("resources/app.asar"), "asar"); let zip_path = temp.path().join("portable.zip"); create_zip_from_dir(&source, &zip_path).unwrap(); let file = File::open(zip_path).unwrap(); let mut zip = zip::ZipArchive::new(file).unwrap(); assert!(zip.by_name(".portable").is_ok()); assert!(zip.by_name("resources/app.asar").is_ok()); } #[test] fn velopack_portable_archives_are_removed_from_the_release_directory() { let temp = tempfile::tempdir().unwrap(); let output_dir = temp.path(); write_file( &output_dir.join("fluxer_desktop_canary-2026.810.1-Portable.zip"), "velopack", ); write_file( &output_dir.join("fluxer_desktop_canary-2026.810.1-full.nupkg"), "payload", ); write_file( &output_dir.join("Fluxer Canary-2026.810.1-win-arm64.exe"), "setup", ); write_file(&output_dir.join("RELEASES"), "feed"); remove_velopack_portable_archives(output_dir).unwrap(); let remaining = collect_files(output_dir) .unwrap() .into_iter() .filter_map(|path| file_name_string(&path).ok()) .collect::>(); assert!(!remaining.iter().any(|name| name.ends_with(".zip"))); assert!(remaining.contains("fluxer_desktop_canary-2026.810.1-full.nupkg")); assert!(remaining.contains("Fluxer Canary-2026.810.1-win-arm64.exe")); assert!(remaining.contains("RELEASES")); } #[test] fn percent_encoded_archive_names_match_their_decoded_inventory_entry() { assert_eq!( percent_decode_archive_name("Fluxer%20Canary.exe"), "Fluxer Canary.exe" ); assert_eq!(percent_decode_archive_name("Fluxer.exe"), "Fluxer.exe"); assert_eq!( percent_decode_archive_name("win-game-capture.win32-arm64-msvc.node"), "win-game-capture.win32-arm64-msvc.node" ); assert_eq!(percent_decode_archive_name("100%.dll"), "100%.dll"); assert_eq!(percent_decode_archive_name("a%zz.dll"), "a%zz.dll"); } #[test] fn canary_nupkg_inventory_accepts_percent_encoded_main_executable() { let root = Path::new("lib").join("app"); let files = expected_windows_pe_inventory("arm64", "Fluxer Canary.exe") .into_iter() .map(|name| { if name == "Fluxer Canary.exe" { return root.join("Fluxer%20Canary.exe"); } root.join(name) }) .collect::>(); assert_expected_windows_pe_inventory(&root, &files, "arm64", "Fluxer Canary.exe").unwrap(); } #[test] fn known_optional_windows_pe_inventory_never_repeats_a_required_binary() { for arch in ["x64", "arm64"] { for main_exe in ["Fluxer.exe", "Fluxer Canary.exe"] { assert_eq!( contradictory_optional_windows_pe_inventory(arch, main_exe), Vec::::new(), "{arch}/{main_exe} declares a binary as both required and known-optional" ); } } } }