Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
3912 lines
131 KiB
Rust
3912 lines
131 KiB
Rust
// 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, count_files_min_depth,
|
|
download_file, download_s3_prefix, env_bool, env_string, first_word, get_s3_object_bytes,
|
|
join_s3_key, 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, s3_client, title_case, trim_option, upload_directory_to_s3,
|
|
upload_directory_to_s3_overwrite,
|
|
};
|
|
use crate::functions::write_json_pretty;
|
|
use anyhow::{Context, Result, anyhow, bail, ensure};
|
|
use aws_sdk_s3::Client as S3Client;
|
|
use chrono::{DateTime, Utc};
|
|
use clap::{Args, ValueEnum};
|
|
use flate2::{Compression, GzBuilder, read::GzDecoder};
|
|
use reqwest::Client;
|
|
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, Cursor, Read, 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 PUBLIC_DL_BASE: &str = "https://api.fluxer.app/dl";
|
|
const PNPM_VERSION: &str = "10.29.3";
|
|
const RUST_TOOLCHAIN: &str = "1.93.0";
|
|
const DEFAULT_DESKTOP_VARIANT: &str = "default";
|
|
const WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT: &str = "windows-game-capture";
|
|
|
|
type DesktopFormat = (&'static str, &'static str);
|
|
type DesktopArchFormats = (&'static str, &'static [DesktopFormat]);
|
|
type DesktopDownloadSection = (
|
|
&'static str,
|
|
&'static str,
|
|
&'static str,
|
|
&'static [DesktopArchFormats],
|
|
);
|
|
|
|
const WINDOWS_DESKTOP_FORMATS: &[DesktopFormat] =
|
|
&[("setup", "Setup.exe"), ("portable", "Portable ZIP")];
|
|
const MACOS_DESKTOP_FORMATS: &[DesktopFormat] = &[("dmg", "DMG"), ("zip", "ZIP")];
|
|
const DESKTOP_WEBHOOK_CONTENT_LIMIT: usize = 2_000;
|
|
const LINUX_DESKTOP_FORMATS: &[DesktopFormat] = &[
|
|
("appimage", "AppImage"),
|
|
("deb", "DEB"),
|
|
("rpm", "RPM"),
|
|
("tar_gz", "tar.gz"),
|
|
];
|
|
const WINDOWS_DESKTOP_ARCHES: &[DesktopArchFormats] = &[
|
|
("x64", WINDOWS_DESKTOP_FORMATS),
|
|
("arm64", WINDOWS_DESKTOP_FORMATS),
|
|
];
|
|
const MACOS_DESKTOP_ARCHES: &[DesktopArchFormats] = &[
|
|
("x64", MACOS_DESKTOP_FORMATS),
|
|
("arm64", MACOS_DESKTOP_FORMATS),
|
|
];
|
|
const LINUX_DESKTOP_ARCHES: &[DesktopArchFormats] = &[
|
|
("x64", LINUX_DESKTOP_FORMATS),
|
|
("arm64", LINUX_DESKTOP_FORMATS),
|
|
];
|
|
const DESKTOP_DOWNLOAD_SECTIONS: &[DesktopDownloadSection] = &[
|
|
(
|
|
"win32",
|
|
DEFAULT_DESKTOP_VARIANT,
|
|
"Windows (`win32`)",
|
|
WINDOWS_DESKTOP_ARCHES,
|
|
),
|
|
(
|
|
"win32",
|
|
WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT,
|
|
"Windows Game Capture (`win32`)",
|
|
WINDOWS_DESKTOP_ARCHES,
|
|
),
|
|
(
|
|
"darwin",
|
|
DEFAULT_DESKTOP_VARIANT,
|
|
"macOS (`darwin`)",
|
|
MACOS_DESKTOP_ARCHES,
|
|
),
|
|
(
|
|
"linux",
|
|
DEFAULT_DESKTOP_VARIANT,
|
|
"Linux (`linux`)",
|
|
LINUX_DESKTOP_ARCHES,
|
|
),
|
|
];
|
|
|
|
#[derive(Debug, Args, Clone)]
|
|
pub struct BuildDesktopArgs {
|
|
#[arg(long, value_enum)]
|
|
step: DesktopStep,
|
|
#[arg(long)]
|
|
channel: Option<String>,
|
|
#[arg(long)]
|
|
test_build: Option<String>,
|
|
#[arg(long)]
|
|
skip_targets: Option<String>,
|
|
#[arg(long)]
|
|
skip_windows: Option<String>,
|
|
#[arg(long)]
|
|
skip_windows_x64: Option<String>,
|
|
#[arg(long)]
|
|
skip_windows_arm64: Option<String>,
|
|
#[arg(long)]
|
|
skip_macos: Option<String>,
|
|
#[arg(long)]
|
|
skip_macos_x64: Option<String>,
|
|
#[arg(long)]
|
|
skip_macos_arm64: Option<String>,
|
|
#[arg(long)]
|
|
skip_linux: Option<String>,
|
|
#[arg(long)]
|
|
skip_linux_x64: Option<String>,
|
|
#[arg(long)]
|
|
skip_linux_arm64: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
#[clap(rename_all = "snake_case")]
|
|
enum DesktopStep {
|
|
SetMetadata,
|
|
SetMatrix,
|
|
WindowsPaths,
|
|
SetWorkdirUnix,
|
|
EnsurePython3Windows,
|
|
SetupPnpmCorepack,
|
|
ResolvePnpmStoreWindows,
|
|
ResolvePnpmStoreUnix,
|
|
InstallSetuptoolsWindowsArm64,
|
|
InstallSetuptoolsMacos,
|
|
InstallLinuxDeps,
|
|
InstallMsvcArm64Tools,
|
|
InstallRustWindowsTargets,
|
|
InstallDependencies,
|
|
UpdateVersion,
|
|
SetBuildChannel,
|
|
BuildElectronMain,
|
|
InstallVelopackCli,
|
|
BuildAppMacos,
|
|
VerifyBundleId,
|
|
BuildAppWindows,
|
|
PackageAppWindowsVelopack,
|
|
AnalyseVelopackPaths,
|
|
BuildAppLinux,
|
|
CreatePortableZipWindows,
|
|
PrepareArtifactsWindows,
|
|
PrepareArtifactsUnix,
|
|
NormaliseUpdaterYaml,
|
|
GenerateChecksumsUnix,
|
|
GenerateChecksumsWindows,
|
|
BuildSourceTarball,
|
|
UploadHandoff,
|
|
CheckSigningSecrets,
|
|
DownloadWindowsHandoff,
|
|
CheckWindowsArtifacts,
|
|
VerifyAuthenticode,
|
|
RegenerateSignedChecksums,
|
|
StageSignedWindowsArtifacts,
|
|
DownloadHandoff,
|
|
CleanupHandoff,
|
|
BuildPayload,
|
|
UploadPayload,
|
|
VerifySourceTarball,
|
|
BuildSummary,
|
|
NotifyWebhook,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
struct Platform {
|
|
platform: &'static str,
|
|
arch: &'static str,
|
|
desktop_variant: &'static str,
|
|
os: &'static str,
|
|
electron_arch: &'static str,
|
|
}
|
|
|
|
const PLATFORMS: &[Platform] = &[
|
|
Platform {
|
|
platform: "windows",
|
|
arch: "x64",
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT,
|
|
os: "blacksmith-32vcpu-windows-2025",
|
|
electron_arch: "x64",
|
|
},
|
|
Platform {
|
|
platform: "windows",
|
|
arch: "x64",
|
|
desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT,
|
|
os: "blacksmith-32vcpu-windows-2025",
|
|
electron_arch: "x64",
|
|
},
|
|
Platform {
|
|
platform: "windows",
|
|
arch: "arm64",
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT,
|
|
os: "blacksmith-32vcpu-windows-2025",
|
|
electron_arch: "arm64",
|
|
},
|
|
Platform {
|
|
platform: "windows",
|
|
arch: "arm64",
|
|
desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT,
|
|
os: "blacksmith-32vcpu-windows-2025",
|
|
electron_arch: "arm64",
|
|
},
|
|
Platform {
|
|
platform: "macos",
|
|
arch: "x64",
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT,
|
|
os: "fluxer-desktop-macos-arm64",
|
|
electron_arch: "x64",
|
|
},
|
|
Platform {
|
|
platform: "macos",
|
|
arch: "arm64",
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT,
|
|
os: "fluxer-desktop-macos-arm64",
|
|
electron_arch: "arm64",
|
|
},
|
|
Platform {
|
|
platform: "linux",
|
|
arch: "x64",
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT,
|
|
os: "blacksmith-32vcpu-ubuntu-2404",
|
|
electron_arch: "x64",
|
|
},
|
|
Platform {
|
|
platform: "linux",
|
|
arch: "arm64",
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT,
|
|
os: "blacksmith-32vcpu-ubuntu-2404-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());
|
|
let test_build = args
|
|
.test_build
|
|
.as_deref()
|
|
.map(parse_bool)
|
|
.unwrap_or_else(|| env_bool("TEST_BUILD"));
|
|
set_metadata_step(&channel, test_build)
|
|
}
|
|
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::SetupPnpmCorepack => setup_pnpm_corepack_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(),
|
|
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::PackageAppWindowsVelopack => package_app_windows_velopack_step(),
|
|
DesktopStep::AnalyseVelopackPaths => analyse_velopack_paths_step(),
|
|
DesktopStep::BuildAppLinux => build_app_step(DesktopBuildPlatform::Linux),
|
|
DesktopStep::CreatePortableZipWindows => create_portable_zip_windows_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::BuildSourceTarball => build_source_tarball_step(),
|
|
DesktopStep::UploadHandoff => upload_handoff_step(false).await,
|
|
DesktopStep::CheckSigningSecrets => check_signing_secrets_step(),
|
|
DesktopStep::DownloadWindowsHandoff => download_windows_handoff_step().await,
|
|
DesktopStep::CheckWindowsArtifacts => check_windows_artifacts_step(),
|
|
DesktopStep::VerifyAuthenticode => verify_authenticode_step(),
|
|
DesktopStep::RegenerateSignedChecksums => regenerate_signed_checksums_step(),
|
|
DesktopStep::StageSignedWindowsArtifacts => stage_signed_windows_artifacts_step().await,
|
|
DesktopStep::DownloadHandoff => download_handoff_step().await,
|
|
DesktopStep::CleanupHandoff => cleanup_handoff_step().await,
|
|
DesktopStep::BuildPayload => build_payload_step(),
|
|
DesktopStep::UploadPayload => upload_payload_step().await,
|
|
DesktopStep::VerifySourceTarball => verify_source_tarball_step().await,
|
|
DesktopStep::BuildSummary => build_summary_step(),
|
|
DesktopStep::NotifyWebhook => notify_webhook_step().await,
|
|
}
|
|
}
|
|
|
|
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, test_build: bool) -> 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 s3_prefix = if test_build {
|
|
"desktop-test"
|
|
} else {
|
|
"desktop"
|
|
};
|
|
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),
|
|
("test_build", if test_build { "true" } else { "false" }),
|
|
("s3_prefix", s3_prefix),
|
|
("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<PathBuf> {
|
|
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<String> {
|
|
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::<Vec<_>>()
|
|
.join(",");
|
|
let windows_x64 = selected_platform(&platforms, "windows", "x64").to_string();
|
|
let windows_arm64 = selected_platform(&platforms, "windows", "arm64").to_string();
|
|
let windows_x64_default =
|
|
selected_platform_variant(&platforms, "windows", "x64", DEFAULT_DESKTOP_VARIANT)
|
|
.to_string();
|
|
let windows_arm64_default =
|
|
selected_platform_variant(&platforms, "windows", "arm64", DEFAULT_DESKTOP_VARIANT)
|
|
.to_string();
|
|
let windows_game_capture_x64 = selected_platform_variant(
|
|
&platforms,
|
|
"windows",
|
|
"x64",
|
|
WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT,
|
|
)
|
|
.to_string();
|
|
let windows_game_capture_arm64 = selected_platform_variant(
|
|
&platforms,
|
|
"windows",
|
|
"arm64",
|
|
WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT,
|
|
)
|
|
.to_string();
|
|
let matrix = format!("{{\"include\":[{include}]}}");
|
|
append_github_output(&[
|
|
("matrix", matrix.as_str()),
|
|
("windows_x64", windows_x64.as_str()),
|
|
("windows_arm64", windows_arm64.as_str()),
|
|
("windows_x64_default", windows_x64_default.as_str()),
|
|
("windows_arm64_default", windows_arm64_default.as_str()),
|
|
(
|
|
"windows_game_capture_x64",
|
|
windows_game_capture_x64.as_str(),
|
|
),
|
|
(
|
|
"windows_game_capture_arm64",
|
|
windows_game_capture_arm64.as_str(),
|
|
),
|
|
])
|
|
}
|
|
|
|
fn selected_platforms(args: &BuildDesktopArgs) -> Result<Vec<Platform>> {
|
|
let skip_targets = skip_target_set(args)?;
|
|
Ok(PLATFORMS
|
|
.iter()
|
|
.copied()
|
|
.filter(|platform| !skip_platform(*platform, args, &skip_targets))
|
|
.collect())
|
|
}
|
|
|
|
fn selected_platform(platforms: &[Platform], platform: &str, arch: &str) -> bool {
|
|
platforms
|
|
.iter()
|
|
.any(|item| item.platform == platform && item.arch == arch)
|
|
}
|
|
|
|
fn selected_platform_variant(
|
|
platforms: &[Platform],
|
|
platform: &str,
|
|
arch: &str,
|
|
desktop_variant: &str,
|
|
) -> bool {
|
|
platforms.iter().any(|item| {
|
|
item.platform == platform && item.arch == arch && item.desktop_variant == desktop_variant
|
|
})
|
|
}
|
|
|
|
fn skip_target_set(args: &BuildDesktopArgs) -> Result<BTreeSet<String>> {
|
|
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",
|
|
"windows-game-capture",
|
|
"windows-game-capture-x64",
|
|
"windows-game-capture-arm64",
|
|
"macos",
|
|
"macos-x64",
|
|
"macos-arm64",
|
|
"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::<Vec<_>>().join(", ")
|
|
);
|
|
targets.insert(target);
|
|
}
|
|
Ok(targets)
|
|
}
|
|
|
|
fn skip_platform(
|
|
platform: Platform,
|
|
args: &BuildDesktopArgs,
|
|
skip_targets: &BTreeSet<String>,
|
|
) -> bool {
|
|
let flag = |arg: &Option<String>, 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;
|
|
}
|
|
if platform.desktop_variant != DEFAULT_DESKTOP_VARIANT {
|
|
let variant_arch = format!("{}-{}", platform.desktop_variant, platform.arch);
|
|
if skip_targets.contains(platform.desktop_variant)
|
|
|| skip_targets.contains(variant_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")
|
|
|| (platform.arch == "x64" && flag(&args.skip_macos_x64, "SKIP_MACOS_X64"))
|
|
|| (platform.arch == "arm64" && flag(&args.skip_macos_arm64, "SKIP_MACOS_ARM64"))
|
|
}
|
|
"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\":\"{}\",\"desktop_variant\":\"{}\",\"os\":\"{}\",\"electron_arch\":\"{}\"}}",
|
|
platform.platform,
|
|
platform.arch,
|
|
platform.desktop_variant,
|
|
platform.os,
|
|
platform.electron_arch
|
|
)
|
|
}
|
|
|
|
fn desktop_variant_from_env() -> Result<String> {
|
|
let variant = env::var("DESKTOP_VARIANT")
|
|
.ok()
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| DEFAULT_DESKTOP_VARIANT.to_string());
|
|
ensure_valid_desktop_variant(&variant)?;
|
|
Ok(variant)
|
|
}
|
|
|
|
fn ensure_valid_desktop_variant(variant: &str) -> Result<()> {
|
|
ensure!(
|
|
matches!(
|
|
variant,
|
|
DEFAULT_DESKTOP_VARIANT | WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT
|
|
),
|
|
"Unknown desktop variant: {variant}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_platform_supports_desktop_variant(platform: &str, variant: &str) -> Result<()> {
|
|
ensure_valid_desktop_variant(variant)?;
|
|
ensure!(
|
|
variant == DEFAULT_DESKTOP_VARIANT || platform == "windows",
|
|
"Desktop variant {variant} is only supported for Windows artifacts."
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn desktop_variant_path_segment(variant: &str) -> Option<&str> {
|
|
if variant == DEFAULT_DESKTOP_VARIANT {
|
|
None
|
|
} else {
|
|
Some(variant)
|
|
}
|
|
}
|
|
|
|
fn windows_artifact_dir(arch: &str, variant: &str) -> PathBuf {
|
|
let mut name = format!("windows-{arch}");
|
|
if let Some(segment) = desktop_variant_path_segment(variant) {
|
|
name.push('-');
|
|
name.push_str(segment);
|
|
}
|
|
Path::new("artifacts").join(name)
|
|
}
|
|
|
|
fn expected_windows_artifacts(arch: &str, variant: &str) -> bool {
|
|
if env_string("EXPECT_WINDOWS_ARTIFACTS").is_some() {
|
|
return env_bool("EXPECT_WINDOWS_ARTIFACTS");
|
|
}
|
|
let env_name = match (arch, variant) {
|
|
("x64", DEFAULT_DESKTOP_VARIANT) => "EXPECT_WINDOWS_X64_DEFAULT",
|
|
("arm64", DEFAULT_DESKTOP_VARIANT) => "EXPECT_WINDOWS_ARM64_DEFAULT",
|
|
("x64", WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT) => "EXPECT_WINDOWS_GAME_CAPTURE_X64",
|
|
("arm64", WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT) => "EXPECT_WINDOWS_GAME_CAPTURE_ARM64",
|
|
("x64", _) => "EXPECT_WINDOWS_X64",
|
|
_ => "EXPECT_WINDOWS_ARM64",
|
|
};
|
|
if env_string(env_name).is_some() {
|
|
return env_bool(env_name);
|
|
}
|
|
let fallback_env = if arch == "x64" {
|
|
"EXPECT_WINDOWS_X64"
|
|
} else {
|
|
"EXPECT_WINDOWS_ARM64"
|
|
};
|
|
env_bool(fallback_env)
|
|
}
|
|
|
|
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()))?;
|
|
fs::write(
|
|
Path::new(r"W:\.npmrc"),
|
|
format!("store-dir={}\n", store_dir.display()),
|
|
)
|
|
.context("Failed to write W:\\.npmrc")?;
|
|
|
|
append_github_env(&[
|
|
("WORKDIR", "W:"),
|
|
("TEMP", r"C:\t"),
|
|
("TMP", r"C:\t"),
|
|
("ELECTRON_BUILDER_CACHE", r"C:\ebcache"),
|
|
("NPM_CONFIG_STORE_DIR", store_dir.to_string_lossy().as_ref()),
|
|
("npm_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((
|
|
"NPM_CONFIG_STORE_DIR",
|
|
store_dir.to_string_lossy().to_string(),
|
|
));
|
|
env_pairs.push((
|
|
"npm_config_store_dir",
|
|
store_dir.to_string_lossy().to_string(),
|
|
));
|
|
}
|
|
|
|
let pairs = env_pairs
|
|
.iter()
|
|
.map(|(key, value)| (*key, value.as_str()))
|
|
.collect::<Vec<_>>();
|
|
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_corepack_step() -> Result<()> {
|
|
let corepack = corepack_program()?;
|
|
run_command(CommandSpec::new(corepack.clone()).arg("enable"))?;
|
|
run_command(CommandSpec::new(corepack).args([
|
|
"prepare",
|
|
&format!("pnpm@{PNPM_VERSION}"),
|
|
"--activate",
|
|
]))?;
|
|
ensure_pnpm_available()
|
|
}
|
|
|
|
fn corepack_program() -> Result<OsString> {
|
|
if command_succeeds(CommandSpec::new("corepack").arg("--version")) {
|
|
return Ok(OsString::from("corepack"));
|
|
}
|
|
|
|
if cfg!(windows) {
|
|
let node_dir =
|
|
node_executable_dir().context("Failed to locate Node.js while resolving corepack")?;
|
|
|
|
for file_name in ["corepack.cmd", "corepack.exe", "corepack"] {
|
|
let candidate = node_dir.join(file_name);
|
|
if candidate.exists() {
|
|
return Ok(candidate.into_os_string());
|
|
}
|
|
}
|
|
|
|
bail!(
|
|
"corepack not found on PATH or next to Node.js at {}",
|
|
node_dir.display()
|
|
);
|
|
}
|
|
|
|
bail!("corepack not found on PATH")
|
|
}
|
|
|
|
fn ensure_pnpm_available() -> Result<()> {
|
|
if let Ok(pnpm) = pnpm_program()
|
|
&& command_succeeds(CommandSpec::new(pnpm).arg("--version"))
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
if cfg!(windows) {
|
|
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_prefix = PathBuf::from(npm_prefix);
|
|
append_github_path(&npm_prefix)?;
|
|
|
|
for file_name in ["pnpm.cmd", "pnpm.exe", "pnpm"] {
|
|
let candidate = npm_prefix.join(file_name);
|
|
if candidate.exists() {
|
|
return run_command(CommandSpec::new(candidate.into_os_string()).arg("--version"));
|
|
}
|
|
}
|
|
}
|
|
|
|
run_command(pnpm_command()?.arg("--version"))
|
|
.context("Failed to verify pnpm after Corepack setup")
|
|
}
|
|
|
|
fn pnpm_command() -> Result<CommandSpec> {
|
|
Ok(CommandSpec::new(pnpm_program()?))
|
|
}
|
|
|
|
fn pnpm_program() -> Result<OsString> {
|
|
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<PathBuf> {
|
|
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<PathBuf>, 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<OsString> {
|
|
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<PathBuf> {
|
|
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"]))
|
|
}
|
|
|
|
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",
|
|
"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",
|
|
"libfido2-dev",
|
|
"libcbor-dev",
|
|
"libssl-dev",
|
|
"pkg-config",
|
|
"libegl-dev",
|
|
"libclang-dev",
|
|
"clang",
|
|
"libpulse-dev",
|
|
])?;
|
|
run_command(CommandSpec::new("sudo").args(["gem", "install", "--no-document", "fpm"]))
|
|
}
|
|
|
|
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<anyhow::Error> = 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 installer =
|
|
Path::new(r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe");
|
|
let install_path = Path::new(r"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools");
|
|
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 mut found = false;
|
|
let msvc_root = install_path.join("VC").join("Tools").join("MSVC");
|
|
if msvc_root.exists() {
|
|
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.exists() {
|
|
println!("ARM64 cross link.exe: {}", candidate.display());
|
|
found = true;
|
|
}
|
|
}
|
|
}
|
|
ensure!(
|
|
found,
|
|
"ARM64 cross-build tools were not installed under {}\\*\\bin\\HostX64\\arm64.",
|
|
msvc_root.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn windows_installer_process_running() -> Result<bool> {
|
|
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 arch == "x64" {
|
|
run_command(CommandSpec::new("rustup").args([
|
|
"target",
|
|
"add",
|
|
"--toolchain",
|
|
RUST_TOOLCHAIN,
|
|
"i686-pc-windows-msvc",
|
|
]))?;
|
|
run_command(CommandSpec::new("rustup").args([
|
|
"target",
|
|
"add",
|
|
"--toolchain",
|
|
RUST_TOOLCHAIN,
|
|
"aarch64-pc-windows-msvc",
|
|
]))?;
|
|
}
|
|
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",
|
|
"0.0.1298",
|
|
]))
|
|
}
|
|
|
|
#[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<PathBuf> {
|
|
let missing = ["APPLE_ID", "APPLE_APP_SPECIFIC_PASSWORD", "APPLE_TEAM_ID"]
|
|
.into_iter()
|
|
.filter(|name| env_string(name).is_none())
|
|
.collect::<Vec<_>>();
|
|
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}"
|
|
);
|
|
|
|
let expected_macho_arch = if electron_arch == "arm64" {
|
|
"arm64"
|
|
} else {
|
|
"x86_64"
|
|
};
|
|
let native_rels = macos_native_runtime_rels(&electron_arch);
|
|
for rel in native_rels {
|
|
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, &electron_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_rels(electron_arch: &str) -> Vec<String> {
|
|
[
|
|
"@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"))
|
|
.collect()
|
|
}
|
|
|
|
fn check_macho_arch(file: &Path, expected: &str, electron_arch: &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::<Vec<_>>();
|
|
ensure!(
|
|
arch_list.contains(&expected),
|
|
"{} has Mach-O archs '{archs}', expected '{expected}'",
|
|
file.display()
|
|
);
|
|
ensure!(
|
|
!(electron_arch == "x64"
|
|
&& 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(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct WindowsPackageConfig {
|
|
pack_id: &'static str,
|
|
pack_title: &'static str,
|
|
icon_dir: &'static str,
|
|
runtime: &'static str,
|
|
main_exe: String,
|
|
output_dir: PathBuf,
|
|
}
|
|
|
|
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,
|
|
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()?;
|
|
|
|
run_command(CommandSpec::new(vpk).args([
|
|
"--yes",
|
|
"pack",
|
|
"--packId",
|
|
config.pack_id,
|
|
"--packVersion",
|
|
version.as_str(),
|
|
"--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",
|
|
"BestSpeed",
|
|
]))?;
|
|
|
|
validate_velopack_output(&config, &version, &arch)?;
|
|
print_directory(&config.output_dir)
|
|
}
|
|
|
|
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"));
|
|
|
|
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."
|
|
);
|
|
let full_nupkg = full_nupkg.ok_or_else(|| {
|
|
anyhow!("Velopack did not produce a full nupkg payload for Windows updates.")
|
|
})?;
|
|
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.pack_title);
|
|
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 find_windows_unpacked_app(arch: &str, main_exe: &str) -> Option<PathBuf> {
|
|
windows_unpacked_candidates(arch)
|
|
.into_iter()
|
|
.find(|candidate| candidate.join(main_exe).exists())
|
|
}
|
|
|
|
fn windows_unpacked_candidates(arch: &str) -> Vec<PathBuf> {
|
|
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<PathBuf> {
|
|
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::<usize>().ok())
|
|
.unwrap_or(260);
|
|
let headroom = env::var("PATH_HEADROOM")
|
|
.ok()
|
|
.and_then(|value| value.parse::<usize>().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<Vec<ArchivePathLength>> {
|
|
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(());
|
|
};
|
|
fs::write(pack_dir.join(".portable"), "")
|
|
.with_context(|| format!("Failed to write {}", pack_dir.join(".portable").display()))?;
|
|
let zip_name = format!("{}-{version}-portable-win-{arch}.zip", config.pack_title);
|
|
let zip_path = PathBuf::from("dist-electron").join(zip_name);
|
|
create_zip_from_dir(&pack_dir, &zip_path)?;
|
|
let size_mb = fs::metadata(&zip_path)?.len() as f64 / 1024.0 / 1024.0;
|
|
println!(
|
|
"Created portable ZIP: {} ({size_mb:.1} MB)",
|
|
zip_path.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
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() == "arm64"
|
|
{
|
|
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 build_source_tarball_step() -> Result<()> {
|
|
let workdir = require_env("WORKDIR")?;
|
|
let workdir = PathBuf::from(workdir);
|
|
let commit = require_env("SOURCE_SHA")?;
|
|
let short_commit = commit.chars().take(12).collect::<String>();
|
|
let desktop_version = require_env("VERSION")?;
|
|
let published_at = require_env("PUB_DATE")?;
|
|
let build_channel = require_env("BUILD_CHANNEL")?;
|
|
let s3_prefix = require_env("S3_DESKTOP_PREFIX")?;
|
|
let filename = format!("fluxer_desktop-source-{desktop_version}-{short_commit}.tar.gz");
|
|
let archive_dir = Path::new("source_staging").join("by-commit").join(&commit);
|
|
let required_linux_packaging = [
|
|
"packaging/linux/app.fluxer.Fluxer.desktop",
|
|
"packaging/linux/app.fluxer.Fluxer.metainfo.xml",
|
|
"packaging/linux/app.fluxer.Fluxer.svg",
|
|
"packaging/linux/app.fluxer.FluxerCanary.desktop",
|
|
"packaging/linux/app.fluxer.FluxerCanary.metainfo.xml",
|
|
"packaging/linux/app.fluxer.FluxerCanary.svg",
|
|
];
|
|
|
|
ensure!(
|
|
workdir.join("fluxer_desktop/LICENSE").exists(),
|
|
"Missing fluxer_desktop/LICENSE"
|
|
);
|
|
for file in required_linux_packaging {
|
|
let path = workdir.join("fluxer_desktop").join(file);
|
|
ensure!(
|
|
path.exists(),
|
|
"Missing required packaging file {}",
|
|
path.display()
|
|
);
|
|
}
|
|
run_command(
|
|
CommandSpec::new("desktop-file-validate").arg(
|
|
workdir
|
|
.join("fluxer_desktop/packaging/linux/app.fluxer.Fluxer.desktop")
|
|
.to_string_lossy()
|
|
.as_ref(),
|
|
),
|
|
)?;
|
|
run_command(
|
|
CommandSpec::new("desktop-file-validate").arg(
|
|
workdir
|
|
.join("fluxer_desktop/packaging/linux/app.fluxer.FluxerCanary.desktop")
|
|
.to_string_lossy()
|
|
.as_ref(),
|
|
),
|
|
)?;
|
|
for file in [
|
|
"app.fluxer.Fluxer.metainfo.xml",
|
|
"app.fluxer.FluxerCanary.metainfo.xml",
|
|
] {
|
|
run_command(
|
|
CommandSpec::new("appstreamcli").args([
|
|
"validate",
|
|
"--no-net",
|
|
workdir
|
|
.join("fluxer_desktop/packaging/linux")
|
|
.join(file)
|
|
.to_string_lossy()
|
|
.as_ref(),
|
|
]),
|
|
)?;
|
|
}
|
|
|
|
remove_dir_if_exists(Path::new("source_staging"))?;
|
|
fs::create_dir_all(&archive_dir)
|
|
.with_context(|| format!("Failed to create {}", archive_dir.display()))?;
|
|
|
|
let source_dir = TempDir::new().context("Failed to create source temp directory")?;
|
|
let prefix = format!("fluxer_desktop-{desktop_version}-{commit}/");
|
|
let archive_bytes = output_bytes(CommandSpec::new("git").args([
|
|
"-C",
|
|
workdir.to_string_lossy().as_ref(),
|
|
"archive",
|
|
"--format=tar",
|
|
&format!("--prefix={prefix}"),
|
|
"HEAD:fluxer_desktop",
|
|
]))?;
|
|
tar::Archive::new(Cursor::new(archive_bytes))
|
|
.unpack(source_dir.path())
|
|
.context("Failed to unpack git archive")?;
|
|
|
|
let source_root = source_dir
|
|
.path()
|
|
.join(format!("fluxer_desktop-{desktop_version}-{commit}"));
|
|
rewrite_package_version(&source_root.join("package.json"), &desktop_version)?;
|
|
|
|
let tar_gz_path = archive_dir.join(&filename);
|
|
create_deterministic_tar_gz(&source_root, &tar_gz_path, &published_at)?;
|
|
let archived_package_version = package_version_from_tar_gz(
|
|
&tar_gz_path,
|
|
&format!("fluxer_desktop-{desktop_version}-{commit}/package.json"),
|
|
)?;
|
|
ensure!(
|
|
archived_package_version == desktop_version,
|
|
"Archived package version {archived_package_version} did not match {desktop_version}"
|
|
);
|
|
|
|
let sha256 = sha256_file(&tar_gz_path)?;
|
|
let size = fs::metadata(&tar_gz_path)?.len();
|
|
fs::write(
|
|
archive_dir.join(format!("{filename}.sha256")),
|
|
format!("{sha256} {filename}\n"),
|
|
)?;
|
|
fs::copy(
|
|
&tar_gz_path,
|
|
Path::new("source_staging").join("latest.tar.gz"),
|
|
)?;
|
|
fs::write(
|
|
Path::new("source_staging").join("latest.tar.gz.sha256"),
|
|
format!("{sha256} {filename}\n"),
|
|
)?;
|
|
|
|
let latest = SourceManifest {
|
|
filename: filename.clone(),
|
|
key: format!("{s3_prefix}/source/by-commit/{commit}/{filename}"),
|
|
sha256,
|
|
commit,
|
|
published_at: published_at.clone(),
|
|
size,
|
|
desktop_version: desktop_version.clone(),
|
|
desktop_version_source: DesktopVersionSource {
|
|
channel: build_channel.clone(),
|
|
platform: "linux".to_string(),
|
|
arch: "x64".to_string(),
|
|
key: format!("{s3_prefix}/{build_channel}/linux/x64/manifest.json"),
|
|
pub_date: published_at,
|
|
},
|
|
};
|
|
write_json_pretty(Path::new("source_staging/latest.json"), &latest)?;
|
|
|
|
println!("Desktop source payload:");
|
|
print_tree(Path::new("source_staging"), 4)
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
|
struct SourceManifest {
|
|
filename: String,
|
|
key: String,
|
|
sha256: String,
|
|
commit: String,
|
|
published_at: String,
|
|
size: u64,
|
|
desktop_version: String,
|
|
desktop_version_source: DesktopVersionSource,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
|
struct DesktopVersionSource {
|
|
channel: String,
|
|
platform: String,
|
|
arch: String,
|
|
key: String,
|
|
pub_date: String,
|
|
}
|
|
|
|
fn rewrite_package_version(package_json: &Path, version: &str) -> Result<()> {
|
|
let mut package: Value = serde_json::from_str(
|
|
&fs::read_to_string(package_json)
|
|
.with_context(|| format!("Failed to read {}", package_json.display()))?,
|
|
)
|
|
.with_context(|| format!("Failed to parse {}", package_json.display()))?;
|
|
package["version"] = Value::String(version.to_string());
|
|
write_json_pretty(package_json, &package)
|
|
}
|
|
|
|
fn create_deterministic_tar_gz(
|
|
source_root: &Path,
|
|
output: &Path,
|
|
published_at: &str,
|
|
) -> Result<()> {
|
|
let mtime = DateTime::parse_from_rfc3339(published_at)
|
|
.with_context(|| format!("Invalid PUB_DATE: {published_at}"))?
|
|
.timestamp()
|
|
.try_into()
|
|
.context("PUB_DATE timestamp did not fit u64")?;
|
|
let file =
|
|
File::create(output).with_context(|| format!("Failed to create {}", output.display()))?;
|
|
let encoder = GzBuilder::new()
|
|
.mtime(0)
|
|
.write(file, Compression::default());
|
|
let mut builder = tar::Builder::new(encoder);
|
|
let parent = source_root
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("source root has no parent: {}", source_root.display()))?;
|
|
let mut paths = WalkDir::new(source_root)
|
|
.follow_links(false)
|
|
.into_iter()
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
paths.sort_by(|a, b| a.path().cmp(b.path()));
|
|
|
|
for entry in paths {
|
|
let path = entry.path();
|
|
let archive_path = path.strip_prefix(parent)?;
|
|
let metadata = fs::symlink_metadata(path)?;
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_mtime(mtime);
|
|
header.set_uid(0);
|
|
header.set_gid(0);
|
|
header.set_mode(if metadata.is_dir() { 0o755 } else { 0o644 });
|
|
if metadata.is_dir() {
|
|
header.set_entry_type(tar::EntryType::Directory);
|
|
header.set_size(0);
|
|
header.set_cksum();
|
|
builder.append_data(&mut header, archive_path, io::empty())?;
|
|
} else if metadata.file_type().is_symlink() {
|
|
let target = fs::read_link(path)?;
|
|
header.set_entry_type(tar::EntryType::Symlink);
|
|
header.set_size(0);
|
|
header.set_cksum();
|
|
builder.append_link(&mut header, archive_path, target)?;
|
|
} else if metadata.is_file() {
|
|
header.set_entry_type(tar::EntryType::Regular);
|
|
header.set_size(metadata.len());
|
|
header.set_cksum();
|
|
let mut file = File::open(path)?;
|
|
builder.append_data(&mut header, archive_path, &mut file)?;
|
|
}
|
|
}
|
|
let encoder = builder.into_inner()?;
|
|
encoder.finish()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn package_version_from_tar_gz(tar_gz: &Path, package_json_path: &str) -> Result<String> {
|
|
let file =
|
|
File::open(tar_gz).with_context(|| format!("Failed to open {}", tar_gz.display()))?;
|
|
let decoder = GzDecoder::new(file);
|
|
let mut archive = tar::Archive::new(decoder);
|
|
for entry in archive.entries()? {
|
|
let mut entry = entry?;
|
|
if entry.path()?.to_string_lossy() == package_json_path {
|
|
let mut text = String::new();
|
|
entry.read_to_string(&mut text)?;
|
|
let package: Value = serde_json::from_str(&text)?;
|
|
return package
|
|
.get("version")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.ok_or_else(|| anyhow!("package.json in archive has no version"));
|
|
}
|
|
}
|
|
bail!("{package_json_path} not found in {}", tar_gz.display())
|
|
}
|
|
|
|
async fn upload_handoff_step(signed_windows_artifacts: bool) -> Result<()> {
|
|
let client = s3_client(None).await?;
|
|
let bucket = require_env("S3_BUCKET")?;
|
|
let prefix = require_env("DESKTOP_HANDOFF_PREFIX")?;
|
|
let build_channel = require_env("BUILD_CHANNEL")?;
|
|
let platform = require_any_env(&["DESKTOP_PLATFORM", "PLATFORM"])?;
|
|
let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?;
|
|
let desktop_variant = desktop_variant_from_env()?;
|
|
ensure_platform_supports_desktop_variant(&platform, &desktop_variant)?;
|
|
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,
|
|
&desktop_variant,
|
|
signed_windows_artifacts,
|
|
);
|
|
let artifact_prefix = join_s3_key(&prefix, &artifact_name);
|
|
println!("Uploading {artifact_count} desktop artifact file(s) to {artifact_prefix}");
|
|
upload_directory_to_s3(&client, &bucket, &artifact_prefix, staging, |_| true).await?;
|
|
|
|
let source_staging = Path::new("source_staging");
|
|
if !signed_windows_artifacts && source_staging.exists() {
|
|
let source_count = count_files(source_staging)?;
|
|
if source_count > 0 {
|
|
let source_name = format!("fluxer-desktop-{build_channel}-source-linux-x64");
|
|
let source_prefix = join_s3_key(&prefix, &source_name);
|
|
println!("Uploading {source_count} desktop source file(s) to {source_prefix}");
|
|
upload_directory_to_s3(&client, &bucket, &source_prefix, source_staging, |_| true)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handoff_artifact_name(
|
|
build_channel: &str,
|
|
platform: &str,
|
|
arch: &str,
|
|
desktop_variant: &str,
|
|
signed_windows_artifacts: bool,
|
|
) -> String {
|
|
let variant_suffix = desktop_variant_path_segment(desktop_variant)
|
|
.map(|variant| format!("-{variant}"))
|
|
.unwrap_or_default();
|
|
let signed_suffix = if signed_windows_artifacts && platform == "windows" {
|
|
"-signed"
|
|
} else {
|
|
""
|
|
};
|
|
format!("fluxer-desktop-{build_channel}-{platform}-{arch}{variant_suffix}{signed_suffix}")
|
|
}
|
|
|
|
fn check_signing_secrets_step() -> Result<()> {
|
|
if env_string("AZURE_CLIENT_ID").is_some() {
|
|
append_github_output(&[("enabled", "true")])
|
|
} else {
|
|
println!("::notice::Windows code signing secrets not configured - skipping signing.");
|
|
append_github_output(&[("enabled", "false")])
|
|
}
|
|
}
|
|
|
|
async fn download_windows_handoff_step() -> Result<()> {
|
|
let client = s3_client(None).await?;
|
|
let bucket = require_env("S3_BUCKET")?;
|
|
let prefix = require_env("DESKTOP_HANDOFF_PREFIX")?;
|
|
let build_channel = require_env("BUILD_CHANNEL")?;
|
|
let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?;
|
|
let desktop_variant = desktop_variant_from_env()?;
|
|
let artifact_name =
|
|
handoff_artifact_name(&build_channel, "windows", &arch, &desktop_variant, false);
|
|
let target = windows_artifact_dir(&arch, &desktop_variant);
|
|
remove_dir_if_exists(&target)?;
|
|
fs::create_dir_all(&target)?;
|
|
let artifact_prefix = join_s3_key(&prefix, &artifact_name);
|
|
download_s3_prefix(&client, &bucket, &artifact_prefix, &target).await
|
|
}
|
|
|
|
fn check_windows_artifacts_step() -> Result<()> {
|
|
let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?;
|
|
let desktop_variant = desktop_variant_from_env()?;
|
|
let artifact_path = windows_artifact_dir(&arch, &desktop_variant);
|
|
let found = artifact_path.exists() && count_files(&artifact_path)? > 0;
|
|
if !found && expected_windows_artifacts(&arch, &desktop_variant) {
|
|
bail!("Expected Windows artifacts for {arch} ({desktop_variant}), but none were found.");
|
|
}
|
|
append_github_output(&[("found", if found { "true" } else { "false" })])?;
|
|
if found {
|
|
println!("Found Windows artifacts for {arch} ({desktop_variant}).");
|
|
} else {
|
|
println!(
|
|
"No Windows artifacts found for {arch} ({desktop_variant}). Skipping signing for this variant."
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_authenticode_step() -> Result<()> {
|
|
let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?;
|
|
let desktop_variant = desktop_variant_from_env()?;
|
|
let artifact_path = windows_artifact_dir(&arch, &desktop_variant);
|
|
let files = collect_files(&artifact_path)?
|
|
.into_iter()
|
|
.filter(|path| extension_is(path, "exe"))
|
|
.collect::<Vec<_>>();
|
|
ensure!(
|
|
!files.is_empty(),
|
|
"No executable files found to verify for {arch}."
|
|
);
|
|
let signtool = find_signtool()?;
|
|
for file in files {
|
|
run_command(CommandSpec::new(&signtool).args([
|
|
"verify",
|
|
"/pa",
|
|
"/all",
|
|
file.to_string_lossy().as_ref(),
|
|
]))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn regenerate_signed_checksums_step() -> Result<()> {
|
|
let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?;
|
|
let desktop_variant = desktop_variant_from_env()?;
|
|
let artifact_path = windows_artifact_dir(&arch, &desktop_variant);
|
|
for file in collect_files(&artifact_path)?
|
|
.into_iter()
|
|
.filter(|path| extension_is(path, "exe"))
|
|
{
|
|
let hash = sha256_file(&file)?;
|
|
let name = file_name_string(&file)?;
|
|
fs::write(file.with_file_name(format!("{name}.sha256")), &hash)?;
|
|
println!("Regenerated checksum for {}", file.display());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn stage_signed_windows_artifacts_step() -> Result<()> {
|
|
let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?;
|
|
let desktop_variant = desktop_variant_from_env()?;
|
|
let source = windows_artifact_dir(&arch, &desktop_variant);
|
|
let staging = Path::new("upload_staging");
|
|
remove_dir_if_exists(staging)?;
|
|
fs::create_dir_all(staging)?;
|
|
copy_dir_contents(&source, staging)?;
|
|
upload_handoff_step(true).await
|
|
}
|
|
|
|
async fn download_handoff_step() -> Result<()> {
|
|
let client = s3_client(None).await?;
|
|
let bucket = require_env("S3_BUCKET")?;
|
|
let prefix = require_env("DESKTOP_HANDOFF_PREFIX")?;
|
|
let artifacts = Path::new("artifacts");
|
|
remove_dir_if_exists(artifacts)?;
|
|
fs::create_dir_all(artifacts)?;
|
|
println!("Downloading desktop handoff artifacts from {prefix}");
|
|
download_s3_prefix(&client, &bucket, &prefix, artifacts).await?;
|
|
ensure!(
|
|
count_files_min_depth(artifacts, 2)? > 0,
|
|
"No desktop handoff files were downloaded."
|
|
);
|
|
println!("Downloaded handoff artifact tree:");
|
|
print_tree(artifacts, 3)
|
|
}
|
|
|
|
async fn cleanup_handoff_step() -> Result<()> {
|
|
println!("S3 handoff cleanup skipped: CI S3 writes are append-only and never delete objects.");
|
|
Ok(())
|
|
}
|
|
|
|
fn build_payload_step() -> Result<()> {
|
|
let s3_prefix = require_env("S3_DESKTOP_PREFIX")?;
|
|
let payload_root = Path::new("s3_payload").join(&s3_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");
|
|
for (dir, identity) in payload_artifact_dirs(artifacts, &channel)? {
|
|
let platform = match identity.platform.as_str() {
|
|
"windows" => "win32",
|
|
"macos" => "darwin",
|
|
"linux" => "linux",
|
|
other => {
|
|
println!("Unknown platform: {other}");
|
|
continue;
|
|
}
|
|
};
|
|
let mut dest = payload_root
|
|
.join(&channel)
|
|
.join(platform)
|
|
.join(&identity.arch);
|
|
if let Some(segment) = desktop_variant_path_segment(&identity.desktop_variant) {
|
|
dest = dest.join(segment);
|
|
}
|
|
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: identity.arch.clone(),
|
|
desktop_variant: identity.desktop_variant.clone(),
|
|
version: version.clone(),
|
|
pub_date: pub_date.clone(),
|
|
},
|
|
)?;
|
|
if platform == "darwin" {
|
|
write_macos_releases(&dest, &s3_prefix, &channel, &manifest)?;
|
|
}
|
|
write_json_pretty(&dest.join("manifest.json"), &manifest)?;
|
|
}
|
|
|
|
let source_artifact_root = artifacts.join(format!("fluxer-desktop-{channel}-source-linux-x64"));
|
|
let source_artifact = if source_artifact_root.join("latest.json").exists() {
|
|
source_artifact_root.clone()
|
|
} else {
|
|
source_artifact_root.join("source_staging")
|
|
};
|
|
if source_artifact.join("latest.json").exists() {
|
|
let source_dest = payload_root.join("source");
|
|
fs::create_dir_all(&source_dest)?;
|
|
copy_dir_contents(&source_artifact, &source_dest)?;
|
|
}
|
|
|
|
println!("Payload tree:");
|
|
print_tree(&payload_root, 6)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ArtifactIdentity {
|
|
platform: String,
|
|
arch: String,
|
|
desktop_variant: String,
|
|
source: bool,
|
|
signed: bool,
|
|
}
|
|
|
|
fn parse_artifact_dir_name(base: &str, channel: &str) -> Option<ArtifactIdentity> {
|
|
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));
|
|
if rest == "source-linux-x64" {
|
|
return Some(ArtifactIdentity {
|
|
platform: "linux".to_string(),
|
|
arch: "x64".to_string(),
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(),
|
|
source: true,
|
|
signed: false,
|
|
});
|
|
}
|
|
let (rest, desktop_variant) = rest
|
|
.strip_suffix(&format!("-{WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT}"))
|
|
.map(|value| (value, WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT))
|
|
.unwrap_or((rest, DEFAULT_DESKTOP_VARIANT));
|
|
let (platform, arch) = rest.rsplit_once('-')?;
|
|
Some(ArtifactIdentity {
|
|
platform: platform.to_string(),
|
|
arch: arch.to_string(),
|
|
desktop_variant: desktop_variant.to_string(),
|
|
source: false,
|
|
signed,
|
|
})
|
|
}
|
|
|
|
fn payload_artifact_dirs(
|
|
artifacts: &Path,
|
|
channel: &str,
|
|
) -> Result<Vec<(PathBuf, ArtifactIdentity)>> {
|
|
let mut selected = BTreeMap::<(String, 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::<std::result::Result<Vec<_>, _>>()?;
|
|
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;
|
|
};
|
|
if identity.source {
|
|
continue;
|
|
}
|
|
|
|
let key = (
|
|
identity.platform.clone(),
|
|
identity.arch.clone(),
|
|
identity.desktop_variant.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,
|
|
desktop_variant: String,
|
|
version: String,
|
|
pub_date: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
|
struct DesktopManifest {
|
|
channel: String,
|
|
platform: String,
|
|
arch: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
variant: Option<String>,
|
|
version: String,
|
|
pub_date: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
minimum_system_version: Option<String>,
|
|
files: BTreeMap<String, DesktopManifestFile>,
|
|
}
|
|
|
|
#[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<DesktopManifest> {
|
|
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::<Result<BTreeMap<_, _>>>()?;
|
|
Ok(DesktopManifest {
|
|
channel: input.channel.clone(),
|
|
platform: input.platform.clone(),
|
|
arch: input.arch.clone(),
|
|
variant: desktop_variant_path_segment(&input.desktop_variant).map(ToOwned::to_owned),
|
|
version: input.version.clone(),
|
|
pub_date: input.pub_date.clone(),
|
|
minimum_system_version: if input.platform == "darwin" {
|
|
Some("12.0".to_string())
|
|
} else {
|
|
None
|
|
},
|
|
files,
|
|
})
|
|
}
|
|
|
|
fn manifest_candidates(dest: &Path, platform: &str, arch: &str) -> Result<Vec<(String, PathBuf)>> {
|
|
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<DesktopManifestFile> {
|
|
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 write_macos_releases(
|
|
dest: &Path,
|
|
s3_prefix: &str,
|
|
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!(
|
|
"{PUBLIC_DL_BASE}/{s3_prefix}/{channel}/{}/{}/{}/{}",
|
|
manifest.platform,
|
|
manifest.arch,
|
|
zip.filename(),
|
|
""
|
|
);
|
|
let url = url.trim_end_matches('/').to_string();
|
|
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(())
|
|
}
|
|
|
|
async fn upload_payload_step() -> Result<()> {
|
|
let client = s3_client(None).await?;
|
|
let s3_prefix = require_env("S3_DESKTOP_PREFIX")?;
|
|
let bucket = require_env("S3_BUCKET")?;
|
|
let payload_root = Path::new("s3_payload").join(&s3_prefix);
|
|
let overwrite_existing = should_overwrite_payload(&s3_prefix, env_bool("TEST_BUILD"));
|
|
|
|
println!("Uploading desktop binaries and checksums first (prefix: {s3_prefix})...");
|
|
upload_payload_directory(
|
|
&client,
|
|
&bucket,
|
|
&s3_prefix,
|
|
&payload_root,
|
|
overwrite_existing,
|
|
|relative| !is_payload_metadata_key(relative),
|
|
)
|
|
.await?;
|
|
println!("Uploading manifests and updater metadata last...");
|
|
upload_payload_directory(
|
|
&client,
|
|
&bucket,
|
|
&s3_prefix,
|
|
&payload_root,
|
|
overwrite_existing,
|
|
is_payload_metadata_key,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn upload_payload_directory<F>(
|
|
client: &S3Client,
|
|
bucket: &str,
|
|
s3_prefix: &str,
|
|
payload_root: &Path,
|
|
overwrite_existing: bool,
|
|
include: F,
|
|
) -> Result<()>
|
|
where
|
|
F: Fn(&Path) -> bool,
|
|
{
|
|
if overwrite_existing {
|
|
upload_directory_to_s3_overwrite(client, bucket, s3_prefix, payload_root, include).await
|
|
} else {
|
|
upload_directory_to_s3(client, bucket, s3_prefix, payload_root, include).await
|
|
}
|
|
}
|
|
|
|
fn should_overwrite_payload(s3_prefix: &str, test_build: bool) -> bool {
|
|
test_build && s3_prefix == "desktop-test"
|
|
}
|
|
|
|
fn is_payload_metadata_key(relative: &Path) -> bool {
|
|
let name = relative
|
|
.file_name()
|
|
.and_then(OsStr::to_str)
|
|
.unwrap_or_default();
|
|
name == "manifest.json"
|
|
|| name.ends_with(".yml")
|
|
|| name.starts_with("RELEASES")
|
|
|| (name.starts_with("releases") && name.ends_with(".json"))
|
|
|| (name.starts_with("assets") && name.ends_with(".json"))
|
|
|| relative
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
.ends_with("source/latest.json")
|
|
}
|
|
|
|
async fn verify_source_tarball_step() -> Result<()> {
|
|
let client = s3_client(None).await?;
|
|
let s3_prefix = require_env("S3_DESKTOP_PREFIX")?;
|
|
let manifest_path = Path::new("s3_payload")
|
|
.join(&s3_prefix)
|
|
.join("source")
|
|
.join("latest.json");
|
|
if !manifest_path.exists() {
|
|
println!("No desktop source tarball payload present; skipping source verification.");
|
|
return Ok(());
|
|
}
|
|
|
|
let manifest: SourceManifest = serde_json::from_str(
|
|
&fs::read_to_string(&manifest_path)
|
|
.with_context(|| format!("Failed to read {}", manifest_path.display()))?,
|
|
)?;
|
|
ensure!(
|
|
!manifest.filename.is_empty()
|
|
&& !manifest.sha256.is_empty()
|
|
&& !manifest.desktop_version.is_empty()
|
|
&& !manifest.commit.is_empty(),
|
|
"Desktop source manifest is missing filename, sha256, desktop_version, or commit."
|
|
);
|
|
|
|
let bucket = require_env("S3_BUCKET")?;
|
|
let remote_base = join_s3_key(&s3_prefix, "source");
|
|
let remote_sha256 = first_word(&String::from_utf8(
|
|
get_s3_object_bytes(
|
|
&client,
|
|
&bucket,
|
|
&join_s3_key(
|
|
&remote_base,
|
|
&format!("by-commit/{}/{}.sha256", manifest.commit, manifest.filename),
|
|
),
|
|
)
|
|
.await?
|
|
.to_vec(),
|
|
)?);
|
|
let latest_sha256 = first_word(&String::from_utf8(
|
|
get_s3_object_bytes(
|
|
&client,
|
|
&bucket,
|
|
&join_s3_key(&remote_base, "latest.tar.gz.sha256"),
|
|
)
|
|
.await?
|
|
.to_vec(),
|
|
)?);
|
|
let remote_manifest: SourceManifest = serde_json::from_slice(
|
|
&get_s3_object_bytes(&client, &bucket, &join_s3_key(&remote_base, "latest.json")).await?,
|
|
)?;
|
|
ensure!(
|
|
remote_sha256 == manifest.sha256,
|
|
"Remote by-commit checksum mismatch"
|
|
);
|
|
ensure!(
|
|
latest_sha256 == manifest.sha256,
|
|
"Remote latest checksum mismatch"
|
|
);
|
|
ensure!(
|
|
remote_manifest.sha256 == manifest.sha256,
|
|
"Remote manifest checksum mismatch"
|
|
);
|
|
ensure!(
|
|
remote_manifest.desktop_version == manifest.desktop_version,
|
|
"Remote manifest desktop version mismatch"
|
|
);
|
|
|
|
let tar_bytes = get_s3_object_bytes(
|
|
&client,
|
|
&bucket,
|
|
&join_s3_key(
|
|
&remote_base,
|
|
&format!("by-commit/{}/{}", manifest.commit, manifest.filename),
|
|
),
|
|
)
|
|
.await?;
|
|
let package_version = package_version_from_tar_gz_bytes(
|
|
&tar_bytes,
|
|
&format!(
|
|
"fluxer_desktop-{}-{}/package.json",
|
|
manifest.desktop_version, manifest.commit
|
|
),
|
|
)?;
|
|
ensure!(
|
|
package_version == manifest.desktop_version,
|
|
"Remote source tarball package version mismatch"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn package_version_from_tar_gz_bytes(bytes: &[u8], package_json_path: &str) -> Result<String> {
|
|
let decoder = GzDecoder::new(Cursor::new(bytes));
|
|
let mut archive = tar::Archive::new(decoder);
|
|
for entry in archive.entries()? {
|
|
let mut entry = entry?;
|
|
if entry.path()?.to_string_lossy() == package_json_path {
|
|
let mut text = String::new();
|
|
entry.read_to_string(&mut text)?;
|
|
let package: Value = serde_json::from_str(&text)?;
|
|
return package
|
|
.get("version")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.ok_or_else(|| anyhow!("package.json in archive has no version"));
|
|
}
|
|
}
|
|
bail!("{package_json_path} not found in source tarball")
|
|
}
|
|
|
|
fn build_summary_step() -> Result<()> {
|
|
let summary = require_env("GITHUB_STEP_SUMMARY")?;
|
|
let test_build = env_bool("TEST_BUILD");
|
|
let display_channel = env::var("DISPLAY_CHANNEL").unwrap_or_default();
|
|
let version = require_env("VERSION")?;
|
|
let s3_prefix = require_env("S3_DESKTOP_PREFIX")?;
|
|
let channel = require_env("CHANNEL")?;
|
|
let mut file = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&summary)
|
|
.with_context(|| format!("Failed to open {summary}"))?;
|
|
if test_build {
|
|
writeln!(
|
|
file,
|
|
"## Desktop {} Test Upload Complete",
|
|
title_case(&display_channel)
|
|
)?;
|
|
writeln!(
|
|
file,
|
|
"\n_This is a **test build**. Artifacts were stashed under `{s3_prefix}/` so the API will not promote them as a release._"
|
|
)?;
|
|
} else {
|
|
writeln!(
|
|
file,
|
|
"## Desktop {} Upload Complete",
|
|
title_case(&display_channel)
|
|
)?;
|
|
}
|
|
writeln!(
|
|
file,
|
|
"\n**Version:** {version}\n\n**S3 prefix:** {s3_prefix}/{channel}/\n\n**Redirect endpoint shape:** /dl/{s3_prefix}/{channel}/{{plat}}/{{arch}}[/{{variant}}]/{{format}}"
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn notify_webhook_step() -> Result<()> {
|
|
let version = require_env("VERSION")?;
|
|
let channel = env::var("DISPLAY_CHANNEL")
|
|
.ok()
|
|
.filter(|value| !value.is_empty())
|
|
.or_else(|| env::var("CHANNEL").ok())
|
|
.unwrap_or_default();
|
|
let test_build = env_bool("TEST_BUILD");
|
|
|
|
if !should_notify_desktop_webhook(&channel) {
|
|
println!("Skipping desktop notification for channel={channel}, test_build={test_build}.");
|
|
return Ok(());
|
|
}
|
|
|
|
let webhook_url = env::var("FLUXER_WEBHOOK_URL")
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_string();
|
|
if webhook_url.is_empty() {
|
|
println!("FLUXER_WEBHOOK_URL is not set; skipping desktop canary notification.");
|
|
return Ok(());
|
|
}
|
|
|
|
let messages = desktop_webhook_messages(&version, test_build, None)?;
|
|
let message_count = messages.len();
|
|
for (index, message) in messages.into_iter().enumerate() {
|
|
let response = Client::new()
|
|
.post(&webhook_url)
|
|
.header("User-Agent", "fluxer-ci-desktop")
|
|
.json(&json!({
|
|
"content": message,
|
|
"allowed_mentions": {"parse": []},
|
|
}))
|
|
.send()
|
|
.await
|
|
.context("Failed to send desktop webhook")?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let body = response.text().await.unwrap_or_default();
|
|
bail!(
|
|
"Desktop webhook part {}/{} returned {status}: {body}",
|
|
index + 1,
|
|
message_count
|
|
);
|
|
}
|
|
println!(
|
|
"Desktop canary notification part {}/{} sent ({status}).",
|
|
index + 1,
|
|
message_count
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn should_notify_desktop_webhook(channel: &str) -> bool {
|
|
channel == "canary"
|
|
}
|
|
|
|
fn desktop_payload_root() -> PathBuf {
|
|
let s3_prefix = env::var("S3_DESKTOP_PREFIX").unwrap_or_else(|_| "desktop".to_string());
|
|
let channel = env::var("CHANNEL").unwrap_or_else(|_| "canary".to_string());
|
|
PathBuf::from("s3_payload").join(s3_prefix).join(channel)
|
|
}
|
|
|
|
fn desktop_manifest_formats(
|
|
payload_root: &Path,
|
|
platform: &str,
|
|
arch: &str,
|
|
desktop_variant: &str,
|
|
) -> Result<BTreeSet<String>> {
|
|
let mut manifest_path = payload_root.join(platform).join(arch);
|
|
if let Some(segment) = desktop_variant_path_segment(desktop_variant) {
|
|
manifest_path = manifest_path.join(segment);
|
|
}
|
|
manifest_path = manifest_path.join("manifest.json");
|
|
if !manifest_path.exists() {
|
|
return Ok(BTreeSet::new());
|
|
}
|
|
|
|
let manifest: Value = serde_json::from_str(
|
|
&fs::read_to_string(&manifest_path)
|
|
.with_context(|| format!("Failed to read {}", manifest_path.display()))?,
|
|
)
|
|
.with_context(|| format!("Failed to parse {}", manifest_path.display()))?;
|
|
let files = manifest
|
|
.get("files")
|
|
.and_then(Value::as_object)
|
|
.ok_or_else(|| {
|
|
anyhow!(
|
|
"Invalid desktop manifest files object: {}",
|
|
manifest_path.display()
|
|
)
|
|
})?;
|
|
Ok(files.keys().cloned().collect())
|
|
}
|
|
|
|
fn desktop_download_url(
|
|
platform: &str,
|
|
arch: &str,
|
|
desktop_variant: &str,
|
|
version: &str,
|
|
format_name: &str,
|
|
test_build: bool,
|
|
) -> String {
|
|
let test_query = if test_build { "?test=1" } else { "" };
|
|
let variant_segment = desktop_variant_path_segment(desktop_variant)
|
|
.map(|variant| format!("/{variant}"))
|
|
.unwrap_or_default();
|
|
format!(
|
|
"{PUBLIC_DL_BASE}/desktop/canary/{platform}/{arch}{variant_segment}/{version}/{format_name}{test_query}"
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn desktop_download_table(
|
|
version: &str,
|
|
test_build: bool,
|
|
payload_root: Option<&Path>,
|
|
) -> Result<String> {
|
|
Ok(desktop_download_sections(version, test_build, payload_root)?.join("\n\n"))
|
|
}
|
|
|
|
fn desktop_webhook_messages(
|
|
version: &str,
|
|
test_build: bool,
|
|
payload_root: Option<&Path>,
|
|
) -> Result<Vec<String>> {
|
|
let title = if test_build {
|
|
"Canary Desktop Test Build Ready"
|
|
} else {
|
|
"Canary Desktop Build Ready"
|
|
};
|
|
let mut messages = Vec::new();
|
|
let mut current = format!("## {title}\n\nDesktop app version: `{version}`");
|
|
for section in desktop_download_sections(version, test_build, payload_root)? {
|
|
append_desktop_webhook_section(&mut messages, &mut current, §ion)?;
|
|
}
|
|
if !current.is_empty() {
|
|
messages.push(current);
|
|
}
|
|
Ok(messages)
|
|
}
|
|
|
|
fn append_desktop_webhook_section(
|
|
messages: &mut Vec<String>,
|
|
current: &mut String,
|
|
section: &str,
|
|
) -> Result<()> {
|
|
let separator = if current.is_empty() { "" } else { "\n\n" };
|
|
let candidate = format!("{current}{separator}{section}");
|
|
if candidate.chars().count() <= DESKTOP_WEBHOOK_CONTENT_LIMIT {
|
|
*current = candidate;
|
|
return Ok(());
|
|
}
|
|
|
|
if !current.is_empty() {
|
|
messages.push(std::mem::take(current));
|
|
}
|
|
ensure!(
|
|
section.chars().count() <= DESKTOP_WEBHOOK_CONTENT_LIMIT,
|
|
"Desktop webhook section exceeds {DESKTOP_WEBHOOK_CONTENT_LIMIT} characters."
|
|
);
|
|
current.push_str(section);
|
|
Ok(())
|
|
}
|
|
|
|
fn desktop_download_sections(
|
|
version: &str,
|
|
test_build: bool,
|
|
payload_root: Option<&Path>,
|
|
) -> Result<Vec<String>> {
|
|
let root;
|
|
let payload_root = match payload_root {
|
|
Some(path) => path,
|
|
None => {
|
|
root = desktop_payload_root();
|
|
root.as_path()
|
|
}
|
|
};
|
|
let mut rendered_sections = Vec::new();
|
|
for (platform, desktop_variant, heading, arch_groups) in DESKTOP_DOWNLOAD_SECTIONS {
|
|
let mut rows = Vec::new();
|
|
for (arch, formats) in *arch_groups {
|
|
let available_formats =
|
|
desktop_manifest_formats(payload_root, platform, arch, desktop_variant)?;
|
|
for (format_name, label) in *formats {
|
|
if available_formats.contains(*format_name) {
|
|
rows.push(format!(
|
|
"| {arch} | {label} | {} |",
|
|
desktop_download_url(
|
|
platform,
|
|
arch,
|
|
desktop_variant,
|
|
version,
|
|
format_name,
|
|
test_build
|
|
)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !rows.is_empty() {
|
|
let mut table = vec![
|
|
"| Arch | Format | URL |".to_string(),
|
|
"|---|---|---|".to_string(),
|
|
];
|
|
table.extend(rows);
|
|
rendered_sections.push(format!("## {heading}\n\n{}", table.join("\n")));
|
|
}
|
|
}
|
|
|
|
if rendered_sections.is_empty() {
|
|
bail!(
|
|
"No desktop manifests found under {}; refusing to send an empty notification.",
|
|
payload_root.display()
|
|
);
|
|
}
|
|
|
|
Ok(rendered_sections)
|
|
}
|
|
|
|
fn find_dist_file<F>(dist: &Path, predicate: F) -> Option<PathBuf>
|
|
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<F>(root: &Path, predicate: F) -> Option<PathBuf>
|
|
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<F>(dir: &Path, predicate: F) -> Option<PathBuf>
|
|
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<F>(dir: &Path, predicate: F) -> Result<bool>
|
|
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<F>(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<F>(paths: &[PathBuf], predicate: F) -> Option<PathBuf>
|
|
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)
|
|
}
|
|
|
|
fn file_name_string(path: &Path) -> Result<String> {
|
|
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<String> {
|
|
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::<std::result::Result<Vec<_>, _>>()?
|
|
.into_iter()
|
|
.filter(|entry| entry.file_type().is_file())
|
|
{
|
|
println!("{}", entry.path().display());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn find_signtool() -> Result<PathBuf> {
|
|
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"),
|
|
];
|
|
for root in roots {
|
|
if !root.exists() {
|
|
continue;
|
|
}
|
|
if let Some(path) = find_first(&root, |path| {
|
|
path.file_name()
|
|
.and_then(OsStr::to_str)
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("signtool.exe"))
|
|
&& path.to_string_lossy().contains("x64")
|
|
}) {
|
|
return Ok(path);
|
|
}
|
|
}
|
|
Ok(PathBuf::from("signtool.exe"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::common::{directory_upload_plan, parse_version_instant, s3_directory_prefix};
|
|
use chrono::{DateTime, TimeZone, Utc};
|
|
|
|
fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> DateTime<Utc> {
|
|
Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
|
|
.single()
|
|
.unwrap()
|
|
}
|
|
|
|
fn matrix_args() -> BuildDesktopArgs {
|
|
BuildDesktopArgs {
|
|
step: DesktopStep::SetMatrix,
|
|
channel: None,
|
|
test_build: 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_macos_x64: Some("false".to_string()),
|
|
skip_macos_arm64: 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 only_desktop_test_payloads_overwrite_existing_s3_objects() {
|
|
assert!(should_overwrite_payload("desktop-test", true));
|
|
assert!(!should_overwrite_payload("desktop", true));
|
|
assert!(!should_overwrite_payload("desktop-test", false));
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_webhook_notifies_canary_uploads_and_tests() {
|
|
assert!(should_notify_desktop_webhook("canary"));
|
|
assert!(!should_notify_desktop_webhook("stable"));
|
|
}
|
|
|
|
#[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::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
selected,
|
|
vec![
|
|
"{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-windows-2025\",\"electron_arch\":\"arm64\"}",
|
|
"{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"windows-game-capture\",\"os\":\"blacksmith-32vcpu-windows-2025\",\"electron_arch\":\"arm64\"}",
|
|
"{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-ubuntu-2404\",\"electron_arch\":\"x64\"}",
|
|
"{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-ubuntu-2404-arm\",\"electron_arch\":\"arm64\"}",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[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::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
selected,
|
|
vec![
|
|
"{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-windows-2025\",\"electron_arch\":\"arm64\"}",
|
|
"{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"windows-game-capture\",\"os\":\"blacksmith-32vcpu-windows-2025\",\"electron_arch\":\"arm64\"}",
|
|
"{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-ubuntu-2404\",\"electron_arch\":\"x64\"}",
|
|
"{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-ubuntu-2404-arm\",\"electron_arch\":\"arm64\"}",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
#[test]
|
|
fn s3_key_join_and_path_conversion_are_platform_neutral() {
|
|
assert_eq!(
|
|
join_s3_key("/desktop/", "/canary/linux/"),
|
|
"desktop/canary/linux"
|
|
);
|
|
assert_eq!(
|
|
s3_directory_prefix("/_handoff/desktop/build/"),
|
|
"_handoff/desktop/build/"
|
|
);
|
|
assert_eq!(join_s3_key("", "manifest.json"), "manifest.json");
|
|
assert_eq!(
|
|
path_to_s3_key(Path::new("canary").join("linux").join("x64").as_path()),
|
|
"canary/linux/x64"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn upload_plan_splits_payload_metadata_without_s3() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let root = temp.path();
|
|
write_file(&root.join("canary/linux/x64/Fluxer.AppImage"), "app");
|
|
write_file(&root.join("canary/linux/x64/manifest.json"), "{}");
|
|
write_file(&root.join("source/latest.json"), "{}");
|
|
write_file(&root.join("canary/darwin/x64/releases.json"), "{}");
|
|
|
|
let binaries = directory_upload_plan("desktop", root, |relative| {
|
|
!is_payload_metadata_key(relative)
|
|
})
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|item| item.key)
|
|
.collect::<Vec<_>>();
|
|
let metadata = directory_upload_plan("desktop", root, is_payload_metadata_key)
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|item| item.key)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(binaries, vec!["desktop/canary/linux/x64/Fluxer.AppImage"]);
|
|
assert_eq!(
|
|
metadata,
|
|
vec![
|
|
"desktop/canary/darwin/x64/releases.json",
|
|
"desktop/canary/linux/x64/manifest.json",
|
|
"desktop/source/latest.json",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[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(),
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(),
|
|
source: false,
|
|
signed: false,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
parse_artifact_dir_name(
|
|
"fluxer-desktop-canary-windows-x64-windows-game-capture-signed",
|
|
"canary",
|
|
)
|
|
.unwrap(),
|
|
ArtifactIdentity {
|
|
platform: "windows".to_string(),
|
|
arch: "x64".to_string(),
|
|
desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT.to_string(),
|
|
source: false,
|
|
signed: true,
|
|
}
|
|
);
|
|
assert!(parse_artifact_dir_name("fluxer-desktop-stable-linux-x64", "canary").is_none());
|
|
assert!(
|
|
parse_artifact_dir_name("fluxer-desktop-canary-source-linux-x64", "canary")
|
|
.unwrap()
|
|
.source
|
|
);
|
|
assert_eq!(
|
|
parse_artifact_dir_name("fluxer-desktop-canary-windows-x64-signed", "canary").unwrap(),
|
|
ArtifactIdentity {
|
|
platform: "windows".to_string(),
|
|
arch: "x64".to_string(),
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(),
|
|
source: false,
|
|
signed: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn handoff_artifact_name_only_marks_signed_windows_uploads() {
|
|
assert_eq!(
|
|
handoff_artifact_name("canary", "windows", "x64", DEFAULT_DESKTOP_VARIANT, true),
|
|
"fluxer-desktop-canary-windows-x64-signed"
|
|
);
|
|
assert_eq!(
|
|
handoff_artifact_name("canary", "linux", "x64", DEFAULT_DESKTOP_VARIANT, true),
|
|
"fluxer-desktop-canary-linux-x64"
|
|
);
|
|
assert_eq!(
|
|
handoff_artifact_name(
|
|
"stable",
|
|
"windows",
|
|
"arm64",
|
|
WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT,
|
|
false,
|
|
),
|
|
"fluxer-desktop-stable-windows-arm64-windows-game-capture"
|
|
);
|
|
assert_eq!(
|
|
handoff_artifact_name("stable", "windows", "arm64", DEFAULT_DESKTOP_VARIANT, 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-windows-x64-windows-game-capture"),
|
|
)
|
|
.unwrap();
|
|
fs::create_dir_all(
|
|
artifacts.join("fluxer-desktop-canary-windows-x64-windows-game-capture-signed"),
|
|
)
|
|
.unwrap();
|
|
fs::create_dir_all(artifacts.join("fluxer-desktop-canary-linux-x64")).unwrap();
|
|
fs::create_dir_all(artifacts.join("fluxer-desktop-canary-source-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::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
selected,
|
|
vec![
|
|
(
|
|
"fluxer-desktop-canary-linux-x64".to_string(),
|
|
ArtifactIdentity {
|
|
platform: "linux".to_string(),
|
|
arch: "x64".to_string(),
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(),
|
|
source: false,
|
|
signed: false,
|
|
},
|
|
),
|
|
(
|
|
"fluxer-desktop-canary-windows-x64-signed".to_string(),
|
|
ArtifactIdentity {
|
|
platform: "windows".to_string(),
|
|
arch: "x64".to_string(),
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(),
|
|
source: false,
|
|
signed: true,
|
|
},
|
|
),
|
|
(
|
|
"fluxer-desktop-canary-windows-x64-windows-game-capture-signed".to_string(),
|
|
ArtifactIdentity {
|
|
platform: "windows".to_string(),
|
|
arch: "x64".to_string(),
|
|
desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT.to_string(),
|
|
source: false,
|
|
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(),
|
|
desktop_variant: DEFAULT_DESKTOP_VARIANT.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(),
|
|
variant: None,
|
|
version: "2026.520.1".to_string(),
|
|
pub_date: "2026-05-20T01:02:03Z".to_string(),
|
|
minimum_system_version: Some("12.0".to_string()),
|
|
files: BTreeMap::from([(
|
|
"zip".to_string(),
|
|
DesktopManifestFile::Name("Fluxer-2026.520.1-arm64.zip".to_string()),
|
|
)]),
|
|
};
|
|
|
|
write_macos_releases(temp.path(), "desktop-test", "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://api.fluxer.app/dl/desktop-test/canary/darwin/arm64/Fluxer-2026.520.1-arm64.zip"
|
|
);
|
|
assert!(temp.path().join("releases.json").exists());
|
|
}
|
|
|
|
#[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 deterministic_tarball_rewrites_and_reads_package_version() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let source_root = temp.path().join("fluxer_desktop-2026.520.1-abcdef");
|
|
write_file(
|
|
&source_root.join("package.json"),
|
|
r#"{"name":"fluxer","version":"0.0.0"}"#,
|
|
);
|
|
rewrite_package_version(&source_root.join("package.json"), "2026.520.1").unwrap();
|
|
let archive = temp.path().join("source.tar.gz");
|
|
|
|
create_deterministic_tar_gz(&source_root, &archive, "2026-05-20T01:02:03Z").unwrap();
|
|
|
|
assert_eq!(
|
|
package_version_from_tar_gz(&archive, "fluxer_desktop-2026.520.1-abcdef/package.json")
|
|
.unwrap(),
|
|
"2026.520.1"
|
|
);
|
|
assert_eq!(sha256_file(&archive).unwrap().len(), 64);
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_download_table_uses_manifest_files() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let manifest_dir = temp.path().join("win32").join("x64");
|
|
fs::create_dir_all(&manifest_dir).unwrap();
|
|
fs::write(
|
|
manifest_dir.join("manifest.json"),
|
|
r#"{"files":{"setup":"Fluxer.exe","portable":"Fluxer.zip"}}"#,
|
|
)
|
|
.unwrap();
|
|
let variant_manifest_dir = temp
|
|
.path()
|
|
.join("win32")
|
|
.join("x64")
|
|
.join(WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT);
|
|
fs::create_dir_all(&variant_manifest_dir).unwrap();
|
|
fs::write(
|
|
variant_manifest_dir.join("manifest.json"),
|
|
r#"{"files":{"setup":"Fluxer Game Capture.exe"}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let table = desktop_download_table("2026.520.1", true, Some(temp.path())).unwrap();
|
|
assert!(table.contains("## Windows (`win32`)"));
|
|
assert!(table.contains("## Windows Game Capture (`win32`)"));
|
|
assert!(table.contains("| Arch | Format | URL |"));
|
|
assert!(table.contains("|---|---|---|"));
|
|
assert!(table.contains("| x64 | Setup.exe | https://api.fluxer.app/dl/desktop/canary/win32/x64/2026.520.1/setup?test=1 |"));
|
|
assert!(table.contains("| x64 | Portable ZIP | https://api.fluxer.app/dl/desktop/canary/win32/x64/2026.520.1/portable?test=1 |"));
|
|
assert!(table.contains("| x64 | Setup.exe | https://api.fluxer.app/dl/desktop/canary/win32/x64/windows-game-capture/2026.520.1/setup?test=1 |"));
|
|
assert!(!table.contains("SHA-256"));
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_webhook_messages_split_under_receiver_limit() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let manifest = r#"{"files":{"setup":"Fluxer.exe","portable":"Fluxer.zip","dmg":"Fluxer.dmg","zip":"Fluxer.zip","appimage":"Fluxer.AppImage","deb":"Fluxer.deb","rpm":"Fluxer.rpm","tar_gz":"Fluxer.tar.gz"}}"#;
|
|
for (platform, desktop_variant, _, arch_groups) in DESKTOP_DOWNLOAD_SECTIONS {
|
|
for (arch, _) in *arch_groups {
|
|
let mut manifest_dir = temp.path().join(platform).join(arch);
|
|
if let Some(segment) = desktop_variant_path_segment(desktop_variant) {
|
|
manifest_dir = manifest_dir.join(segment);
|
|
}
|
|
write_file(&manifest_dir.join("manifest.json"), manifest);
|
|
}
|
|
}
|
|
|
|
let messages =
|
|
desktop_webhook_messages("2026.614.181201", true, Some(temp.path())).unwrap();
|
|
assert!(messages.len() > 1);
|
|
assert!(
|
|
messages
|
|
.iter()
|
|
.all(|message| message.chars().count() <= DESKTOP_WEBHOOK_CONTENT_LIMIT)
|
|
);
|
|
|
|
let combined = messages.join("\n\n");
|
|
assert!(combined.starts_with("## Canary Desktop Test Build Ready"));
|
|
assert!(combined.contains("Desktop app version: `2026.614.181201`"));
|
|
assert!(combined.contains("## Windows (`win32`)"));
|
|
assert!(combined.contains("## Windows Game Capture (`win32`)"));
|
|
assert!(combined.contains("## macOS (`darwin`)"));
|
|
assert!(combined.contains("## Linux (`linux`)"));
|
|
assert!(combined.contains("| Arch | Format | URL |"));
|
|
assert!(!combined.contains("SHA-256"));
|
|
}
|
|
}
|