diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf0ef9ba..475360c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ env: RUST_BACKTRACE: 1 EMULEBB_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 EMULEBB_ANDROID_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 - EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d - EMULEBB_TOOLING_REF: 856e0499d12e281697618cc81a0b09805e96f59e + EMULEBB_BUILD_TESTS_REF: d2a48d8920ef4225e86fbe918045240ab2452e67 + EMULEBB_TOOLING_REF: 3b045ab4dea7554dceb13d3022a21727046d06e5 # Pin the native NAT sources linked by the sys crates. MINIUPNP_REF: 4e7a10948004ca2085ac8103e14fc77edb898b4c PCPNATPMP_REF: a6e787f837e507336a8774522c33b54264ef4a4b @@ -440,7 +440,7 @@ jobs: with: persist-credentials: false repository: emulebb/emulebb-build-tests - ref: fd4c6d21e079fab2b84704294c7c7000b893a81e + ref: d2a48d8920ef4225e86fbe918045240ab2452e67 path: repos/emulebb-build-tests - name: Checkout pinned Rust OpenAPI contract @@ -448,7 +448,7 @@ jobs: with: persist-credentials: false repository: emulebb/emulebb-tooling - ref: 630d1a3492ae65acad0a8dfd1575d671ff7b492c + ref: 3b045ab4dea7554dceb13d3022a21727046d06e5 path: repos/emulebb-tooling - name: Download tested Linux daemon diff --git a/.github/workflows/optional-platforms.yml b/.github/workflows/optional-platforms.yml index 63d646d3..a6fd5179 100644 --- a/.github/workflows/optional-platforms.yml +++ b/.github/workflows/optional-platforms.yml @@ -19,8 +19,8 @@ env: RUST_BACKTRACE: 1 EMULEBB_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 EMULEBB_ANDROID_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 - EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d - EMULEBB_TOOLING_REF: 856e0499d12e281697618cc81a0b09805e96f59e + EMULEBB_BUILD_TESTS_REF: d2a48d8920ef4225e86fbe918045240ab2452e67 + EMULEBB_TOOLING_REF: 3b045ab4dea7554dceb13d3022a21727046d06e5 # Pin the native NAT sources linked by the sys crates. MINIUPNP_REF: 4e7a10948004ca2085ac8103e14fc77edb898b4c PCPNATPMP_REF: a6e787f837e507336a8774522c33b54264ef4a4b diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19d37662..7883b184 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,9 +51,9 @@ permissions: env: RELEASE_PLATFORM_PROFILE: ${{ inputs.platform_profile || 'full' }} RELEASE_CHANNEL: ${{ inputs.channel || (startsWith(github.ref, 'refs/tags/rust-v') && 'beta') || 'candidate' }} - EMULEBB_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 - EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d - EMULEBB_TOOLING_REF: c9aa7c7b5c0a298a6ba3b0485f29d9f4c86b5d05 + EMULEBB_BUILD_REF: a508474e81f641e2a1ba1a6f7c8535e8ab358ddf + EMULEBB_BUILD_TESTS_REF: 86e32b1d547ca68c294350ad45d70d79b987d2bb + EMULEBB_TOOLING_REF: d406cfd8e6f3e3b6f04e377ac469c413293f81d9 MINIUPNP_REF: 4e7a10948004ca2085ac8103e14fc77edb898b4c PCPNATPMP_REF: a6e787f837e507336a8774522c33b54264ef4a4b @@ -219,7 +219,13 @@ jobs: - name: Install Linux build dependencies if: matrix.os == 'linux' - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y dpkg-dev libfontconfig1-dev + timeout-minutes: 6 + working-directory: .ci/emulebb-build + run: >- + python -B -m emule_workspace provision-ubuntu-dependencies-ci + --profile rust-native-release --deadline-seconds 300 + --network-timeout-seconds 20 --attempts 3 + --report "$RUNNER_TEMP/emulebb-rust-out/reports/native-linux-provisioning.json" - name: Install verified AppImage builder if: matrix.os == 'linux' shell: bash @@ -263,7 +269,9 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: emulebb-rust-build-logs-${{ matrix.os }}-${{ matrix.arch }} - path: ${{ runner.temp }}/emulebb-rust-out/logs/builds/**/*.log + path: | + ${{ runner.temp }}/emulebb-rust-out/logs/builds/**/*.log + ${{ runner.temp }}/emulebb-rust-out/reports/native-linux-provisioning.json if-no-files-found: warn retention-days: 30 - name: Retain package and provenance @@ -324,7 +332,10 @@ jobs: name: ${{ env.NATIVE_PAYLOAD_PREFIX }}-${{ matrix.slug }} path: ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }} - name: Prove packaged lifecycle, local transfer, and embedded WebUI - run: python .ci/emulebb-build-tests/scripts/smoke-rust-release-package.py --release-dir "${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}" --release-version ${{ env.RELEASE_VERSION }} --rust-repo .ci/candidate --expected-source-sha "${{ needs.prepare.outputs.source_sha }}" --report-path "${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json" --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} ${{ matrix.os == 'windows' && matrix.arch == 'x64' && '--render-webui' || '' }} + env: + EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} + EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out + run: python .ci/emulebb-build-tests/scripts/smoke-rust-release-package.py --release-dir "${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}" --release-version ${{ env.RELEASE_VERSION }} --rust-repo .ci/candidate --expected-source-sha "${{ needs.prepare.outputs.source_sha }}" --expected-channel "${{ env.RELEASE_CHANNEL }}" --report-path "${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json" --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} ${{ matrix.os != 'macos' && '--verify-profile-ownership' || '' }} ${{ matrix.os == 'windows' && matrix.arch == 'x64' && '--render-webui' || '' }} - name: Retain package lifecycle evidence uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -468,10 +479,15 @@ jobs: uses: docker/setup-docker-action@7ec28a010822b4626af4874f9448a4b05ac6f3e5 # v5.6.0 with: version: v29.6.1 + - name: Prepare inherited host Cargo target for the source-cache fence + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target + run: python -c "from pathlib import Path; import os; Path(os.environ['CARGO_TARGET_DIR']).mkdir(parents=True, exist_ok=True)" - name: Build, test and smoke static musl with the local recipe working-directory: .ci/emulebb-build env: EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out + CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target run: >- python -B -m emule_workspace package-emulebb-rust-linux-ci --libc musl --architecture ${{ matrix.architecture }} --release-version "$RELEASE_VERSION" diff --git a/Cargo.lock b/Cargo.lock index 002e7b49..19063ab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -620,6 +620,7 @@ dependencies = [ "emulebb-metadata", "emulebb-rest", "emulebb-settings", + "find-msvc-tools", "futures-util", "hex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index b8a9a38d..3cfd4730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ data-encoding = "2" embed-manifest = "1.5" encoding_rs = "0.8" file-id = "0.2" +find-msvc-tools = "0.1.15" flate2 = "1" fs4 = "1.1" futures-util = "0.3" diff --git a/crates/emulebb-core/src/build_identity.rs b/crates/emulebb-core/src/build_identity.rs new file mode 100644 index 00000000..76182f88 --- /dev/null +++ b/crates/emulebb-core/src/build_identity.rs @@ -0,0 +1,51 @@ +//! Compile-time distribution identity shared by the daemon and REST views. + +use std::sync::LazyLock; + +pub const BUILD_VERSION: &str = match option_env!("EMULEBB_RELEASE_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; +pub const BUILD_CHANNEL: &str = match option_env!("EMULEBB_RELEASE_CHANNEL") { + Some(channel) => channel, + None => "development", +}; +pub const BUILD_SOURCE_REVISION: &str = match option_env!("EMULEBB_SOURCE_REVISION") { + Some(revision) => revision, + None => "unknown", +}; +pub const BUILD_ARCHITECTURE: &str = if cfg!(target_arch = "aarch64") { + "arm64" +} else if cfg!(target_arch = "x86_64") { + "x64" +} else { + std::env::consts::ARCH +}; +pub const BUILD_FLAVOR: &str = if cfg!(debug_assertions) { + "debug" +} else { + "release" +}; + +/// Describe the same immutable identity in both daemon CLI flavors. +pub fn build_version_description() -> &'static str { + static DESCRIPTION: LazyLock = LazyLock::new(|| { + format!("{BUILD_VERSION} ({BUILD_CHANNEL}; {BUILD_ARCHITECTURE}; {BUILD_SOURCE_REVISION})") + }); + DESCRIPTION.as_str() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_description_identifies_the_distribution_without_a_clock() { + let description = build_version_description(); + assert!(description.starts_with(BUILD_VERSION)); + for identity in [BUILD_CHANNEL, BUILD_ARCHITECTURE, BUILD_SOURCE_REVISION] { + assert!(description.contains(identity)); + } + assert_eq!(description, build_version_description()); + } +} diff --git a/crates/emulebb-core/src/lib.rs b/crates/emulebb-core/src/lib.rs index 0afa1b32..068f8632 100644 --- a/crates/emulebb-core/src/lib.rs +++ b/crates/emulebb-core/src/lib.rs @@ -104,6 +104,7 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; mod app_api; +mod build_identity; mod callback_tracker; mod categories; mod category_api; @@ -285,6 +286,10 @@ use shared_directories::{ mod network_status_defaults; mod rest_model; mod rest_model_serde; +pub use build_identity::{ + BUILD_ARCHITECTURE, BUILD_CHANNEL, BUILD_FLAVOR, BUILD_SOURCE_REVISION, BUILD_VERSION, + build_version_description, +}; use download_task_supervisor::DownloadTaskSupervisor; pub use emulebb_ed2k::NatStatusSnapshot; pub use emulebb_ed2k::normalize_ed2k_file_name; diff --git a/crates/emulebb-daemon/Cargo.toml b/crates/emulebb-daemon/Cargo.toml index 265c394f..52cab6e9 100644 --- a/crates/emulebb-daemon/Cargo.toml +++ b/crates/emulebb-daemon/Cargo.toml @@ -64,5 +64,8 @@ uuid.workspace = true [build-dependencies] embed-manifest.workspace = true +[target.'cfg(windows)'.build-dependencies] +find-msvc-tools.workspace = true + [dev-dependencies] tempfile.workspace = true diff --git a/crates/emulebb-daemon/build.rs b/crates/emulebb-daemon/build.rs index 4f360d78..717f2bfe 100644 --- a/crates/emulebb-daemon/build.rs +++ b/crates/emulebb-daemon/build.rs @@ -1,4 +1,4 @@ -//! Build script for the `emulebb-rust` daemon binary. +//! Windows manifest and executable resources for the daemon targets. //! //! On Windows it embeds an application manifest that declares //! `true`. Together with the OS-level @@ -13,17 +13,13 @@ //! (shared-directory trees, incoming downloads, category paths) get long-path //! handling; config/logs/the SQLite DB/internal piece-store dirs stay short. //! -//! On every non-Windows target this build script is a no-op (the manifest is a -//! Windows-only concept), so it never affects Linux/macOS builds. +//! Non-Windows targets do not embed Windows resources. fn main() { - // Re-run only when this script itself changes; the embedded manifest has no - // external inputs. println!("cargo:rerun-if-changed=build.rs"); - // `CARGO_CFG_WINDOWS` is set by cargo when the *target* is Windows, which is - // the correct gate for a Windows-only manifest (host-independent, so a cross - // build still embeds it for a Windows target and skips it otherwise). + // The existing manifest integration runs on Windows hosts. Version and + // icon resources use the Windows SDK for the supported MSVC targets. #[cfg(windows)] { use embed_manifest::manifest::Setting; @@ -32,6 +28,124 @@ fn main() { if std::env::var_os("CARGO_CFG_WINDOWS").is_some() { embed_manifest(new_manifest("EmulebbRust").long_path_aware(Setting::Enabled)) .expect("failed to embed longPathAware Windows manifest"); + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + embed_windows_resources(); + } } } } + +#[cfg(windows)] +fn embed_windows_resources() { + use std::{env, fs, path::PathBuf, process::Command}; + + const ICON: &str = "resources/windows/application.ico"; + println!("cargo:rerun-if-changed={ICON}"); + println!("cargo:rerun-if-changed=resources/windows/version.rc.in"); + println!("cargo:rerun-if-env-changed=EMULEBB_RELEASE_VERSION"); + println!("cargo:rerun-if-env-changed=EMULEBB_RELEASE_CHANNEL"); + println!("cargo:rerun-if-env-changed=EMULEBB_SOURCE_REVISION"); + println!("cargo:rerun-if-env-changed=WindowsSdkDir"); + println!("cargo:rerun-if-env-changed=WindowsSDKVersion"); + + let architecture = env::var("CARGO_CFG_TARGET_ARCH").expect("Cargo target architecture"); + let sdk = find_msvc_tools::find_windows_sdk(&architecture) + .expect("install the Windows SDK to compile daemon executable resources"); + let compiler = sdk + .path() + .map(|directory| directory.join("rc.exe")) + .find(|path| path.is_absolute() && path.is_file()) + .expect("install the Windows SDK resource compiler (rc.exe) for the build host"); + + // Match BUILD_VERSION and keep the SemVer string safe in the RC literal. + let version = match env::var("EMULEBB_RELEASE_VERSION") { + Ok(version) => version, + Err(env::VarError::NotPresent) => env!("CARGO_PKG_VERSION").to_owned(), + Err(error) => panic!("invalid EMULEBB_RELEASE_VERSION: {error}"), + }; + assert!( + !version.is_empty() + && version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+')), + "the executable resource version must use ASCII SemVer characters" + ); + let [major, minor, patch] = [ + env!("CARGO_PKG_VERSION_MAJOR"), + env!("CARGO_PKG_VERSION_MINOR"), + env!("CARGO_PKG_VERSION_PATCH"), + ] + .map(|component| { + component + .parse::() + .expect("Cargo version component exceeds the Windows version-resource range") + }); + let fixed_version = format!("{major},{minor},{patch},0"); + let channel = env::var("EMULEBB_RELEASE_CHANNEL").unwrap_or_else(|_| "development".to_owned()); + assert!( + matches!( + channel.as_str(), + "development" | "candidate" | "beta" | "nightly" + ), + "the executable resource channel must use a supported build channel" + ); + let source_revision = + env::var("EMULEBB_SOURCE_REVISION").unwrap_or_else(|_| "unknown".to_owned()); + assert!( + source_revision == "unknown" + || (source_revision.len() == 40 + && source_revision + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())), + "the executable resource source must be unknown or a full lowercase commit" + ); + let architecture_label = match architecture.as_str() { + "x86_64" => "x64", + "aarch64" => "arm64", + other => other, + }; + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo build output directory")); + // Give RC a relative ASCII icon name regardless of the checkout path. + fs::copy(ICON, out_dir.join("application.ico")).expect("copy approved application icon"); + + let mut binaries = vec![("emulebb-rust", "eMuleBB", "eMuleBB Rust daemon")]; + if env::var_os("CARGO_FEATURE_PACKET_DIAGNOSTICS").is_some() { + binaries.push(( + "emulebb-rust-diagnostics", + "eMuleBB Diagnostics", + "eMuleBB Rust diagnostics daemon", + )); + } + for (binary, product, description) in binaries { + let script = format!("{binary}.rc"); + let resource = format!("{binary}.res"); + let contents = format!( + include_str!("resources/windows/version.rc.in"), + fixed_version = fixed_version, + version = version, + binary = binary, + product = product, + description = description, + channel = channel, + architecture = architecture_label, + source_revision = source_revision, + ); + fs::write(out_dir.join(&script), contents).expect("write Windows resource script"); + let output = Command::new(&compiler) + .current_dir(&out_dir) + .args(["/nologo", "/fo", &resource, &script]) + .output() + .expect("run Windows SDK resource compiler"); + assert!( + output.status.success(), + "Windows resource compilation failed for {binary} ({}):\n{}{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + println!( + "cargo:rustc-link-arg-bin={binary}={}", + out_dir.join(resource).display() + ); + } +} diff --git a/crates/emulebb-daemon/resources/windows/application.ico b/crates/emulebb-daemon/resources/windows/application.ico new file mode 100644 index 00000000..4eae9532 Binary files /dev/null and b/crates/emulebb-daemon/resources/windows/application.ico differ diff --git a/crates/emulebb-daemon/resources/windows/version.rc.in b/crates/emulebb-daemon/resources/windows/version.rc.in new file mode 100644 index 00000000..b67b7200 --- /dev/null +++ b/crates/emulebb-daemon/resources/windows/version.rc.in @@ -0,0 +1,32 @@ +LANGUAGE 0x09, 0x01 + +1 ICON "application.ico" + +1 VERSIONINFO +FILEVERSION {fixed_version} +PRODUCTVERSION {fixed_version} +FILEFLAGSMASK 0x3fL +FILEFLAGS 0 +FILEOS 0x00040004L +FILETYPE 0x00000001L +FILESUBTYPE 0 +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "eMuleBB Project\0" + VALUE "Comments", "{channel}; {architecture}; {source_revision}\0" + VALUE "FileDescription", "{description}\0" + VALUE "FileVersion", "{version}\0" + VALUE "LegalCopyright", "Copyright (C) eMuleBB contributors\0" + VALUE "OriginalFilename", "{binary}.exe\0" + VALUE "ProductName", "{product}\0" + VALUE "ProductVersion", "{version}\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 1200 + END +END diff --git a/crates/emulebb-daemon/src/bin/emulebb-rust.rs b/crates/emulebb-daemon/src/bin/emulebb-rust.rs index d2ac0fcb..5cc30a30 100644 --- a/crates/emulebb-daemon/src/bin/emulebb-rust.rs +++ b/crates/emulebb-daemon/src/bin/emulebb-rust.rs @@ -3,12 +3,15 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use emulebb_daemon::{BUILD_VERSION, DaemonProfile, apply_profile_import, logging, run}; +use emulebb_daemon::{ + BUILD_VERSION, DaemonProfile, apply_profile_import, build_version_description, logging, run, +}; #[derive(Debug, Parser)] #[command( name = "emulebb-rust", version = BUILD_VERSION, + long_version = build_version_description(), about = "Rust headless eMuleBB client" )] struct Cli { diff --git a/crates/emulebb-daemon/src/lib.rs b/crates/emulebb-daemon/src/lib.rs index 088e0cfd..a1d6faff 100644 --- a/crates/emulebb-daemon/src/lib.rs +++ b/crates/emulebb-daemon/src/lib.rs @@ -46,17 +46,8 @@ pub use shutdown::{ShutdownRequest, ShutdownSignal}; pub const PROFILE_SETTINGS_FILE: &str = "emulebb-rust-settings.toml"; pub const PROFILE_METADATA_FILE: &str = "emulebb-rust-metadata.db"; -pub const BUILD_VERSION: &str = match option_env!("EMULEBB_RELEASE_VERSION") { - Some(version) => version, - None => env!("CARGO_PKG_VERSION"), -}; -pub const BUILD_CHANNEL: &str = match option_env!("EMULEBB_RELEASE_CHANNEL") { - Some(channel) => channel, - None => "development", -}; -pub const BUILD_SOURCE_REVISION: &str = match option_env!("EMULEBB_SOURCE_REVISION") { - Some(revision) => revision, - None => "unknown", +pub use emulebb_core::{ + BUILD_CHANNEL, BUILD_SOURCE_REVISION, BUILD_VERSION, build_version_description, }; const REGULAR_DIAGNOSTIC_SUMMARY_INTERVAL: Duration = Duration::from_secs(10); diff --git a/crates/emulebb-rest/src/responses.rs b/crates/emulebb-rest/src/responses.rs index d31e9c80..4dfc7f58 100644 --- a/crates/emulebb-rest/src/responses.rs +++ b/crates/emulebb-rest/src/responses.rs @@ -10,10 +10,11 @@ use std::{ }; use emulebb_core::{ - AppInfo, AppLifecycle, LocalShare, NatStatusSnapshot, NetworkBindingStatus, NetworkStatus, - Search, SearchResult, SearchSpec, ServerInfo, SharedFileListItem, SharedFilePublishState, - Status, Transfer, TransferEventDiagnostics, TransferThroughputStats, UploadPolicyMetrics, - VpnGuardProbeStatus, VpnGuardStatus, normal_path_display, normalize_ed2k_file_name, + AppInfo, AppLifecycle, BUILD_ARCHITECTURE, BUILD_CHANNEL, BUILD_FLAVOR, BUILD_SOURCE_REVISION, + LocalShare, NatStatusSnapshot, NetworkBindingStatus, NetworkStatus, Search, SearchResult, + SearchSpec, ServerInfo, SharedFileListItem, SharedFilePublishState, Status, Transfer, + TransferEventDiagnostics, TransferThroughputStats, UploadPolicyMetrics, VpnGuardProbeStatus, + VpnGuardStatus, normal_path_display, normalize_ed2k_file_name, }; use serde_json::{Value, json}; @@ -45,8 +46,10 @@ pub(crate) fn app_info_response(app: AppInfo) -> Value { "version": app.version, "apiVersion": app.api_version, // Match the eMuleBB master app metadata: build flavor + platform token. - "build": if cfg!(debug_assertions) { "debug" } else { "release" }, - "platform": if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" }, + "build": BUILD_FLAVOR, + "platform": BUILD_ARCHITECTURE, + "channel": BUILD_CHANNEL, + "sourceRevision": BUILD_SOURCE_REVISION, "lifecycle": lifecycle_response(&app.lifecycle), "capabilities": capabilities }) diff --git a/crates/emulebb-rest/src/tests/app.rs b/crates/emulebb-rest/src/tests/app.rs index a939bc6d..cc166ff6 100644 --- a/crates/emulebb-rest/src/tests/app.rs +++ b/crates/emulebb-rest/src/tests/app.rs @@ -117,6 +117,13 @@ async fn app_returns_evelope_with_capabilities() { assert_eq!(value["meta"]["apiVersion"], "v1"); assert_eq!(value["data"]["apiVersion"], "v1"); assert_eq!(value["data"]["name"], "eMuleBB"); + assert_eq!(value["data"]["channel"], emulebb_core::BUILD_CHANNEL); + assert_eq!( + value["data"]["sourceRevision"], + emulebb_core::BUILD_SOURCE_REVISION + ); + assert_eq!(value["data"]["platform"], emulebb_core::BUILD_ARCHITECTURE); + assert_eq!(value["data"]["build"], emulebb_core::BUILD_FLAVOR); assert_eq!(value["data"]["capabilities"]["transfers"], true); assert_eq!(value["data"]["capabilities"]["transfers.sse"], true); assert_eq!(value["data"]["capabilities"]["transfers.sse.status"], true); diff --git a/resources/icons/donkey-128.png b/resources/icons/donkey-128.png new file mode 100644 index 00000000..84b6eca4 Binary files /dev/null and b/resources/icons/donkey-128.png differ diff --git a/resources/icons/donkey-16.png b/resources/icons/donkey-16.png new file mode 100644 index 00000000..54a7f934 Binary files /dev/null and b/resources/icons/donkey-16.png differ diff --git a/resources/icons/donkey-24.png b/resources/icons/donkey-24.png new file mode 100644 index 00000000..b7a1307b Binary files /dev/null and b/resources/icons/donkey-24.png differ diff --git a/resources/icons/donkey-256.png b/resources/icons/donkey-256.png new file mode 100644 index 00000000..008bd475 Binary files /dev/null and b/resources/icons/donkey-256.png differ diff --git a/resources/icons/donkey-32.png b/resources/icons/donkey-32.png new file mode 100644 index 00000000..f5cf62e7 Binary files /dev/null and b/resources/icons/donkey-32.png differ diff --git a/resources/icons/donkey-48.png b/resources/icons/donkey-48.png new file mode 100644 index 00000000..3245d2de Binary files /dev/null and b/resources/icons/donkey-48.png differ diff --git a/resources/icons/donkey-512.png b/resources/icons/donkey-512.png new file mode 100644 index 00000000..42fdbd1c Binary files /dev/null and b/resources/icons/donkey-512.png differ diff --git a/resources/icons/donkey-64.png b/resources/icons/donkey-64.png new file mode 100644 index 00000000..4413ad31 Binary files /dev/null and b/resources/icons/donkey-64.png differ diff --git a/tools/ci-toolchain.json b/tools/ci-toolchain.json index 116a5f89..fdce0d1e 100644 --- a/tools/ci-toolchain.json +++ b/tools/ci-toolchain.json @@ -31,10 +31,10 @@ "inputs": { "emulebb-miniupnp": "4e7a10948004ca2085ac8103e14fc77edb898b4c", "emulebb-libpcpnatpmp": "a6e787f837e507336a8774522c33b54264ef4a4b", - "emulebb-build-tests": "fd4c6d21e079fab2b84704294c7c7000b893a81e", - "emulebb-tooling": "630d1a3492ae65acad0a8dfd1575d671ff7b492c" + "emulebb-build-tests": "d2a48d8920ef4225e86fbe918045240ab2452e67", + "emulebb-tooling": "3b045ab4dea7554dceb13d3022a21727046d06e5" }, "node": "26.13.0", "npm": "11.20.0", - "openapi_sha256": "81f7134686f66b5a26ce13a489304d1661d0a3e45b0ff4f0269df8f32da500c9" + "openapi_sha256": "4c133d3efa69d433c2dc76e77a0e64dde2c9a56838567f674ae950a029ba7c9a" } diff --git a/tools/package_release_zip.py b/tools/package_release_zip.py index ae0c8ed7..dd3a91d6 100644 --- a/tools/package_release_zip.py +++ b/tools/package_release_zip.py @@ -202,7 +202,6 @@ def package_readme(version: str) -> str: "This is the unsigned Windows x64 beta package for the headless emulebb-rust daemon.\n\n" "Run `emulebb-rust.exe --profile ` from this directory or from a script that points at a profile.\n" "The daemon serves the embedded browser WebUI from the packaged `webui` directory beside the executable.\n\n" - "The retired native Slint UI has been removed; use the embedded browser WebUI.\n" ) diff --git a/tools/tests/test_android_release_workflow.py b/tools/tests/test_android_release_workflow.py index 9aff9691..81d9f2ff 100644 --- a/tools/tests/test_android_release_workflow.py +++ b/tools/tests/test_android_release_workflow.py @@ -88,8 +88,24 @@ def test_existing_native_musl_and_oci_proofs_keep_full_inventory(self): self.assertEqual(images["variant"], ["ubuntu", "alpine"]) self.assertEqual({row["architecture"] for row in images["include"]}, {"amd64", "arm64"}) self.assertEqual(self.text.count("smoke-rust-release-package.py"), 1) + smoke = next(step["run"] for step in self.jobs["native-package-smoke"]["steps"] + if "smoke-rust-release-package.py" in step.get("run", "")) + self.assertIn('--expected-channel "${{ env.RELEASE_CHANNEL }}"', smoke) + self.assertIn('--expected-source-sha "${{ needs.prepare.outputs.source_sha }}"', smoke) + self.assertIs(self.jobs["native-package-smoke"]["steps"], self.jobs["optional-macos-smoke"]["steps"]) self.assertNotIn("package-smoke", str(self.jobs["android-package"])) + def test_profile_ownership_proof_is_selected_only_for_windows_and_linux(self): + step = next(step for step in self.jobs["native-package-smoke"]["steps"] + if "smoke-rust-release-package.py" in step.get("run", "")) + self.assertIn("${{ matrix.os != 'macos' && '--verify-profile-ownership' || '' }}", step["run"]) + self.assertEqual(step["env"]["EMULEBB_WORKSPACE_ROOT"], "${{ github.workspace }}") + self.assertEqual(step["env"]["EMULEBB_WORKSPACE_OUTPUT_ROOT"], + "${{ runner.temp }}/emulebb-rust-out") + self.assertIn('--expected-channel "${{ env.RELEASE_CHANNEL }}"', step["run"]) + self.assertNotIn('$RELEASE_CHANNEL', step["run"], + "Native smoke also runs under PowerShell, which uses different environment syntax") + def test_android_uses_pinned_frontend_and_compiler_recipe_inputs(self): job = self.jobs["android-package"] steps = job["steps"] diff --git a/tools/tests/test_release_linux_provisioning.py b/tools/tests/test_release_linux_provisioning.py new file mode 100644 index 00000000..a4df6e50 --- /dev/null +++ b/tools/tests/test_release_linux_provisioning.py @@ -0,0 +1,79 @@ +"""Native release setup must use bounded, pinned Ubuntu provisioning.""" + +from pathlib import Path +import shlex +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] + + +class ReleaseLinuxProvisioningTests(unittest.TestCase): + def setUp(self): + self.workflow = yaml.safe_load((ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8")) + self.job = self.workflow["jobs"]["native-package"] + self.steps = self.job["steps"] + self.provision = next(step for step in self.steps + if "provision-ubuntu-dependencies-ci" in step.get("run", "")) + + def test_linux_setup_uses_the_shared_bounded_provisioner(self): + self.assertEqual(self.provision["if"], "matrix.os == 'linux'") + self.assertEqual(shlex.split(self.provision["run"]), [ + "python", "-B", "-m", "emule_workspace", "provision-ubuntu-dependencies-ci", + "--profile", "rust-native-release", "--deadline-seconds", "300", + "--network-timeout-seconds", "20", "--attempts", "3", + "--report", "$RUNNER_TEMP/emulebb-rust-out/reports/native-linux-provisioning.json", + ]) + self.assertFalse(any("apt-get" in step.get("run", "") for step in self.steps), + "APT source selection and retries belong to the pinned Builder") + + def test_setup_has_a_short_outer_deadline_and_failure_remains_blocking(self): + self.assertEqual(self.provision["timeout-minutes"], 6) + self.assertEqual(self.job["timeout-minutes"], 45) + self.assertNotIn("continue-on-error", self.provision) + self.assertNotIn("continue-on-error", self.job) + self.assertNotIn("env", self.provision, + "Dependency setup must not repair inherited workspace inputs") + + def test_setup_uses_the_checked_out_packaging_owner_before_packaging(self): + checkout = next(step for step in self.steps + if step.get("with", {}).get("repository") == "emulebb/emulebb-build") + self.assertEqual(checkout["with"]["ref"], "${{ env.EMULEBB_BUILD_REF }}") + self.assertRegex(self.workflow["env"]["EMULEBB_BUILD_REF"], r"^[0-9a-f]{40}$") + self.assertEqual(self.provision["working-directory"], checkout["with"]["path"]) + provision_index = self.steps.index(self.provision) + self.assertLess(self.steps.index(checkout), provision_index) + self.assertTrue(any("pip install" in step.get("run", "") + and "click==8.4.1" in step["run"] for step in self.steps[:provision_index])) + appimage = next(step for step in self.steps if step.get("name") == "Install verified AppImage builder") + package = next(step for step in self.steps if "package-emulebb-rust-ci" in step.get("run", "")) + self.assertLess(provision_index, self.steps.index(appimage)) + self.assertLess(provision_index, self.steps.index(package)) + self.assertIs(self.workflow["jobs"]["optional-macos-package"]["steps"], self.steps) + + def test_provisioning_evidence_is_retained_even_if_packaging_fails(self): + diagnostics = next(step for step in self.steps if step.get("name") == "Retain build diagnostics") + self.assertEqual(diagnostics["if"], "always()") + self.assertIn("${{ runner.temp }}/emulebb-rust-out/reports/native-linux-provisioning.json", + diagnostics["with"]["path"]) + self.assertIn("${{ runner.temp }}/emulebb-rust-out/logs/builds/**/*.log", diagnostics["with"]["path"]) + + def test_musl_host_cache_preflight_inherits_an_existing_owned_target(self): + steps = self.workflow["jobs"]["musl-package"]["steps"] + package = next(step for step in steps + if "package-emulebb-rust-linux-ci" in step.get("run", "")) + prepare = next(step for step in steps + if step.get("name") == "Prepare inherited host Cargo target for the source-cache fence") + target = "${{ runner.temp }}/emulebb-rust-out/builds/rust/target" + self.assertEqual(package["env"]["CARGO_TARGET_DIR"], target) + self.assertEqual(prepare["env"]["CARGO_TARGET_DIR"], target) + self.assertIn("Path(os.environ['CARGO_TARGET_DIR']).mkdir(parents=True, exist_ok=True)", prepare["run"]) + self.assertLess(steps.index(prepare), steps.index(package)) + self.assertEqual(package["env"]["EMULEBB_WORKSPACE_OUTPUT_ROOT"], + "${{ runner.temp }}/emulebb-rust-out") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/index.html b/webui/index.html index feaa41f0..42322c0d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,6 +3,8 @@ + + eMuleBB WebUI diff --git a/webui/public/apple-touch-icon.png b/webui/public/apple-touch-icon.png new file mode 100644 index 00000000..cf2b9f99 Binary files /dev/null and b/webui/public/apple-touch-icon.png differ diff --git a/webui/public/favicon.ico b/webui/public/favicon.ico new file mode 100644 index 00000000..d31fe07c Binary files /dev/null and b/webui/public/favicon.ico differ diff --git a/webui/src/AppIdentity.tsx b/webui/src/AppIdentity.tsx new file mode 100644 index 00000000..55ed62ad --- /dev/null +++ b/webui/src/AppIdentity.tsx @@ -0,0 +1,21 @@ +import type { AppInfo } from "./api"; + +/** Keep the existing application heading and its build details on one identity. */ +export function AppIdentity({ app }: { app: AppInfo | null }) { + const version = app?.version ?? app?.apiVersion ?? "REST dashboard"; + return ( +
+

