mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(desktop): harden capture and release packaging (#2049)
This commit is contained in:
1 parent
5ee59c4675
commit
374db9ed2b
25 files changed
+1394
-1317
No files matched your search
+479
-56
@@ -10,6 +10,7 @@ use crate::common::{
|
||||
upload_directory_to_s3, upload_s3_plan_append_only, upload_s3_plan_overwrite,
|
||||
};
|
||||
use crate::functions::write_json_pretty;
|
||||
use crate::release::DESKTOP_RELEASE_ASSET_SUFFIXES;
|
||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use chrono::Utc;
|
||||
@@ -110,6 +111,9 @@ enum DesktopStep {
|
||||
DownloadHandoff,
|
||||
CleanupHandoff,
|
||||
BuildPayload,
|
||||
PrepareReleaseAssets,
|
||||
UploadReleaseAssets,
|
||||
DownloadReleaseAssets,
|
||||
UploadPayload,
|
||||
BuildSummary,
|
||||
}
|
||||
@@ -235,6 +239,9 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> {
|
||||
DesktopStep::DownloadHandoff => download_handoff_step().await,
|
||||
DesktopStep::CleanupHandoff => cleanup_handoff_step().await,
|
||||
DesktopStep::BuildPayload => build_payload_step(),
|
||||
DesktopStep::PrepareReleaseAssets => prepare_release_assets_step(),
|
||||
DesktopStep::UploadReleaseAssets => upload_release_assets_step().await,
|
||||
DesktopStep::DownloadReleaseAssets => download_release_assets_step().await,
|
||||
DesktopStep::UploadPayload => upload_payload_step().await,
|
||||
DesktopStep::BuildSummary => build_summary_step(),
|
||||
}
|
||||
@@ -1401,22 +1408,6 @@ fn install_rust_windows_targets_step() -> Result<()> {
|
||||
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() {
|
||||
@@ -2011,7 +2002,7 @@ fn pack_and_validate_windows_velopack(
|
||||
"--outputDir",
|
||||
config.output_dir.to_string_lossy().as_ref(),
|
||||
"--delta",
|
||||
"BestSpeed",
|
||||
"None",
|
||||
"--azureTrustedSignFile",
|
||||
trusted_sign_file.to_string_lossy().as_ref(),
|
||||
]))?;
|
||||
@@ -2063,6 +2054,15 @@ fn validate_velopack_output(
|
||||
let legacy_releases = config.output_dir.join("RELEASES");
|
||||
let velopack_releases = config.output_dir.join("releases.win.json");
|
||||
let full_nupkg = first_file_matching(&config.output_dir, |name| name.ends_with("-full.nupkg"));
|
||||
let delta_nupkgs = collect_files(&config.output_dir)?
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| name.ends_with("-delta.nupkg"))
|
||||
})
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ensure!(
|
||||
legacy_releases.exists(),
|
||||
@@ -2072,6 +2072,12 @@ fn validate_velopack_output(
|
||||
velopack_releases.exists(),
|
||||
"Velopack did not produce releases.win.json for Windows updates."
|
||||
);
|
||||
ensure!(
|
||||
delta_nupkgs.is_empty(),
|
||||
"Velopack produced {} disabled delta package(s), which cannot be independently verified against the signed full package:\n{}",
|
||||
delta_nupkgs.len(),
|
||||
delta_nupkgs.join("\n")
|
||||
);
|
||||
let full_nupkg = full_nupkg.ok_or_else(|| {
|
||||
anyhow!("Velopack did not produce a full nupkg payload for Windows updates.")
|
||||
})?;
|
||||
@@ -2243,7 +2249,15 @@ const THIRD_PARTY_WINDOWS_SIGNATURE_ALLOWLIST: &[(&str, &str)] = &[
|
||||
"CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US",
|
||||
),
|
||||
];
|
||||
const KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY: &[&str] = &["fluxer-vulkan-layer.win32-ia32-msvc.dll"];
|
||||
const KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY: &[&str] = &[];
|
||||
const FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES: &[&str] = &[
|
||||
"fluxer-game-hook.",
|
||||
"fluxer-inject-helper.",
|
||||
"fluxer-vulkan-layer.",
|
||||
"fluxer_game_hook.",
|
||||
"fluxer_inject_helper.",
|
||||
"fluxer_vulkan_layer.",
|
||||
];
|
||||
const WINDOWS_NATIVE_ADDON_STEMS: &[&str] = &[
|
||||
"hardware-encoder",
|
||||
"webauthn",
|
||||
@@ -2261,33 +2275,26 @@ fn expected_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec<String> {
|
||||
main_exe.to_string(),
|
||||
format!("velopack_nodeffi_win_{arch}_msvc.node"),
|
||||
format!("win-game-capture.{tag}.node"),
|
||||
format!("fluxer-game-hook.{tag}.dll"),
|
||||
format!("fluxer-inject-helper.{tag}.exe"),
|
||||
format!("fluxer-vulkan-layer.{tag}.dll"),
|
||||
];
|
||||
names.extend(
|
||||
WINDOWS_NATIVE_ADDON_STEMS
|
||||
.iter()
|
||||
.map(|stem| format!("{stem}.{tag}.node")),
|
||||
);
|
||||
if arch == "x64" {
|
||||
names.push("fluxer-game-hook.win32-ia32-msvc.dll".to_string());
|
||||
names.push("fluxer-inject-helper.win32-ia32-msvc.exe".to_string());
|
||||
}
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
fn is_pe_file(path: &Path) -> Result<bool> {
|
||||
fn read_pe_machine(path: &Path) -> Result<Option<u16>> {
|
||||
let mut file =
|
||||
File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
||||
let mut dos_header = [0u8; 0x40];
|
||||
if !read_exact_or_eof(&mut file, &mut dos_header, path)? {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
if &dos_header[0..2] != b"MZ" {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
let e_lfanew = u32::from_le_bytes([
|
||||
dos_header[0x3c],
|
||||
@@ -2299,9 +2306,20 @@ fn is_pe_file(path: &Path) -> Result<bool> {
|
||||
.with_context(|| format!("Failed to seek in {}", path.display()))?;
|
||||
let mut signature = [0u8; 4];
|
||||
if !read_exact_or_eof(&mut file, &mut signature, path)? {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(&signature == b"PE\0\0")
|
||||
if &signature != b"PE\0\0" {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut machine = [0u8; 2];
|
||||
if !read_exact_or_eof(&mut file, &mut machine, path)? {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(u16::from_le_bytes(machine)))
|
||||
}
|
||||
|
||||
fn is_pe_file(path: &Path) -> Result<bool> {
|
||||
Ok(read_pe_machine(path)?.is_some())
|
||||
}
|
||||
|
||||
fn read_exact_or_eof(file: &mut File, buffer: &mut [u8], path: &Path) -> Result<bool> {
|
||||
@@ -2361,6 +2379,61 @@ fn percent_decode_archive_name(name: &str) -> String {
|
||||
String::from_utf8(decoded).unwrap_or_else(|_| name.to_string())
|
||||
}
|
||||
|
||||
fn windows_pe_machine_arch(machine: u16) -> Option<&'static str> {
|
||||
match machine {
|
||||
0x014c => Some("ia32"),
|
||||
0x8664 => Some("x64"),
|
||||
0xaa64 => Some("arm64"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_windows_native_pe_machines(
|
||||
root: &Path,
|
||||
files: &[PathBuf],
|
||||
expected_arch: &str,
|
||||
main_exe: &str,
|
||||
) -> Result<()> {
|
||||
let mut violations = Vec::new();
|
||||
let normalized_main_exe = main_exe.to_ascii_lowercase();
|
||||
for path in files {
|
||||
let Some(file_name) = path.file_name().and_then(OsStr::to_str) else {
|
||||
continue;
|
||||
};
|
||||
let normalized_name = percent_decode_archive_name(file_name).to_ascii_lowercase();
|
||||
let file_arch = if normalized_name == normalized_main_exe {
|
||||
Some(expected_arch)
|
||||
} else {
|
||||
windows_native_pe_arch(&normalized_name)
|
||||
};
|
||||
let Some(file_arch) = file_arch else {
|
||||
continue;
|
||||
};
|
||||
let machine = read_pe_machine(path)?.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"{} was collected as a PE file but no COFF Machine field was readable",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let actual_arch = windows_pe_machine_arch(machine);
|
||||
if actual_arch != Some(file_arch) || actual_arch != Some(expected_arch) {
|
||||
violations.push(format!(
|
||||
"{}: file policy expects {file_arch}, COFF Machine is 0x{machine:04x} ({}) and package architecture is {expected_arch}",
|
||||
relative_display(root, path),
|
||||
actual_arch.unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
violations.is_empty(),
|
||||
"{} contains {} Windows native binary/binaries with invalid machine architecture:\n{}",
|
||||
root.display(),
|
||||
violations.len(),
|
||||
violations.join("\n")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_expected_windows_pe_inventory(
|
||||
root: &Path,
|
||||
files: &[PathBuf],
|
||||
@@ -2385,6 +2458,17 @@ fn assert_expected_windows_pe_inventory(
|
||||
missing.len(),
|
||||
missing.join("\n")
|
||||
);
|
||||
let forbidden = present
|
||||
.iter()
|
||||
.filter_map(|name| windows_pe_inventory_violation(name, arch))
|
||||
.collect::<Vec<_>>();
|
||||
ensure!(
|
||||
forbidden.is_empty(),
|
||||
"{} contains {} forbidden Windows native binaries:\n{}",
|
||||
root.display(),
|
||||
forbidden.len(),
|
||||
forbidden.join("\n")
|
||||
);
|
||||
let contradictory = contradictory_optional_windows_pe_inventory(arch, main_exe);
|
||||
ensure!(
|
||||
contradictory.is_empty(),
|
||||
@@ -2411,7 +2495,7 @@ fn assert_expected_windows_pe_inventory(
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
println!(
|
||||
"{}: {} expected, {} unlisted PE(s) shipped by glob (Electron runtime and cross-architecture native artifacts). Every one of them is signature-classified below; none may be unsigned or signed by an unknown publisher.",
|
||||
"{}: {} expected, {} unlisted PE(s) shipped by glob (Electron runtime and third-party binaries). Every one of them is signature-classified below; none may be unsigned or signed by an unknown publisher.",
|
||||
root.display(),
|
||||
expected.len(),
|
||||
unlisted.len()
|
||||
@@ -2422,6 +2506,231 @@ fn assert_expected_windows_pe_inventory(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ASAR_HEADER_LIMIT: usize = 64 * 1024 * 1024;
|
||||
const ASAR_ENTRY_LIMIT: usize = 1_000_000;
|
||||
const ASAR_NESTING_LIMIT: usize = 256;
|
||||
|
||||
fn windows_package_file_policy_violation(relative: &str, expected_arch: &str) -> bool {
|
||||
let normalized_relative = relative
|
||||
.replace('\\', "/")
|
||||
.split('/')
|
||||
.map(percent_decode_archive_name)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
.replace('\\', "/")
|
||||
.to_ascii_lowercase();
|
||||
let normalized_name = normalized_relative
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| normalized_name.starts_with(prefix))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if normalized_name == "compatibility.json"
|
||||
&& normalized_relative
|
||||
.split('/')
|
||||
.any(|component| component == "win-game-capture")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
windows_native_pe_arch(&normalized_name).is_some_and(|arch| arch != expected_arch)
|
||||
}
|
||||
|
||||
fn read_u32_le(bytes: &[u8], offset: usize) -> Result<u32> {
|
||||
let end = offset
|
||||
.checked_add(4)
|
||||
.ok_or_else(|| anyhow!("ASAR header offset overflow"))?;
|
||||
let value = bytes
|
||||
.get(offset..end)
|
||||
.ok_or_else(|| anyhow!("ASAR header is truncated at byte {offset}"))?;
|
||||
let value = <[u8; 4]>::try_from(value)
|
||||
.map_err(|_| anyhow!("ASAR header field at byte {offset} is not four bytes"))?;
|
||||
Ok(u32::from_le_bytes(value))
|
||||
}
|
||||
|
||||
fn collect_asar_policy_violations(
|
||||
node: &Value,
|
||||
expected_arch: &str,
|
||||
violations: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
let mut stack = vec![(node, String::new(), 0usize)];
|
||||
let mut entry_count = 0usize;
|
||||
while let Some((current, prefix, depth)) = stack.pop() {
|
||||
ensure!(
|
||||
depth <= ASAR_NESTING_LIMIT,
|
||||
"ASAR header nesting exceeds {ASAR_NESTING_LIMIT} levels under {prefix:?}"
|
||||
);
|
||||
let files = current
|
||||
.get("files")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| anyhow!("ASAR header node {prefix:?} has no files object"))?;
|
||||
for (name, entry) in files {
|
||||
entry_count = entry_count
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| anyhow!("ASAR entry count overflow"))?;
|
||||
ensure!(
|
||||
entry_count <= ASAR_ENTRY_LIMIT,
|
||||
"ASAR header contains more than {ASAR_ENTRY_LIMIT} entries"
|
||||
);
|
||||
let decoded_name = percent_decode_archive_name(name);
|
||||
ensure!(
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& decoded_name != "."
|
||||
&& decoded_name != ".."
|
||||
&& !decoded_name.contains('/')
|
||||
&& !decoded_name.contains('\\'),
|
||||
"ASAR header contains invalid entry name {name:?} under {prefix:?}"
|
||||
);
|
||||
let relative = if prefix.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{prefix}/{name}")
|
||||
};
|
||||
if windows_package_file_policy_violation(&relative, expected_arch) {
|
||||
violations.push(relative.clone());
|
||||
}
|
||||
if entry.get("files").is_some() {
|
||||
stack.push((entry, relative, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn asar_policy_violations(path: &Path, expected_arch: &str) -> Result<Vec<String>> {
|
||||
let mut file =
|
||||
File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
||||
let file_size = file
|
||||
.metadata()
|
||||
.with_context(|| format!("Failed to stat {}", path.display()))?
|
||||
.len();
|
||||
let mut prefix = [0u8; 16];
|
||||
file.read_exact(&mut prefix)
|
||||
.with_context(|| format!("Failed to read ASAR header prefix from {}", path.display()))?;
|
||||
let size_pickle_payload = read_u32_le(&prefix, 0)?;
|
||||
let header_pickle_size = read_u32_le(&prefix, 4)?;
|
||||
let header_pickle_payload = read_u32_le(&prefix, 8)?;
|
||||
let header_json_size = read_u32_le(&prefix, 12)?;
|
||||
let padded_header_json_size = header_json_size
|
||||
.checked_add(3)
|
||||
.map(|size| size & !3)
|
||||
.ok_or_else(|| anyhow!("{} ASAR JSON header size overflow", path.display()))?;
|
||||
let expected_header_pickle_payload = padded_header_json_size
|
||||
.checked_add(4)
|
||||
.ok_or_else(|| anyhow!("{} ASAR pickle payload size overflow", path.display()))?;
|
||||
ensure!(
|
||||
size_pickle_payload == 4
|
||||
&& header_pickle_payload.checked_add(4) == Some(header_pickle_size)
|
||||
&& header_pickle_payload == expected_header_pickle_payload,
|
||||
"{} has an invalid ASAR pickle header",
|
||||
path.display()
|
||||
);
|
||||
let header_json_size = usize::try_from(header_json_size).context("ASAR header is too large")?;
|
||||
ensure!(
|
||||
header_json_size > 0 && header_json_size <= ASAR_HEADER_LIMIT,
|
||||
"{} ASAR JSON header size {} is outside 1..={ASAR_HEADER_LIMIT}",
|
||||
path.display(),
|
||||
header_json_size
|
||||
);
|
||||
let header_pickle_size =
|
||||
usize::try_from(header_pickle_size).context("ASAR pickle header is too large")?;
|
||||
let archive_payload_offset = 8usize
|
||||
.checked_add(header_pickle_size)
|
||||
.ok_or_else(|| anyhow!("{} ASAR header size overflow", path.display()))?;
|
||||
ensure!(
|
||||
u64::try_from(archive_payload_offset).unwrap_or(u64::MAX) <= file_size,
|
||||
"{} ASAR header extends beyond the {}-byte archive",
|
||||
path.display(),
|
||||
file_size
|
||||
);
|
||||
let mut header_json = vec![0u8; header_json_size];
|
||||
file.read_exact(&mut header_json)
|
||||
.with_context(|| format!("Failed to read ASAR JSON header from {}", path.display()))?;
|
||||
let header: Value = serde_json::from_slice(&header_json)
|
||||
.with_context(|| format!("Failed to parse ASAR JSON header from {}", path.display()))?;
|
||||
let mut violations = Vec::new();
|
||||
collect_asar_policy_violations(&header, expected_arch, &mut violations)?;
|
||||
Ok(violations)
|
||||
}
|
||||
|
||||
fn assert_windows_package_file_policy(root: &Path, expected_arch: &str) -> Result<()> {
|
||||
let mut forbidden = Vec::new();
|
||||
for path in collect_files(root)? {
|
||||
let relative = relative_display(root, &path);
|
||||
if windows_package_file_policy_violation(&relative, expected_arch) {
|
||||
forbidden.push(relative.clone());
|
||||
}
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| {
|
||||
percent_decode_archive_name(name)
|
||||
.to_ascii_lowercase()
|
||||
.ends_with(".asar")
|
||||
})
|
||||
{
|
||||
forbidden.extend(
|
||||
asar_policy_violations(&path, expected_arch)?
|
||||
.into_iter()
|
||||
.map(|entry| format!("{relative}!/{entry}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
forbidden.sort();
|
||||
forbidden.dedup();
|
||||
ensure!(
|
||||
forbidden.is_empty(),
|
||||
"{} contains {} forbidden Windows package file(s):\n{}",
|
||||
root.display(),
|
||||
forbidden.len(),
|
||||
forbidden.join("\n")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn windows_pe_inventory_violation(name: &str, expected_arch: &str) -> Option<String> {
|
||||
let normalized = name.to_ascii_lowercase();
|
||||
if FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| normalized.starts_with(prefix))
|
||||
{
|
||||
return Some(format!(
|
||||
"{name}: disabled game-capture hook sidecars must not ship"
|
||||
));
|
||||
}
|
||||
let packaged_arch = windows_native_pe_arch(&normalized)?;
|
||||
if packaged_arch == expected_arch {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{name}: native architecture is {packaged_arch}, expected {expected_arch}"
|
||||
))
|
||||
}
|
||||
|
||||
fn windows_native_pe_arch(name: &str) -> Option<&'static str> {
|
||||
for (marker, arch) in [
|
||||
(".win32-x64-msvc.", "x64"),
|
||||
(".win32-arm64-msvc.", "arm64"),
|
||||
(".win32-ia32-msvc.", "ia32"),
|
||||
("_win_x64_msvc.", "x64"),
|
||||
("_win_arm64_msvc.", "arm64"),
|
||||
("_win_x86_msvc.", "ia32"),
|
||||
] {
|
||||
if name.contains(marker) {
|
||||
return Some(arch);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn contradictory_optional_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec<String> {
|
||||
let expected = expected_windows_pe_inventory(arch, main_exe);
|
||||
KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY
|
||||
@@ -2778,6 +3087,8 @@ fn verify_windows_unpacked_signatures_step() -> Result<()> {
|
||||
let config = windows_package_config(&build_channel, &arch);
|
||||
let pack_dir = resolve_windows_unpacked_dir(&arch, &config.main_exe)?;
|
||||
let files = collect_pe_files(&pack_dir)?;
|
||||
assert_windows_package_file_policy(&pack_dir, &arch)?;
|
||||
assert_windows_native_pe_machines(&pack_dir, &files, &arch, &config.main_exe)?;
|
||||
assert_expected_windows_pe_inventory(&pack_dir, &files, &arch, &config.main_exe)?;
|
||||
ensure!(
|
||||
files.iter().any(|file| extension_is(file, "node")),
|
||||
@@ -2870,23 +3181,14 @@ fn verify_windows_signed_artifacts_step() -> Result<()> {
|
||||
.into_iter()
|
||||
.filter(|path| extension_is(path, "nupkg"))
|
||||
.collect::<Vec<_>>();
|
||||
let delta_nupkgs = staged_nupkgs
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| name.ends_with("-delta.nupkg"))
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let unclassified_nupkgs = staged_nupkgs
|
||||
.iter()
|
||||
.filter(|path| **path != nupkg && !delta_nupkgs.contains(path))
|
||||
.filter(|path| **path != nupkg)
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
ensure!(
|
||||
unclassified_nupkgs.is_empty(),
|
||||
"{} stages {} nupkg(s) that are neither the verified full package nor a delta package, so they would be published unverified:\n{}",
|
||||
"{} stages {} nupkg(s) other than the verified full package, so they would be published unverified:\n{}",
|
||||
config.output_dir.display(),
|
||||
unclassified_nupkgs.len(),
|
||||
unclassified_nupkgs.join("\n")
|
||||
@@ -2930,26 +3232,16 @@ fn verify_windows_signed_artifacts_step() -> Result<()> {
|
||||
nupkg.display()
|
||||
);
|
||||
let nupkg_files = collect_pe_files(&lib_app)?;
|
||||
assert_windows_package_file_policy(&lib_app, &arch)?;
|
||||
assert_windows_native_pe_machines(&lib_app, &nupkg_files, &arch, &config.main_exe)?;
|
||||
assert_expected_windows_pe_inventory(&lib_app, &nupkg_files, &arch, &config.main_exe)?;
|
||||
verify_windows_pe_signatures(&signtool, "nupkg lib/app", &lib_app, &nupkg_files)?;
|
||||
|
||||
for (index, delta_nupkg) in delta_nupkgs.iter().enumerate() {
|
||||
let delta_root = root.join(format!("d{index}"));
|
||||
extract_zip_safely(delta_nupkg, &delta_root)?;
|
||||
let delta_files = collect_pe_files(&delta_root)?;
|
||||
let label = format!("delta nupkg {}", file_name_string(delta_nupkg)?);
|
||||
if delta_files.is_empty() {
|
||||
println!(
|
||||
"{label}: contains no whole PE entries, only Velopack diffs; nothing to verify."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
verify_windows_pe_signatures(&signtool, &label, &delta_root, &delta_files)?;
|
||||
}
|
||||
|
||||
let portable_root = root.join("p");
|
||||
extract_zip_safely(&portable_zip, &portable_root)?;
|
||||
let portable_files = collect_pe_files(&portable_root)?;
|
||||
assert_windows_package_file_policy(&portable_root, &arch)?;
|
||||
assert_windows_native_pe_machines(&portable_root, &portable_files, &arch, &config.main_exe)?;
|
||||
assert_expected_windows_pe_inventory(&portable_root, &portable_files, &arch, &config.main_exe)?;
|
||||
verify_windows_pe_signatures(&signtool, "portable zip", &portable_root, &portable_files)?;
|
||||
|
||||
@@ -3212,6 +3504,137 @@ fn build_payload_step() -> Result<()> {
|
||||
print_tree(&payload_root, 6)
|
||||
}
|
||||
|
||||
fn prepare_release_assets_step() -> Result<()> {
|
||||
let channel = require_env("CHANNEL")?;
|
||||
let version = require_env("VERSION")?;
|
||||
let product = match channel.as_str() {
|
||||
"stable" => "Fluxer",
|
||||
"canary" => "Fluxer.Canary",
|
||||
other => bail!("Unsupported desktop release channel {other:?}"),
|
||||
};
|
||||
let expected_asset_names = DESKTOP_RELEASE_ASSET_SUFFIXES
|
||||
.iter()
|
||||
.map(|suffix| format!("{product}-{version}-{suffix}"))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let artifacts = Path::new("artifacts");
|
||||
let release_assets = Path::new("release_assets");
|
||||
remove_dir_if_exists(release_assets)?;
|
||||
fs::create_dir_all(release_assets)?;
|
||||
|
||||
let mut asset_names = BTreeSet::new();
|
||||
for (dir, identity) in payload_artifact_dirs(artifacts, &channel)? {
|
||||
ensure!(
|
||||
identity.desktop_variant == DEFAULT_DESKTOP_VARIANT,
|
||||
"GitHub release asset naming is undefined for desktop variant {:?}",
|
||||
identity.desktop_variant
|
||||
);
|
||||
let platform = match identity.platform.as_str() {
|
||||
"windows" => "win32",
|
||||
"macos" => "darwin",
|
||||
"linux" => "linux",
|
||||
other => bail!("Unsupported desktop release platform {other:?}"),
|
||||
};
|
||||
let candidates = manifest_candidates(&dir, platform, &identity.arch)?;
|
||||
let candidate_kinds = candidates
|
||||
.iter()
|
||||
.map(|(kind, _)| kind.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let expected_kinds = match platform {
|
||||
"win32" => vec!["setup", "portable"],
|
||||
"darwin" => vec!["dmg", "zip"],
|
||||
"linux" => vec!["appimage", "deb", "rpm", "tar_gz"],
|
||||
_ => unreachable!(),
|
||||
};
|
||||
ensure!(
|
||||
candidate_kinds == expected_kinds,
|
||||
"Incomplete shipped artifact set for {}/{:?}: expected {:?}, found {:?}",
|
||||
identity.platform,
|
||||
identity.arch,
|
||||
expected_kinds,
|
||||
candidate_kinds
|
||||
);
|
||||
|
||||
for (_, source) in candidates {
|
||||
let source_name = file_name_string(&source)?;
|
||||
let asset_name = clean_release_asset_name(&source_name)?;
|
||||
ensure!(
|
||||
asset_names.insert(asset_name.clone()),
|
||||
"Duplicate GitHub release asset name {asset_name:?}"
|
||||
);
|
||||
let destination = release_assets.join(&asset_name);
|
||||
let copied = fs::copy(&source, &destination).with_context(|| {
|
||||
format!(
|
||||
"Failed to copy shipped artifact {} to {}",
|
||||
source.display(),
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
ensure!(
|
||||
copied > 0,
|
||||
"Copied empty GitHub release asset {}",
|
||||
destination.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
asset_names == expected_asset_names,
|
||||
"GitHub release asset inventory mismatch: expected {expected_asset_names:?}, found {asset_names:?}"
|
||||
);
|
||||
println!("GitHub release asset tree:");
|
||||
print_tree(release_assets, 2)
|
||||
}
|
||||
|
||||
fn clean_release_asset_name(source_name: &str) -> Result<String> {
|
||||
let name = source_name
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_whitespace() {
|
||||
'.'
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
ensure!(
|
||||
name.bytes()
|
||||
.all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_') }),
|
||||
"Cannot produce a clean GitHub release asset name from {source_name:?}"
|
||||
);
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
async fn upload_release_assets_step() -> Result<()> {
|
||||
let client = s3_client(None).await?;
|
||||
let bucket = require_env("S3_BUCKET")?;
|
||||
let prefix = require_env("DESKTOP_RELEASE_ASSETS_PREFIX")?;
|
||||
let release_assets = Path::new("release_assets");
|
||||
ensure!(
|
||||
release_assets.is_dir(),
|
||||
"GitHub release asset directory is missing"
|
||||
);
|
||||
ensure!(
|
||||
count_files(release_assets)? > 0,
|
||||
"GitHub release asset directory is empty"
|
||||
);
|
||||
upload_directory_to_s3(&client, &bucket, &prefix, release_assets, |_| true).await
|
||||
}
|
||||
|
||||
async fn download_release_assets_step() -> Result<()> {
|
||||
let client = s3_client(None).await?;
|
||||
let bucket = require_env("S3_BUCKET")?;
|
||||
let prefix = require_env("DESKTOP_RELEASE_ASSETS_PREFIX")?;
|
||||
let release_assets = Path::new("release_assets");
|
||||
remove_dir_if_exists(release_assets)?;
|
||||
fs::create_dir_all(release_assets)?;
|
||||
download_s3_prefix(&client, &bucket, &prefix, release_assets).await?;
|
||||
ensure!(
|
||||
count_files(release_assets)? > 0,
|
||||
"No GitHub release assets were downloaded from {prefix}"
|
||||
);
|
||||
println!("Downloaded GitHub release asset tree:");
|
||||
print_tree(release_assets, 2)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ArtifactIdentity {
|
||||
platform: String,
|
||||
|
||||
+75
-229
@@ -1,8 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::common::{
|
||||
CommandSpec, command_succeeds, output_text, remove_file_if_exists, run_command,
|
||||
};
|
||||
use crate::common::{CommandSpec, command_succeeds, output_text, run_command};
|
||||
use crate::desktop::MACOS_UNIVERSAL_ARCH;
|
||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||
use clap::Args;
|
||||
@@ -309,7 +307,9 @@ fn build_desktop_native_addon(addon_root: &Path, addon: &DesktopNativeAddon) ->
|
||||
DesktopNativeSpecialBuild::Webauthn => {
|
||||
copy_webauthn_linux_shared_libraries(&built.out_file, addon_root)?
|
||||
}
|
||||
DesktopNativeSpecialBuild::WinGameCapture => build_win_game_capture_artifacts(addon_root)?,
|
||||
DesktopNativeSpecialBuild::WinGameCapture => {
|
||||
remove_stale_win_game_capture_outputs(addon_root, &built.out_file)?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -365,6 +365,9 @@ fn build_rust_node_addon_for_arch(
|
||||
OsString::from("--manifest-path"),
|
||||
OsString::from("Cargo.toml"),
|
||||
];
|
||||
if addon.special == DesktopNativeSpecialBuild::WinGameCapture {
|
||||
args.push(OsString::from("--no-default-features"));
|
||||
}
|
||||
if !addon.features.is_empty() {
|
||||
args.push(OsString::from("--features"));
|
||||
args.push(OsString::from(addon.features.join(",")));
|
||||
@@ -413,6 +416,7 @@ fn build_rust_node_addon_for_arch(
|
||||
);
|
||||
sign_macos_node_addon(&out_file, platform)?;
|
||||
assert_no_redistributable_runtime_imports(&out_file, platform)?;
|
||||
assert_no_disabled_win_game_capture_capabilities(&out_file, addon, platform)?;
|
||||
Ok(BuiltNodeAddon {
|
||||
out_file,
|
||||
source,
|
||||
@@ -668,235 +672,43 @@ fn should_bundle_linux_library(library_name: &str) -> bool {
|
||||
.any(|prefix| library_name.starts_with(prefix))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct HookLayerArch {
|
||||
arch: &'static str,
|
||||
target: &'static str,
|
||||
tag: &'static str,
|
||||
}
|
||||
|
||||
const WIN_GAME_CAPTURE_ARCHES: &[HookLayerArch] = &[
|
||||
HookLayerArch {
|
||||
arch: "x64",
|
||||
target: "x86_64-pc-windows-msvc",
|
||||
tag: "win32-x64-msvc",
|
||||
},
|
||||
HookLayerArch {
|
||||
arch: "ia32",
|
||||
target: "i686-pc-windows-msvc",
|
||||
tag: "win32-ia32-msvc",
|
||||
},
|
||||
HookLayerArch {
|
||||
arch: "arm64",
|
||||
target: "aarch64-pc-windows-msvc",
|
||||
tag: "win32-arm64-msvc",
|
||||
},
|
||||
];
|
||||
|
||||
fn build_win_game_capture_artifacts(root: &Path) -> Result<()> {
|
||||
let primary_arch = electron_arch();
|
||||
for arch in WIN_GAME_CAPTURE_ARCHES {
|
||||
let required_for_primary_runtime =
|
||||
arch.arch == primary_arch || (primary_arch == "x64" && arch.arch == "ia32");
|
||||
build_win_game_capture_hook(root, arch, required_for_primary_runtime)?;
|
||||
build_win_game_capture_vulkan_layer(root, arch, arch.arch == primary_arch)?;
|
||||
build_win_game_capture_inject_helper(root, arch, required_for_primary_runtime)?;
|
||||
fn remove_stale_win_game_capture_outputs(root: &Path, primary_node: &Path) -> Result<()> {
|
||||
let primary_node_name = primary_node
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Invalid win-game-capture output path: {}",
|
||||
primary_node.display()
|
||||
)
|
||||
})?;
|
||||
let mut stale = fs::read_dir(root)
|
||||
.with_context(|| format!("Failed to read {}", root.display()))?
|
||||
.map(|entry| entry.map(|entry| entry.path()))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
stale.retain(|path| {
|
||||
if !path.is_file() {
|
||||
return false;
|
||||
}
|
||||
let Some(file_name) = path.file_name().and_then(OsStr::to_str) else {
|
||||
return false;
|
||||
};
|
||||
file_name.starts_with("fluxer-game-hook.")
|
||||
|| file_name.starts_with("fluxer-inject-helper.")
|
||||
|| file_name.starts_with("fluxer-vulkan-layer.")
|
||||
|| (file_name.starts_with("win-game-capture.")
|
||||
&& file_name.ends_with(".node")
|
||||
&& file_name != primary_node_name)
|
||||
});
|
||||
stale.sort();
|
||||
for path in stale {
|
||||
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
||||
println!("[win-game-capture] removed stale output {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_win_game_capture_hook(root: &Path, arch: &HookLayerArch, required: bool) -> Result<()> {
|
||||
build_win_game_capture_extra(
|
||||
"game-capture hook",
|
||||
root,
|
||||
&root.join("hook"),
|
||||
"fluxer_game_hook",
|
||||
&format!("fluxer-game-hook.{}.dll", arch.tag),
|
||||
arch,
|
||||
required,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_win_game_capture_vulkan_layer(
|
||||
root: &Path,
|
||||
arch: &HookLayerArch,
|
||||
required: bool,
|
||||
) -> Result<()> {
|
||||
let layer_dll_name = format!("fluxer-vulkan-layer.{}.dll", arch.tag);
|
||||
build_win_game_capture_extra(
|
||||
"Vulkan game-capture layer",
|
||||
root,
|
||||
&root.join("vulkan-layer"),
|
||||
"fluxer_vulkan_layer",
|
||||
&layer_dll_name,
|
||||
arch,
|
||||
required,
|
||||
)?;
|
||||
let manifest_path = root.join(format!("fluxer-vulkan-layer.{}.json", arch.tag));
|
||||
if !root.join(&layer_dll_name).exists() {
|
||||
remove_file_if_exists(&manifest_path)?;
|
||||
println!(
|
||||
"[win-game-capture] no {layer_dll_name}; not emitting {} so packages never ship a manifest pointing at an absent layer",
|
||||
manifest_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
fs::write(&manifest_path, vulkan_layer_manifest(&layer_dll_name))
|
||||
.with_context(|| format!("Failed to write {}", manifest_path.display()))?;
|
||||
println!("[win-game-capture] emitted {}", manifest_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_win_game_capture_inject_helper(
|
||||
root: &Path,
|
||||
arch: &HookLayerArch,
|
||||
required: bool,
|
||||
) -> Result<()> {
|
||||
let helper_root = root.join("inject-helper");
|
||||
ensure_rust_target_or_skip(arch, "inject-helper", required)?;
|
||||
if !try_cargo_build("inject-helper", &helper_root, arch.target) {
|
||||
if required {
|
||||
bail!(
|
||||
"[win-game-capture] required {} inject-helper build failed",
|
||||
arch.arch
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let helper_source = helper_root
|
||||
.join("target")
|
||||
.join(arch.target)
|
||||
.join("release")
|
||||
.join("fluxer-inject-helper.exe");
|
||||
let helper_out = root.join(format!("fluxer-inject-helper.{}.exe", arch.tag));
|
||||
copy_optional_win_game_capture_artifact(
|
||||
&helper_source,
|
||||
&helper_out,
|
||||
&format!("{} inject-helper", arch.arch),
|
||||
required,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_win_game_capture_extra(
|
||||
label: &str,
|
||||
root: &Path,
|
||||
cargo_root: &Path,
|
||||
crate_name: &str,
|
||||
output_name: &str,
|
||||
arch: &HookLayerArch,
|
||||
required: bool,
|
||||
) -> Result<()> {
|
||||
ensure_rust_target_or_skip(arch, label, required)?;
|
||||
if !try_cargo_build(label, cargo_root, arch.target) {
|
||||
if required {
|
||||
bail!(
|
||||
"[win-game-capture] required {} {} build failed",
|
||||
arch.arch,
|
||||
label
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let source = cargo_root
|
||||
.join("target")
|
||||
.join(arch.target)
|
||||
.join("release")
|
||||
.join(cargo_dynamic_library_file_name(crate_name, "win32")?);
|
||||
let output = root.join(output_name);
|
||||
copy_optional_win_game_capture_artifact(
|
||||
&source,
|
||||
&output,
|
||||
&format!("{} {label}", arch.arch),
|
||||
required,
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_rust_target_or_skip(arch: &HookLayerArch, label: &str, required: bool) -> Result<()> {
|
||||
if rust_target_installed(arch.target) {
|
||||
return Ok(());
|
||||
}
|
||||
let message = format!(
|
||||
"[win-game-capture] {} {} {}: rust target {} not installed",
|
||||
if required {
|
||||
"missing required"
|
||||
} else {
|
||||
"skipping"
|
||||
},
|
||||
arch.arch,
|
||||
label,
|
||||
arch.target
|
||||
);
|
||||
if required {
|
||||
bail!(message);
|
||||
}
|
||||
eprintln!("{message}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rust_target_installed(target: &str) -> bool {
|
||||
let rustup = env::var_os("RUSTUP").unwrap_or_else(|| OsString::from("rustup"));
|
||||
match output_text(CommandSpec::new(rustup).args(["target", "list", "--installed"])) {
|
||||
Ok(output) => output.lines().any(|line| line.trim() == target),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"[win-game-capture] could not query rustup for target {target}; assuming present: {error:#}"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_cargo_build(label: &str, cwd: &Path, target: &str) -> bool {
|
||||
println!("[win-game-capture] building {label} target={target}");
|
||||
run_command(
|
||||
CommandSpec::new(resolve_cargo_bin())
|
||||
.args([
|
||||
OsString::from("build"),
|
||||
OsString::from("--release"),
|
||||
OsString::from("--target"),
|
||||
OsString::from(target),
|
||||
OsString::from("--manifest-path"),
|
||||
cwd.join("Cargo.toml").into_os_string(),
|
||||
])
|
||||
.current_dir(cwd),
|
||||
)
|
||||
.map(|_| true)
|
||||
.unwrap_or_else(|error| {
|
||||
eprintln!(
|
||||
"[win-game-capture] skipping {label} for {target}: cargo build failed: {error:#}"
|
||||
);
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
fn copy_optional_win_game_capture_artifact(
|
||||
source: &Path,
|
||||
output: &Path,
|
||||
label: &str,
|
||||
required: bool,
|
||||
) -> Result<()> {
|
||||
if !source.exists() {
|
||||
let message = format!(
|
||||
"[win-game-capture] expected {} after {label} build",
|
||||
source.display()
|
||||
);
|
||||
if required {
|
||||
bail!(message);
|
||||
}
|
||||
eprintln!("{message}; skipping copy");
|
||||
return Ok(());
|
||||
}
|
||||
fs::copy(source, output).with_context(|| {
|
||||
format!(
|
||||
"Failed to copy {} to {}",
|
||||
source.display(),
|
||||
output.display()
|
||||
)
|
||||
})?;
|
||||
println!("[win-game-capture] emitted {}", output.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn vulkan_layer_manifest(layer_dll_name: &str) -> String {
|
||||
format!(
|
||||
"{{\n\
|
||||
@@ -935,6 +747,40 @@ fn assert_no_redistributable_runtime_imports(node_file_path: &Path, platform: &s
|
||||
)
|
||||
}
|
||||
|
||||
const DISABLED_WIN_GAME_CAPTURE_BINARY_MARKERS: &[&[u8]] = &[
|
||||
b"CreateRemoteThread",
|
||||
b"VirtualAllocEx",
|
||||
b"VirtualFreeEx",
|
||||
b"WriteProcessMemory",
|
||||
b"SetWindowsHookExW",
|
||||
b"fluxer-inject-helper.",
|
||||
];
|
||||
|
||||
fn assert_no_disabled_win_game_capture_capabilities(
|
||||
node_file_path: &Path,
|
||||
addon: &DesktopNativeAddon,
|
||||
platform: &str,
|
||||
) -> Result<()> {
|
||||
if platform != "win32" || addon.special != DesktopNativeSpecialBuild::WinGameCapture {
|
||||
return Ok(());
|
||||
}
|
||||
let binary = fs::read(node_file_path)
|
||||
.with_context(|| format!("Failed to read {}", node_file_path.display()))?;
|
||||
let present = DISABLED_WIN_GAME_CAPTURE_BINARY_MARKERS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|marker| binary.windows(marker.len()).any(|window| window == *marker))
|
||||
.map(|marker| String::from_utf8_lossy(marker).into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
ensure!(
|
||||
present.is_empty(),
|
||||
"{} contains disabled game-capture injection capabilities:\n{}",
|
||||
node_file_path.display(),
|
||||
present.join("\n")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_redistributable_runtime_imports(file_path: &Path) -> Vec<String> {
|
||||
read_pe_imports(file_path)
|
||||
.into_iter()
|
||||
|
||||
+389
-197
@@ -5,123 +5,29 @@ use anyhow::{Context, Result, bail, ensure};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const RELEASE_REPOSITORY: &str = "fluxerapp/fluxer";
|
||||
const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare";
|
||||
const DESKTOP_DOWNLOAD_URL: &str = "https://api.fluxer.app/dl/desktop";
|
||||
|
||||
struct DesktopDownload {
|
||||
arch: &'static str,
|
||||
label: &'static str,
|
||||
format: &'static str,
|
||||
}
|
||||
|
||||
struct DesktopPlatform {
|
||||
name: &'static str,
|
||||
slug: &'static str,
|
||||
downloads: &'static [DesktopDownload],
|
||||
}
|
||||
|
||||
const DESKTOP_PLATFORMS: &[DesktopPlatform] = &[
|
||||
DesktopPlatform {
|
||||
name: "Windows",
|
||||
slug: "win32",
|
||||
downloads: &[
|
||||
DesktopDownload {
|
||||
arch: "x64",
|
||||
label: "Setup.exe",
|
||||
format: "setup",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "x64",
|
||||
label: "Portable ZIP",
|
||||
format: "portable",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "arm64",
|
||||
label: "Setup.exe",
|
||||
format: "setup",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "arm64",
|
||||
label: "Portable ZIP",
|
||||
format: "portable",
|
||||
},
|
||||
],
|
||||
},
|
||||
DesktopPlatform {
|
||||
name: "macOS",
|
||||
slug: "darwin",
|
||||
downloads: &[
|
||||
DesktopDownload {
|
||||
arch: "x64",
|
||||
label: "DMG",
|
||||
format: "dmg",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "x64",
|
||||
label: "ZIP",
|
||||
format: "zip",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "arm64",
|
||||
label: "DMG",
|
||||
format: "dmg",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "arm64",
|
||||
label: "ZIP",
|
||||
format: "zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
DesktopPlatform {
|
||||
name: "Linux",
|
||||
slug: "linux",
|
||||
downloads: &[
|
||||
DesktopDownload {
|
||||
arch: "x64",
|
||||
label: "AppImage",
|
||||
format: "appimage",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "x64",
|
||||
label: "DEB",
|
||||
format: "deb",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "x64",
|
||||
label: "RPM",
|
||||
format: "rpm",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "x64",
|
||||
label: "tar.gz",
|
||||
format: "tar_gz",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "arm64",
|
||||
label: "AppImage",
|
||||
format: "appimage",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "arm64",
|
||||
label: "DEB",
|
||||
format: "deb",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "arm64",
|
||||
label: "RPM",
|
||||
format: "rpm",
|
||||
},
|
||||
DesktopDownload {
|
||||
arch: "arm64",
|
||||
label: "tar.gz",
|
||||
format: "tar_gz",
|
||||
},
|
||||
],
|
||||
},
|
||||
pub(crate) const DESKTOP_RELEASE_ASSET_SUFFIXES: &[&str] = &[
|
||||
"linux-aarch64.rpm",
|
||||
"linux-amd64.deb",
|
||||
"linux-arm64.AppImage",
|
||||
"linux-arm64.deb",
|
||||
"linux-arm64.tar.gz",
|
||||
"linux-x64.tar.gz",
|
||||
"linux-x86_64.AppImage",
|
||||
"linux-x86_64.rpm",
|
||||
"mac-universal.dmg",
|
||||
"mac-universal.zip",
|
||||
"portable-win-arm64.zip",
|
||||
"portable-win-x64.zip",
|
||||
"win-arm64.exe",
|
||||
"win-x64.exe",
|
||||
];
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
@@ -148,6 +54,8 @@ struct PublishArgs {
|
||||
previous_sha: Option<String>,
|
||||
#[arg(long)]
|
||||
prerelease: bool,
|
||||
#[arg(long)]
|
||||
asset_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -175,7 +83,24 @@ struct ReleaseDetail {
|
||||
body: Option<String>,
|
||||
draft: bool,
|
||||
prerelease: bool,
|
||||
assets: Vec<Value>,
|
||||
assets: Vec<PublishedReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PublishedReleaseAsset {
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
size: u64,
|
||||
digest: Option<String>,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LocalReleaseAsset {
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
size: u64,
|
||||
digest: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -207,12 +132,7 @@ fn publish(args: PublishArgs) -> Result<()> {
|
||||
let summaries = release_summaries()?;
|
||||
let qualified = qualified_releases(&summaries, &args.component)?;
|
||||
let existing_release = qualified.iter().find(|release| release.tag == tag);
|
||||
if let Some(existing) = summaries.iter().find(|release| release.tag_name == tag) {
|
||||
ensure!(
|
||||
!existing.is_draft && existing.published_at.is_some(),
|
||||
"Release {tag} exists but is not a published, non-draft component release"
|
||||
);
|
||||
}
|
||||
let existing_summary = summaries.iter().find(|release| release.tag_name == tag);
|
||||
|
||||
if existing_release.is_none()
|
||||
&& let Some(newer) = qualified
|
||||
@@ -271,35 +191,71 @@ fn publish(args: PublishArgs) -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let body = release_body(
|
||||
let body = release_body(&previous_sha, &source_sha);
|
||||
let assets = local_release_assets(
|
||||
&args.component,
|
||||
&args.build_version,
|
||||
&previous_sha,
|
||||
&source_sha,
|
||||
);
|
||||
if summaries.iter().any(|release| release.tag_name == tag) {
|
||||
verify_existing_release(&tag, &title, &body, &source_sha, args.prerelease)?;
|
||||
args.asset_dir.as_deref(),
|
||||
)?;
|
||||
if existing_summary.is_some_and(|release| !release.is_draft) {
|
||||
ensure!(
|
||||
existing_summary
|
||||
.and_then(|release| release.published_at.as_ref())
|
||||
.is_some(),
|
||||
"Published release {tag} is missing its publication timestamp"
|
||||
);
|
||||
verify_release(
|
||||
&tag,
|
||||
&title,
|
||||
&body,
|
||||
&source_sha,
|
||||
args.prerelease,
|
||||
false,
|
||||
&assets,
|
||||
)?;
|
||||
println!("Release {tag} already exists with the expected state.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ensure!(
|
||||
!tag_exists(&tag)?,
|
||||
"Refusing to publish {tag}: the tag already exists without a matching GitHub Release"
|
||||
);
|
||||
|
||||
let mut command = CommandSpec::new("gh")
|
||||
.args(["release", "create", &tag])
|
||||
.args(["--repo", RELEASE_REPOSITORY])
|
||||
.args(["--title", &title])
|
||||
.args(["--notes", &body])
|
||||
.args(["--target", &source_sha])
|
||||
.args(["--latest=false"]);
|
||||
if args.prerelease {
|
||||
command = command.arg("--prerelease");
|
||||
if let Some(existing) = existing_summary {
|
||||
ensure!(
|
||||
existing.published_at.is_none(),
|
||||
"Draft release {tag} unexpectedly has a publication timestamp"
|
||||
);
|
||||
} else {
|
||||
ensure!(
|
||||
!tag_exists(&tag)?,
|
||||
"Refusing to publish {tag}: the tag already exists without a matching GitHub Release"
|
||||
);
|
||||
create_draft_release(&tag, &title, &body, &source_sha, args.prerelease)?;
|
||||
}
|
||||
run_command(command)?;
|
||||
verify_existing_release(&tag, &title, &body, &source_sha, args.prerelease)
|
||||
upload_draft_release_assets(&tag, &title, &body, &source_sha, args.prerelease, &assets)?;
|
||||
verify_release(
|
||||
&tag,
|
||||
&title,
|
||||
&body,
|
||||
&source_sha,
|
||||
args.prerelease,
|
||||
true,
|
||||
&assets,
|
||||
)?;
|
||||
run_command(
|
||||
CommandSpec::new("gh")
|
||||
.args(["release", "edit", &tag])
|
||||
.args(["--repo", RELEASE_REPOSITORY])
|
||||
.arg("--draft=false")
|
||||
.arg(format!("--prerelease={}", args.prerelease))
|
||||
.arg("--latest=false"),
|
||||
)?;
|
||||
verify_release(
|
||||
&tag,
|
||||
&title,
|
||||
&body,
|
||||
&source_sha,
|
||||
args.prerelease,
|
||||
false,
|
||||
&assets,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_component(component: &str) -> Result<()> {
|
||||
@@ -425,20 +381,226 @@ fn tag_exists(tag: &str) -> Result<bool> {
|
||||
Ok(refs.iter().any(|git_ref| git_ref.name == expected))
|
||||
}
|
||||
|
||||
fn verify_existing_release(
|
||||
fn local_release_assets(
|
||||
component: &str,
|
||||
version: &str,
|
||||
asset_dir: Option<&Path>,
|
||||
) -> Result<Vec<LocalReleaseAsset>> {
|
||||
let Some(channel) = desktop_channel(component) else {
|
||||
ensure!(
|
||||
asset_dir.is_none(),
|
||||
"Release assets are supported only for desktop components"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let asset_dir = asset_dir.context("Desktop releases require --asset-dir")?;
|
||||
ensure!(
|
||||
asset_dir.is_dir(),
|
||||
"Desktop release asset directory does not exist: {}",
|
||||
asset_dir.display()
|
||||
);
|
||||
let product = match channel {
|
||||
"stable" => "Fluxer",
|
||||
"canary" => "Fluxer.Canary",
|
||||
other => bail!("Unsupported desktop release channel {other:?}"),
|
||||
};
|
||||
let prefix = format!("{product}-{version}-");
|
||||
let mut entries = fs::read_dir(asset_dir)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to read desktop release assets in {}",
|
||||
asset_dir.display()
|
||||
)
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
entries.sort_by_key(std::fs::DirEntry::file_name);
|
||||
ensure!(
|
||||
!entries.is_empty(),
|
||||
"Desktop release asset directory is empty: {}",
|
||||
asset_dir.display()
|
||||
);
|
||||
|
||||
let mut assets = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&path)
|
||||
.with_context(|| format!("Failed to inspect release asset {}", path.display()))?;
|
||||
ensure!(
|
||||
metadata.file_type().is_file(),
|
||||
"Release asset must be a regular file: {}",
|
||||
path.display()
|
||||
);
|
||||
ensure!(
|
||||
metadata.len() > 0,
|
||||
"Release asset is empty: {}",
|
||||
path.display()
|
||||
);
|
||||
let name = entry
|
||||
.file_name()
|
||||
.into_string()
|
||||
.map_err(|name| anyhow::anyhow!("Release asset name is not valid UTF-8: {name:?}"))?;
|
||||
ensure!(
|
||||
name.bytes()
|
||||
.all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_') }),
|
||||
"Release asset name is not clean and URL-safe: {name:?}"
|
||||
);
|
||||
let suffix = name
|
||||
.strip_prefix(&prefix)
|
||||
.with_context(|| format!("Release asset {name:?} must start with {prefix:?}"))?;
|
||||
ensure!(
|
||||
DESKTOP_RELEASE_ASSET_SUFFIXES.contains(&suffix),
|
||||
"Release asset {name:?} is not a supported shipped desktop artifact"
|
||||
);
|
||||
assets.push(LocalReleaseAsset {
|
||||
digest: sha256_file(&path)?,
|
||||
path,
|
||||
name,
|
||||
size: metadata.len(),
|
||||
});
|
||||
}
|
||||
let expected_names = DESKTOP_RELEASE_ASSET_SUFFIXES
|
||||
.iter()
|
||||
.map(|suffix| format!("{prefix}{suffix}"))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let actual_names = assets
|
||||
.iter()
|
||||
.map(|asset| asset.name.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
ensure!(
|
||||
actual_names == expected_names,
|
||||
"Desktop release asset inventory mismatch: expected {expected_names:?}, found {actual_names:?}"
|
||||
);
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String> {
|
||||
let mut file = File::open(path)
|
||||
.with_context(|| format!("Failed to open release asset {}", 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 release asset {}", path.display()))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
fn create_draft_release(
|
||||
tag: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
source_sha: &str,
|
||||
prerelease: bool,
|
||||
) -> Result<()> {
|
||||
let mut command = CommandSpec::new("gh")
|
||||
.args(["release", "create", tag])
|
||||
.args(["--repo", RELEASE_REPOSITORY])
|
||||
.args(["--title", title])
|
||||
.args(["--notes", body])
|
||||
.args(["--target", source_sha])
|
||||
.args(["--latest=false", "--draft"]);
|
||||
if prerelease {
|
||||
command = command.arg("--prerelease");
|
||||
}
|
||||
run_command(command)
|
||||
}
|
||||
|
||||
fn release_detail(tag: &str) -> Result<ReleaseDetail> {
|
||||
let output = output_text(
|
||||
CommandSpec::new("gh")
|
||||
.arg("api")
|
||||
.arg(format!("repos/{RELEASE_REPOSITORY}/releases/tags/{tag}")),
|
||||
)?;
|
||||
let release: ReleaseDetail =
|
||||
serde_json::from_str(&output).with_context(|| format!("Failed to parse release {tag}"))?;
|
||||
serde_json::from_str(&output).with_context(|| format!("Failed to parse release {tag}"))
|
||||
}
|
||||
|
||||
fn upload_draft_release_assets(
|
||||
tag: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
source_sha: &str,
|
||||
prerelease: bool,
|
||||
expected_assets: &[LocalReleaseAsset],
|
||||
) -> Result<()> {
|
||||
let release = release_detail(tag)?;
|
||||
verify_release_metadata(tag, &release, title, body, source_sha, prerelease, true)?;
|
||||
let expected_by_name = expected_assets
|
||||
.iter()
|
||||
.map(|asset| (asset.name.as_str(), asset))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut published_by_name = BTreeMap::new();
|
||||
for asset in &release.assets {
|
||||
ensure!(
|
||||
expected_by_name.contains_key(asset.name.as_str()),
|
||||
"Draft release {tag} contains unexpected asset {:?}",
|
||||
asset.name
|
||||
);
|
||||
ensure!(
|
||||
published_by_name
|
||||
.insert(asset.name.as_str(), asset)
|
||||
.is_none(),
|
||||
"Draft release {tag} contains duplicate asset name {:?}",
|
||||
asset.name
|
||||
);
|
||||
}
|
||||
let pending = expected_assets
|
||||
.iter()
|
||||
.filter(|expected| {
|
||||
published_by_name
|
||||
.get(expected.name.as_str())
|
||||
.is_none_or(|published| !release_asset_matches(published, expected))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if pending.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut command = CommandSpec::new("gh")
|
||||
.args(["release", "upload", tag])
|
||||
.args(["--repo", RELEASE_REPOSITORY])
|
||||
.arg("--clobber");
|
||||
for asset in pending {
|
||||
command = command.arg(&asset.path);
|
||||
}
|
||||
run_command(command)
|
||||
}
|
||||
|
||||
fn verify_release(
|
||||
tag: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
source_sha: &str,
|
||||
prerelease: bool,
|
||||
draft: bool,
|
||||
expected_assets: &[LocalReleaseAsset],
|
||||
) -> Result<()> {
|
||||
let release = release_detail(tag)?;
|
||||
verify_release_metadata(tag, &release, title, body, source_sha, prerelease, draft)?;
|
||||
verify_release_assets(tag, &release.assets, expected_assets)?;
|
||||
if draft {
|
||||
return Ok(());
|
||||
}
|
||||
let tag_sha = resolve_commit_sha(tag)?;
|
||||
ensure!(
|
||||
tag_sha == source_sha,
|
||||
"Release tag {tag} targets {tag_sha}, expected {source_sha}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_release_metadata(
|
||||
tag: &str,
|
||||
release: &ReleaseDetail,
|
||||
title: &str,
|
||||
body: &str,
|
||||
source_sha: &str,
|
||||
prerelease: bool,
|
||||
draft: bool,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
release.tag_name == tag,
|
||||
"Release {tag} has a mismatched tag"
|
||||
@@ -451,26 +613,93 @@ fn verify_existing_release(
|
||||
release.body.as_deref().unwrap_or_default() == body,
|
||||
"Release {tag} has a mismatched body"
|
||||
);
|
||||
ensure!(!release.draft, "Release {tag} is unexpectedly a draft");
|
||||
ensure!(
|
||||
release.draft == draft,
|
||||
"Release {tag} has draft state {}, expected {draft}",
|
||||
release.draft
|
||||
);
|
||||
ensure!(
|
||||
release.prerelease == prerelease,
|
||||
"Release {tag} has a mismatched prerelease state"
|
||||
);
|
||||
ensure!(
|
||||
release.assets.is_empty(),
|
||||
"Release {tag} has assets; asset-free publication is required"
|
||||
);
|
||||
|
||||
let tag_sha = resolve_commit_sha(tag)?;
|
||||
ensure!(
|
||||
tag_sha == source_sha,
|
||||
"Release tag {tag} targets {tag_sha}, expected {source_sha}"
|
||||
);
|
||||
let target_sha = resolve_commit_sha(&release.target_commitish)?;
|
||||
ensure!(
|
||||
target_sha == source_sha,
|
||||
"Release {tag} target resolves to {target_sha}, expected {source_sha}"
|
||||
);
|
||||
if draft && tag_exists(tag)? {
|
||||
let tag_sha = resolve_commit_sha(tag)?;
|
||||
ensure!(
|
||||
tag_sha == source_sha,
|
||||
"Draft release tag {tag} targets {tag_sha}, expected {source_sha}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release_asset_matches(published: &PublishedReleaseAsset, expected: &LocalReleaseAsset) -> bool {
|
||||
let expected_digest = format!("sha256:{}", expected.digest);
|
||||
published.label.as_deref().unwrap_or_default().is_empty()
|
||||
&& published.state == "uploaded"
|
||||
&& published.size == expected.size
|
||||
&& published.digest.as_deref() == Some(expected_digest.as_str())
|
||||
}
|
||||
|
||||
fn verify_release_assets(
|
||||
tag: &str,
|
||||
published_assets: &[PublishedReleaseAsset],
|
||||
expected_assets: &[LocalReleaseAsset],
|
||||
) -> Result<()> {
|
||||
let mut published_by_name = BTreeMap::new();
|
||||
for asset in published_assets {
|
||||
ensure!(
|
||||
published_by_name
|
||||
.insert(asset.name.as_str(), asset)
|
||||
.is_none(),
|
||||
"Release {tag} contains duplicate asset name {:?}",
|
||||
asset.name
|
||||
);
|
||||
}
|
||||
let expected_names = expected_assets
|
||||
.iter()
|
||||
.map(|asset| asset.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let published_names = published_by_name.keys().copied().collect::<Vec<_>>();
|
||||
ensure!(
|
||||
published_names == expected_names,
|
||||
"Release {tag} asset inventory mismatch: expected {expected_names:?}, published {published_names:?}"
|
||||
);
|
||||
for expected in expected_assets {
|
||||
let published = published_by_name
|
||||
.get(expected.name.as_str())
|
||||
.with_context(|| format!("Release {tag} is missing asset {:?}", expected.name))?;
|
||||
ensure!(
|
||||
published.label.as_deref().unwrap_or_default().is_empty(),
|
||||
"Release {tag} asset {:?} has unexpected label {:?}",
|
||||
expected.name,
|
||||
published.label
|
||||
);
|
||||
ensure!(
|
||||
published.state == "uploaded",
|
||||
"Release {tag} asset {:?} is in unexpected state {:?}",
|
||||
expected.name,
|
||||
published.state
|
||||
);
|
||||
ensure!(
|
||||
published.size == expected.size,
|
||||
"Release {tag} asset {:?} has size {}, expected {}",
|
||||
expected.name,
|
||||
published.size,
|
||||
expected.size
|
||||
);
|
||||
let expected_digest = format!("sha256:{}", expected.digest);
|
||||
ensure!(
|
||||
published.digest.as_deref() == Some(expected_digest.as_str()),
|
||||
"Release {tag} asset {:?} has digest {:?}, expected {expected_digest}",
|
||||
expected.name,
|
||||
published.digest
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -482,51 +711,14 @@ fn release_title(component: &str, version: &str) -> String {
|
||||
format!("{component} {version}")
|
||||
}
|
||||
|
||||
fn release_body(component: &str, version: &str, previous_sha: &str, source_sha: &str) -> String {
|
||||
let changes = format!(
|
||||
fn release_body(previous_sha: &str, source_sha: &str) -> String {
|
||||
format!(
|
||||
"Changes: [`{}..{}`]({RELEASE_COMPARE_URL}/{previous_sha}..{source_sha})",
|
||||
&previous_sha[..7],
|
||||
&source_sha[..7]
|
||||
);
|
||||
match desktop_channel(component) {
|
||||
Some(channel) => format!(
|
||||
"{changes}\n\n{}",
|
||||
desktop_download_sections(channel, version)
|
||||
),
|
||||
None => changes,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn desktop_channel(component: &str) -> Option<&str> {
|
||||
component.strip_prefix("fluxer-desktop-")
|
||||
}
|
||||
|
||||
fn desktop_download_sections(channel: &str, version: &str) -> String {
|
||||
DESKTOP_PLATFORMS
|
||||
.iter()
|
||||
.map(|platform| desktop_download_section(channel, version, platform))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn desktop_download_section(channel: &str, version: &str, platform: &DesktopPlatform) -> String {
|
||||
let rows = platform
|
||||
.downloads
|
||||
.iter()
|
||||
.map(|download| {
|
||||
format!(
|
||||
"| {arch} | {label} | {DESKTOP_DOWNLOAD_URL}/{channel}/{slug}/{arch}/{version}/{format} |",
|
||||
arch = download.arch,
|
||||
label = download.label,
|
||||
slug = platform.slug,
|
||||
format = download.format,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!(
|
||||
"## {name} (`{slug}`)\n\n| Arch | Format | URL |\n|---|---|---|\n{rows}",
|
||||
name = platform.name,
|
||||
slug = platform.slug,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user