eMuleBB WebUI

+
+ {version} +
+
Version
{version}
+
Channel
{app?.channel ?? "unknown"}
+
Architecture
{app?.platform ?? "unknown"}
+
Build
{app?.build ?? "unknown"}
+
Source commit
{app?.sourceRevision ?? "unknown"}
+
+
+
+ ); +} diff --git a/webui/src/api.ts b/webui/src/api.ts index 2a1ae465..4f6a0e0a 100644 --- a/webui/src/api.ts +++ b/webui/src/api.ts @@ -60,6 +60,10 @@ export type Page = { }; export type AppInfo = { + build?: string; + platform?: string; + channel?: string; + sourceRevision?: string; appName?: string; apiVersion?: string; contractVersion?: string; diff --git a/webui/src/app.tsx b/webui/src/app.tsx index 7dba19fe..6ae243ac 100644 --- a/webui/src/app.tsx +++ b/webui/src/app.tsx @@ -1,4 +1,5 @@ import type { ComponentChildren } from "preact"; +import { AppIdentity } from "./AppIdentity"; import { useCallback, useEffect, useState } from "preact/hooks"; import { Activity, @@ -721,11 +722,8 @@ export function App() {
{authState === "authenticated" && ( <> diff --git a/webui/src/styles.css b/webui/src/styles.css index 6bab0c3e..b707127b 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -106,7 +106,7 @@ td { .brand-mark { align-items: center; - background: var(--tblr-primary); + background: transparent; border-radius: var(--tblr-border-radius); color: var(--tblr-primary-fg); display: inline-flex; @@ -131,6 +131,36 @@ td { margin: 2px 0 0; } +.brand-identity { + position: relative; +} + +.brand-identity summary { + cursor: pointer; +} + +.build-details { + background: var(--tblr-bg-surface); + border: 1px solid var(--tblr-border-color); + border-radius: var(--tblr-border-radius); + box-shadow: var(--tblr-box-shadow); + display: grid; + font-size: 0.8rem; + gap: 0.4rem 0.75rem; + grid-template-columns: auto minmax(0, 1fr); + margin-top: 0.5rem; + max-width: calc(100vw - 2rem); + min-width: min(24rem, calc(100vw - 2rem)); + padding: 0.75rem; + position: absolute; + z-index: 1100; +} + +.build-details dd { + margin: 0; + overflow-wrap: anywhere; +} + .status-toolbar { align-items: stretch; display: flex; diff --git a/webui/tests/e2e/app-smoke.spec.ts b/webui/tests/e2e/app-smoke.spec.ts index 8f3939e1..a7c805ca 100644 --- a/webui/tests/e2e/app-smoke.spec.ts +++ b/webui/tests/e2e/app-smoke.spec.ts @@ -21,7 +21,7 @@ test("loads mocked dashboard data and navigates primary views", async ({ page }) await expect(page.getByRole("heading", { name: "eMuleBB WebUI" })).toBeVisible(); await expect.poll(() => page.evaluate(() => window.localStorage.getItem("emulebb.webui.apiKey"))).toBeNull(); - await expect(page.getByText("0.1.0-beta.2")).toBeVisible(); + await expect(page.locator("summary.brand-version")).toHaveText("0.1.0-beta.2"); await expect(page.getByText("Sample Transfer.bin")).toBeVisible(); const topbar = page.locator("header .topbar"); const primaryViews = topbar.getByRole("navigation", { name: "Primary views" }); diff --git a/webui/tests/unit/app-identity.test.tsx b/webui/tests/unit/app-identity.test.tsx new file mode 100644 index 00000000..71a57f57 --- /dev/null +++ b/webui/tests/unit/app-identity.test.tsx @@ -0,0 +1,23 @@ +import { cleanup, render, screen } from "@testing-library/preact"; +import { afterEach, describe, expect, it } from "vitest"; + +import { AppIdentity } from "../../src/AppIdentity"; + +afterEach(cleanup); + +describe("application build identity", () => { + it("shows the server identity and full source commit in About details", () => { + const revision = "0123456789abcdef0123456789abcdef01234567"; + render(); + expect(screen.getByLabelText("About this build").tagName).toBe("DETAILS"); + expect(screen.getByText(revision)).toBeInTheDocument(); + for (const value of ["nightly", "arm64", "release"]) expect(screen.getByText(value)).toBeInTheDocument(); + }); + + it("does not infer an unreported channel or commit", () => { + render(); + expect(screen.getAllByText("unknown")).toHaveLength(4); + expect(screen.queryByText("nightly")).not.toBeInTheDocument(); + }); +});