diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 4923a67928..e9ce14e616 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -101,6 +101,7 @@ export default defineConfig({ "**/sidebar-snapshot.spec.ts", "**/home-collapsed-top-chrome.spec.ts", "**/top-chrome-zoom-clearance.spec.ts", + "**/deprecation-banner.spec.ts", "**/thread-unread.spec.ts", "**/thread-load-failure.spec.ts", "**/project-conversation-load-failure.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 060478dd8b..1dea09f4ad 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1207,6 +1207,7 @@ dependencies = [ "webkit2gtk", "window-vibrancy", "windows-sys 0.61.2", + "xattr", "zeroize", "zip 9.0.5", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 41f3d4d4ac..33a2afd4aa 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -67,6 +67,8 @@ security-framework = { version = "3.7.0", features = ["OSX_10_15"] } window-vibrancy = "0.6" user-idle = { version = "0.6", default-features = false } plist = "1" +# Sets the quarantine attribute on the new-app installer the app downloads. +xattr = "1" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c9933babc0..eb90f8476f 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -45,6 +45,7 @@ pub(crate) mod mesh_llm; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_readiness; mod messages; +mod new_app_installer; mod notifications; mod observer_archive; mod os_idle; @@ -110,6 +111,7 @@ pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; pub use messages::*; +pub use new_app_installer::*; pub use notifications::*; pub use observer_archive::*; pub use os_idle::*; diff --git a/desktop/src-tauri/src/commands/new_app_installer.rs b/desktop/src-tauri/src/commands/new_app_installer.rs new file mode 100644 index 0000000000..900a530453 --- /dev/null +++ b/desktop/src-tauri/src/commands/new_app_installer.rs @@ -0,0 +1,947 @@ +//! In-app download of the new Buzz app's installer, started from the +//! deprecation banner. +//! +//! The webview only names a URL. Everything else is decided here: whether the +//! URL is a release asset of the new app for this machine, which hosts a +//! redirect may reach, what the file is called and where it goes. The file is +//! written under a partial name in the Downloads folder, marked as downloaded +//! from the internet, and only then moved to a free final name. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError}; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use tauri::ipc::Channel; +use tauri::{AppHandle, Manager}; +use tempfile::TempPath; +use tokio::io::AsyncWriteExt; +use tokio_util::sync::CancellationToken; +use url::Url; + +use super::updater::new_app_installer_target; + +const INSTALLER_HOST: &str = "github.com"; +const INSTALLER_PATH_PREFIX: &str = "/block/buzz-app/releases/download/"; +/// Hosts GitHub serves release assets from: the download link itself, and the +/// storage hosts it redirects to. +const REDIRECT_HOSTS: [&str; 3] = [ + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +]; +const MAX_REDIRECTS: usize = 5; +const MAX_FILE_NAME_LEN: usize = 128; +/// `name.ext`, then `name (1).ext` up to `name (99).ext`. +const MAX_NAME_ATTEMPTS: u32 = 100; +/// Published installers are under 200 MiB. +const MAX_INSTALLER_BYTES: u64 = 512 * 1024 * 1024; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); +const STALL_TIMEOUT: Duration = Duration::from_secs(30); +const TOTAL_TIMEOUT: Duration = Duration::from_secs(60 * 60); +const PROGRESS_INTERVAL: Duration = Duration::from_millis(100); +const PARTIAL_SUFFIX: &str = ".part"; +/// File in the app data directory that holds the file name of the running +/// download's partial file, so the launch after a crash can remove exactly +/// that file from the Downloads folder. +const PARTIAL_RECORD_NAME: &str = "new-app-installer-partial.txt"; + +/// Progress sent to the webview over the command's channel. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum InstallerDownloadEvent { + /// The URL was accepted; `file_name` is the name the file is saved under + /// unless that name is taken. + Started { file_name: String }, + /// Bytes written so far. `total_bytes` is absent when the server sent no + /// length. + Progress { + received_bytes: u64, + total_bytes: Option, + }, +} + +/// Why a download did not produce a file. The webview words each one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum InstallerFailure { + /// Another installer download is still running. + Busy, + /// The URL is not an installer of the new app for this machine. + Rejected, + /// The request failed, stalled, or ended early. + Network, + /// A redirect pointed outside the hosts release assets are served from, or + /// there were too many of them. Trying again gets the same answer. + RedirectBlocked, + /// The response is larger than any installer should be. + TooLarge, + /// The file could not be written or moved into the Downloads folder. + Storage, + /// The system refused access to the Downloads folder. + PermissionDenied, + /// The file could not be marked as downloaded from the internet. + Protection, +} + +/// How a download ended. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "status", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum InstallerDownloadOutcome { + /// The installer is at `folder`/`file_name`. + Saved { file_name: String, folder: String }, + /// The download was cancelled and its partial file removed. + Cancelled, + /// The download failed and its partial file was removed. + Failed { reason: InstallerFailure }, +} + +fn fail(reason: InstallerFailure, detail: impl std::fmt::Display) -> InstallerFailure { + eprintln!("buzz-desktop: new app installer download failed ({reason:?}): {detail}"); + reason +} + +/// A write the system refused is told apart from every other storage error: +/// freeing space does not fix it, granting access does. +fn storage_failure(error: io::Error) -> InstallerFailure { + let reason = if error.kind() == io::ErrorKind::PermissionDenied { + InstallerFailure::PermissionDenied + } else { + InstallerFailure::Storage + }; + fail(reason, error) +} + +/// Every extension [`installer_extension`] can return. +const INSTALLER_EXTENSIONS: [&str; 4] = [".dmg", ".exe", ".AppImage", ".deb"]; + +/// The file extension each installer manifest key must point at. +fn installer_extension(target: &str) -> Option<&'static str> { + match target { + "macos-arm64" | "macos-x64" => Some(".dmg"), + "windows-x64" => Some(".exe"), + "linux-appimage" => Some(".AppImage"), + "linux-deb" => Some(".deb"), + _ => None, + } +} + +/// An installer URL that passed validation, with the file name derived from it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct InstallerSource { + pub(super) url: Url, + pub(super) file_name: String, + pub(super) extension: &'static str, +} + +/// Accepts `raw` only when it is an https release asset of block/buzz-app on +/// github.com whose file name is plain and ends in the extension `target` +/// installs. These are the rules the webview's `resolveInstallerUrl` applies; +/// they are repeated here because the webview is not trusted. +pub(super) fn validate_installer_url(raw: &str, target: &str) -> Option { + let extension = installer_extension(target)?; + // Parsing normalises `..` segments and backslashes before the checks. + let url = Url::parse(raw).ok()?; + if url.scheme() != "https" + || url.host_str() != Some(INSTALLER_HOST) + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } + let (tag, file_name) = url + .path() + .strip_prefix(INSTALLER_PATH_PREFIX)? + .rsplit_once('/')?; + if !is_safe_tag(tag) || !is_safe_file_name(file_name, extension) { + return None; + } + Some(InstallerSource { + file_name: file_name.to_string(), + extension, + url, + }) +} + +/// A release tag is sent to GitHub as written: letters, digits and `._+-/` +/// only. It holds no percent escape, so staying inside the repository's +/// releases does not depend on how GitHub decodes one. +fn is_safe_tag(tag: &str) -> bool { + !tag.is_empty() + && tag.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-' | b'/') + }) +} + +/// A file name is used as is or not at all: letters, digits and `._+-` only, +/// so it holds no separator, no percent escape and no control character. +fn is_safe_file_name(name: &str, extension: &str) -> bool { + name.len() <= MAX_FILE_NAME_LEN + && name + .strip_suffix(extension) + .is_some_and(|stem| !stem.is_empty()) + && name.starts_with(|first: char| first.is_ascii_alphanumeric()) + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-')) + && !is_windows_device_name(name) +} + +/// `CON.exe`, `nul.dmg` and friends name devices on Windows, whatever follows +/// the first dot. +fn is_windows_device_name(name: &str) -> bool { + let base = name.split('.').next().unwrap_or(name).to_ascii_uppercase(); + match base.as_str() { + "CON" | "PRN" | "AUX" | "NUL" => true, + _ => { + base.len() == 4 + && (base.starts_with("COM") || base.starts_with("LPT")) + && base.ends_with(|digit: char| ('1'..='9').contains(&digit)) + } + } +} + +/// The name tried on the given attempt: the installer's own name first, then +/// `stem (1).ext`, `stem (2).ext` and so on. +pub(super) fn candidate_file_name(file_name: &str, extension: &str, attempt: u32) -> String { + if attempt == 0 { + return file_name.to_string(); + } + let stem = file_name.strip_suffix(extension).unwrap_or(file_name); + format!("{stem} ({attempt}){extension}") +} + +/// Whether a redirect may be followed: https, default port, no credentials, +/// and one of the hosts GitHub serves release assets from. +pub(super) fn redirect_allowed(url: &Url) -> bool { + url.scheme() == "https" + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && url + .host_str() + .is_some_and(|host| REDIRECT_HOSTS.contains(&host)) +} + +/// Everything about the installer client except the https-only switch, which +/// tests against a local http server have to leave off. +pub(super) fn installer_client_builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() > MAX_REDIRECTS { + attempt.error("too many redirects") + } else if redirect_allowed(attempt.url()) { + attempt.follow() + } else { + attempt.error("redirect left the release asset hosts") + } + })) + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(STALL_TIMEOUT) + .user_agent(concat!("buzz-desktop/", env!("CARGO_PKG_VERSION"))) +} + +/// Builds the client installer downloads use. It is separate from the app-wide +/// client because that one follows redirects to any host; this one refuses +/// plain http and every redirect outside [`REDIRECT_HOSTS`]. +pub(super) fn build_installer_client() -> reqwest::Result { + installer_client_builder().https_only(true).build() +} + +/// Bounds on one transfer. Tests shrink them. +#[derive(Debug, Clone, Copy)] +pub(super) struct TransferLimits { + pub(super) max_bytes: u64, + pub(super) response_timeout: Duration, + pub(super) stall_timeout: Duration, + pub(super) total_timeout: Duration, +} + +impl TransferLimits { + const PRODUCTION: Self = Self { + max_bytes: MAX_INSTALLER_BYTES, + response_timeout: RESPONSE_TIMEOUT, + stall_timeout: STALL_TIMEOUT, + total_timeout: TOTAL_TIMEOUT, + }; +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum TransferError { + Cancelled, + Failed(InstallerFailure), +} + +/// Lets a progress report through at most once per [`PROGRESS_INTERVAL`], plus +/// the first report and the one that completes a known total. +#[derive(Debug, Default)] +pub(super) struct ProgressThrottle { + last: Option, +} + +impl ProgressThrottle { + pub(super) fn admit(&mut self, received: u64, total: Option, now: Instant) -> bool { + let due = self + .last + .is_none_or(|last| now.duration_since(last) >= PROGRESS_INTERVAL); + if due || total == Some(received) { + self.last = Some(now); + return true; + } + false + } +} + +/// Streams the response body into `file`, enforcing the size cap and the +/// response and stall timeouts. Returns the number of bytes written. +async fn fetch_into( + client: &reqwest::Client, + url: Url, + file: &mut tokio::fs::File, + limits: TransferLimits, + mut on_progress: impl FnMut(u64, Option), +) -> Result { + let mut response = tokio::time::timeout(limits.response_timeout, client.get(url).send()) + .await + .map_err(|_| fail(InstallerFailure::Network, "no response in time"))? + .map_err(|error| { + // The client's redirect policy refused the next hop. + let reason = if error.is_redirect() { + InstallerFailure::RedirectBlocked + } else { + InstallerFailure::Network + }; + fail(reason, error) + })?; + if response.status() != reqwest::StatusCode::OK { + return Err(fail(InstallerFailure::Network, response.status())); + } + let total = response.content_length(); + if let Some(total) = total.filter(|total| *total > limits.max_bytes) { + return Err(fail( + InstallerFailure::TooLarge, + format_args!("{total} bytes announced"), + )); + } + + let mut received: u64 = 0; + on_progress(received, total); + loop { + let chunk = tokio::time::timeout(limits.stall_timeout, response.chunk()) + .await + .map_err(|_| fail(InstallerFailure::Network, "transfer stalled"))? + .map_err(|error| fail(InstallerFailure::Network, error))?; + let Some(chunk) = chunk else { + break; + }; + received += chunk.len() as u64; + if received > limits.max_bytes { + return Err(fail( + InstallerFailure::TooLarge, + format_args!("more than {} bytes received", limits.max_bytes), + )); + } + file.write_all(&chunk).await.map_err(storage_failure)?; + on_progress(received, total); + } + if received == 0 || total.is_some_and(|total| total != received) { + return Err(fail( + InstallerFailure::Network, + format_args!("body ended at {received} of {total:?} bytes"), + )); + } + Ok(received) +} + +/// Moves the finished partial file to the first free name in `dest_dir`. The +/// move refuses to replace an existing file, so nothing is ever overwritten +/// even when another program creates the same name at the same moment. +fn persist_without_overwrite( + mut partial: TempPath, + dest_dir: &Path, + source: &InstallerSource, +) -> Result { + for attempt in 0..MAX_NAME_ATTEMPTS { + let candidate = dest_dir.join(candidate_file_name( + &source.file_name, + source.extension, + attempt, + )); + match partial.persist_noclobber(&candidate) { + Ok(()) => return Ok(candidate), + Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => { + partial = error.path; + } + // Dropping the error drops the partial path, which removes the file. + Err(error) => return Err(storage_failure(error.error)), + } + } + Err(fail( + InstallerFailure::Storage, + "no free file name in the Downloads folder", + )) +} + +/// Creates `dest_dir` if needed and `..part` in it. +/// +/// On Unix the file is requested with mode 0644 and the user's umask applies +/// on top. A stricter umask is honoured, and a looser one never makes the +/// installer writable by anyone but its owner. It is never executable. The +/// temporary-file default is private to the user, which is wrong for a file +/// that ends up in Downloads. +fn create_partial(dest_dir: &Path, file_name: &str) -> io::Result<(std::fs::File, TempPath)> { + std::fs::create_dir_all(dest_dir)?; + let prefix = format!("{file_name}."); + let mut builder = tempfile::Builder::new(); + builder.prefix(&prefix).suffix(PARTIAL_SUFFIX); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + builder.permissions(std::fs::Permissions::from_mode(0o644)); + } + Ok(builder.tempfile_in(dest_dir)?.into_parts()) +} + +/// Downloads `source` into `dest_dir` and returns the final path. +/// +/// The body is written to `..part`, which is removed on +/// every path that does not end in success: failure, cancellation, and the +/// future being dropped. `mark` runs on the complete partial file before it is +/// moved into place, so a file under the final name always carries the mark. +#[allow(clippy::too_many_arguments)] +pub(super) async fn download_installer( + client: &reqwest::Client, + source: &InstallerSource, + dest_dir: &Path, + limits: TransferLimits, + cancel: &CancellationToken, + on_partial: impl FnOnce(&Path), + mark: impl FnOnce(&Path, &Url) -> io::Result<()>, + mut emit: impl FnMut(InstallerDownloadEvent), +) -> Result { + let storage = |error: io::Error| TransferError::Failed(storage_failure(error)); + + // Off the async workers: on macOS the first write to Downloads can wait on + // the system's consent prompt for as long as the user takes to answer it. + let (file, partial) = { + let dest_dir = dest_dir.to_path_buf(); + let file_name = source.file_name.clone(); + tokio::task::spawn_blocking(move || create_partial(&dest_dir, &file_name)) + .await + .map_err(io::Error::other) + .and_then(|created| created) + .map_err(storage)? + }; + on_partial(&partial); + let mut file = tokio::fs::File::from_std(file); + + let mut throttle = ProgressThrottle::default(); + let transfer = fetch_into( + client, + source.url.clone(), + &mut file, + limits, + |received_bytes, total_bytes| { + if throttle.admit(received_bytes, total_bytes, Instant::now()) { + emit(InstallerDownloadEvent::Progress { + received_bytes, + total_bytes, + }); + } + }, + ); + tokio::select! { + biased; + () = cancel.cancelled() => return Err(TransferError::Cancelled), + result = tokio::time::timeout(limits.total_timeout, transfer) => match result { + Ok(Ok(_)) => {} + Ok(Err(reason)) => return Err(TransferError::Failed(reason)), + Err(_) => { + return Err(TransferError::Failed(fail( + InstallerFailure::Network, + "download took too long", + ))) + } + }, + } + + file.flush().await.map_err(storage)?; + file.sync_all().await.map_err(storage)?; + drop(file); + mark(&partial, &source.url) + .map_err(|error| TransferError::Failed(fail(InstallerFailure::Protection, error)))?; + // Last point at which a cancel still leaves nothing behind. + if cancel.is_cancelled() { + return Err(TransferError::Cancelled); + } + persist_without_overwrite(partial, dest_dir, source).map_err(TransferError::Failed) +} + +// Written on macOS only. Tests check the value's shape on every platform. +#[cfg(any(target_os = "macos", test))] +const QUARANTINE_AGENT: &str = "Buzz"; + +/// The value a non-sandboxed browser writes to `com.apple.quarantine` for a +/// web download: flags, hex timestamp, downloading app, and an event id that +/// is empty because no LaunchServices quarantine event is recorded. +#[cfg(any(target_os = "macos", test))] +pub(super) fn quarantine_value(unix_seconds: u64) -> String { + format!("0081;{unix_seconds:08x};{QUARANTINE_AGENT};") +} + +/// The NTFS alternate data stream Windows reads the Mark of the Web from. +pub(super) fn zone_identifier_path(path: &Path) -> PathBuf { + let mut stream = path.as_os_str().to_os_string(); + stream.push(":Zone.Identifier"); + PathBuf::from(stream) +} + +/// Zone 3 is the internet zone, which is what makes SmartScreen check the file. +pub(super) fn zone_identifier_contents(origin: &Url) -> String { + format!("[ZoneTransfer]\r\nZoneId=3\r\nHostUrl={origin}\r\n") +} + +/// Writes the Mark of the Web and reads it back. A volume without alternate +/// data streams fails here, and the download fails with it. +pub(super) fn write_zone_identifier(path: &Path, origin: &Url) -> io::Result<()> { + let stream = zone_identifier_path(path); + let contents = zone_identifier_contents(origin); + std::fs::write(&stream, &contents)?; + if std::fs::read(&stream)? != contents.as_bytes() { + return Err(io::Error::other("Zone.Identifier did not persist")); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +pub(super) const QUARANTINE_XATTR: &str = "com.apple.quarantine"; + +#[cfg(target_os = "macos")] +fn write_quarantine(path: &Path, origin: &Url) -> io::Result<()> { + const WHERE_FROMS: &str = "com.apple.metadata:kMDItemWhereFroms"; + + let unix_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(io::Error::other)? + .as_secs(); + let value = quarantine_value(unix_seconds); + xattr::set(path, QUARANTINE_XATTR, value.as_bytes())?; + if xattr::get(path, QUARANTINE_XATTR)?.as_deref() != Some(value.as_bytes()) { + return Err(io::Error::other("quarantine attribute did not persist")); + } + + // Finder's "Where from". Nothing enforces it, so it is best effort. + let mut where_froms = Vec::new(); + if plist::to_writer_binary(&mut where_froms, &[origin.as_str()]).is_ok() { + if let Err(error) = xattr::set(path, WHERE_FROMS, &where_froms) { + eprintln!("buzz-desktop: could not record the installer's origin: {error}"); + } + } + Ok(()) +} + +/// Marks a finished download the way a browser does, so the system checks the +/// installer the first time it is opened: the quarantine attribute on macOS +/// (Gatekeeper) and the Mark of the Web on Windows (SmartScreen). An error +/// means the mark is not on the file, and the caller must not keep it. Linux +/// has no such mechanism, so nothing is written there. +pub(super) fn mark_as_downloaded(path: &Path, origin: &Url) -> io::Result<()> { + #[cfg(target_os = "macos")] + write_quarantine(path, origin)?; + if cfg!(windows) { + write_zone_identifier(path, origin)?; + } + Ok(()) +} + +/// The folder installers are saved to. A Linux desktop with no XDG download +/// directory configured reports none, so `~/Downloads` is used there; it is +/// created on first use. +pub(super) fn resolve_download_dir( + configured: Option, + home: Option, + is_linux: bool, +) -> Option { + configured.or_else(|| home.filter(|_| is_linux).map(|home| home.join("Downloads"))) +} + +/// Records the partial file by name only. The folder is not recorded: the +/// next launch looks in the Downloads folder it resolves itself, so the record +/// can never point the cleanup at a file anywhere else. +fn write_partial_record(record: &Path, partial: &Path) -> io::Result<()> { + let name = partial + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| io::Error::other("the partial file's name is not UTF-8"))?; + if let Some(parent) = record.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(record, name) +} + +fn clear_partial_record(record: &Path) { + match std::fs::remove_file(record) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + eprintln!("buzz-desktop: could not clear the partial installer record: {error}") + } + } +} + +/// Whether nothing is left at `partial`: it was removed, or it was moved to +/// the installer's final name. An error other than "not found" does not prove +/// the file is gone. +fn partial_is_gone(partial: &Path) -> bool { + matches!( + std::fs::symlink_metadata(partial), + Err(error) if error.kind() == io::ErrorKind::NotFound + ) +} + +/// Whether `name` has the shape of a partial file this module writes: +/// `..part`. Such a name holds only letters, digits +/// and `._+-`, so it has no separator and names a file directly in the folder +/// it is joined to. +fn is_partial_file_name(name: &str) -> bool { + let Some((installer, random)) = name + .strip_suffix(PARTIAL_SUFFIX) + .and_then(|rest| rest.rsplit_once('.')) + else { + return false; + }; + !random.is_empty() + && random.bytes().all(|byte| byte.is_ascii_alphanumeric()) + && INSTALLER_EXTENSIONS + .iter() + .any(|extension| is_safe_file_name(installer, extension)) +} + +/// Removes the partial file a previous run recorded and never cleaned up: a +/// crash, a force quit or a power cut ends the app without any of its cleanup. +/// +/// The record holds a file name, and only that name directly inside +/// `download_dir` is touched, and only while it is still a regular file of +/// this module's shape. No folder is searched and no recorded folder is +/// trusted, so a file this app did not create is never removed. Without a +/// Downloads folder nothing can be looked up, and the record is kept. +pub(super) fn remove_recorded_partial(record: &Path, download_dir: Option<&Path>) { + use std::io::Read; + + // A file name is far shorter than this; anything longer is not a record. + const MAX_RECORD_BYTES: u64 = 16 * 1024; + + let mut recorded = String::new(); + match std::fs::File::open(record) + .and_then(|file| file.take(MAX_RECORD_BYTES).read_to_string(&mut recorded)) + { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return, + Err(error) => { + eprintln!("buzz-desktop: could not read the partial installer record: {error}"); + recorded.clear(); + } + } + if is_partial_file_name(&recorded) { + let Some(download_dir) = download_dir else { + eprintln!("buzz-desktop: no Downloads folder to look for a leftover partial installer"); + return; + }; + let partial = download_dir.join(&recorded); + // A regular file, not a link to one. + let is_file = std::fs::symlink_metadata(&partial).is_ok_and(|metadata| metadata.is_file()); + if is_file { + if let Err(error) = std::fs::remove_file(&partial) { + // The record stays, so the next launch tries again. + eprintln!( + "buzz-desktop: could not remove leftover partial installer {}: {error}", + partial.display() + ); + return; + } + } + } + clear_partial_record(record); +} + +#[derive(Default)] +struct Slots { + active: Option, + last_saved: Option, +} + +struct Active { + cancel: CancellationToken, + partial: Option, +} + +/// The one installer download the app runs at a time, and the last file it +/// saved. +#[derive(Default)] +pub struct InstallerDownloads { + slots: Mutex, + client: OnceLock>, + /// Where the running download's partial file is recorded. Unset until + /// [`InstallerDownloads::recover`] runs. + partial_record: OnceLock, +} + +/// Holds the download slot; dropping it frees the slot. +pub(super) struct ActiveDownload<'a> { + downloads: &'a InstallerDownloads, + pub(super) cancel: CancellationToken, +} + +impl Drop for ActiveDownload<'_> { + fn drop(&mut self) { + // By now the partial file has become the installer or has been + // removed, unless its removal failed: then the record stays, and the + // next launch removes the file. The record is settled first, while + // the slot still keeps the next download out. + let partial = self + .downloads + .slots() + .active + .as_ref() + .and_then(|active| active.partial.clone()); + if partial.as_deref().is_none_or(partial_is_gone) { + self.downloads.forget_partial(); + } + self.downloads.slots().active = None; + } +} + +impl InstallerDownloads { + fn slots(&self) -> MutexGuard<'_, Slots> { + self.slots.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Claims the download slot, or returns `None` while a download holds it. + pub(super) fn begin(&self) -> Option> { + let mut slots = self.slots(); + if slots.active.is_some() { + return None; + } + let cancel = CancellationToken::new(); + slots.active = Some(Active { + cancel: cancel.clone(), + partial: None, + }); + Some(ActiveDownload { + downloads: self, + cancel, + }) + } + + /// Removes the partial file a previous run left behind in `download_dir`, + /// then records each download's partial file in `record` from here on. + /// Called once at startup, before any download can start. + pub(super) fn recover(&self, record: PathBuf, download_dir: Option<&Path>) { + remove_recorded_partial(&record, download_dir); + let _ = self.partial_record.set(record); + } + + pub(super) fn set_partial(&self, partial: &Path) { + if let Some(active) = self.slots().active.as_mut() { + active.partial = Some(partial.to_path_buf()); + } + if let Some(record) = self.partial_record.get() { + // Without the record a crash leaves this file behind; the download + // itself is not affected. + if let Err(error) = write_partial_record(record, partial) { + eprintln!("buzz-desktop: could not record the partial installer: {error}"); + } + } + } + + fn forget_partial(&self) { + if let Some(record) = self.partial_record.get() { + clear_partial_record(record); + } + } + + /// Asks the running download, if any, to stop. The download removes its + /// partial file and reports that it was cancelled. + pub(crate) fn cancel(&self) { + if let Some(active) = self.slots().active.as_ref() { + active.cancel.cancel(); + } + } + + /// Stops the running download as the app exits, on the normal exit path + /// and from the signal handler. The download task may never be polled + /// again, so the partial file is removed here. If that fails its record + /// stays, here and when the download task later frees its slot, and the + /// next launch removes the file. + pub(crate) fn abort_for_shutdown(&self) { + let partial = { + let slots = self.slots(); + let Some(active) = slots.active.as_ref() else { + return; + }; + active.cancel.cancel(); + active.partial.clone() + }; + if let Some(partial) = partial { + match std::fs::remove_file(&partial) { + Ok(()) => {} + // The download finished or cleaned up first. + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + eprintln!( + "buzz-desktop: could not remove partial installer {}: {error}", + partial.display() + ); + return; + } + } + } + self.forget_partial(); + } + + fn client(&self) -> Option<&reqwest::Client> { + self.client + .get_or_init(|| match build_installer_client() { + Ok(client) => Some(client), + Err(error) => { + eprintln!("buzz-desktop: could not build the installer client: {error}"); + None + } + }) + .as_ref() + } +} + +/// The Downloads folder of the running app. +fn download_dir(app: &AppHandle) -> Option { + resolve_download_dir( + app.path().download_dir().ok(), + app.path().home_dir().ok(), + cfg!(target_os = "linux"), + ) +} + +async fn run_download( + app: &AppHandle, + downloads: &InstallerDownloads, + active: &ActiveDownload<'_>, + url: &str, + on_event: &Channel, +) -> Result<(PathBuf, PathBuf), TransferError> { + let source = new_app_installer_target() + .and_then(|target| validate_installer_url(url, target)) + .ok_or_else(|| TransferError::Failed(fail(InstallerFailure::Rejected, url)))?; + let client = downloads + .client() + .ok_or(TransferError::Failed(InstallerFailure::Network))?; + let dest_dir = download_dir(app).ok_or_else(|| { + TransferError::Failed(fail(InstallerFailure::Storage, "no Downloads folder")) + })?; + + // A closed webview cannot receive events; the transfer is cancelled through + // the window hooks, not through a failed send. + let _ = on_event.send(InstallerDownloadEvent::Started { + file_name: source.file_name.clone(), + }); + let saved = download_installer( + client, + &source, + &dest_dir, + TransferLimits::PRODUCTION, + &active.cancel, + |partial| downloads.set_partial(partial), + mark_as_downloaded, + |event| { + let _ = on_event.send(event); + }, + ) + .await?; + Ok((saved, dest_dir)) +} + +/// Downloads the new Buzz app's installer into the Downloads folder. +/// +/// `url` must be the installer the manifest lists for this machine; it is +/// validated again here. Progress arrives on `on_event`. Only one download +/// runs at a time: a call made while another is running returns +/// `Failed { reason: Busy }` without touching the running one. +#[tauri::command] +pub async fn download_new_app_installer( + app: AppHandle, + url: String, + on_event: Channel, +) -> InstallerDownloadOutcome { + let downloads = app.state::(); + let Some(active) = downloads.begin() else { + return InstallerDownloadOutcome::Failed { + reason: InstallerFailure::Busy, + }; + }; + match run_download(&app, &downloads, &active, &url, &on_event).await { + Ok((saved, folder)) => { + let file_name = saved + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + downloads.slots().last_saved = Some(saved); + InstallerDownloadOutcome::Saved { + file_name, + folder: folder.display().to_string(), + } + } + Err(TransferError::Cancelled) => InstallerDownloadOutcome::Cancelled, + Err(TransferError::Failed(reason)) => InstallerDownloadOutcome::Failed { reason }, + } +} + +/// Removes the partial installer a crashed or force-quit run left in the +/// Downloads folder, and starts recording each new one so the next launch can +/// do the same. Run once from the app's setup hook. +pub fn recover_new_app_installer_downloads(app: &AppHandle) { + match app.path().app_data_dir() { + Ok(data_dir) => app.state::().recover( + data_dir.join(PARTIAL_RECORD_NAME), + download_dir(app).as_deref(), + ), + Err(error) => { + eprintln!("buzz-desktop: partial installer downloads are not recorded: {error}"); + } + } +} + +/// Cancels the running installer download, if any. The download command then +/// returns `Cancelled` once its partial file is gone. +#[tauri::command] +pub fn cancel_new_app_installer_download(app: AppHandle) { + app.state::().cancel(); +} + +/// Shows the last installer this app saved in the system file manager, with +/// the file selected. Takes no path: the webview cannot point it elsewhere. +#[tauri::command] +pub async fn reveal_new_app_installer(app: AppHandle) -> Result<(), String> { + let saved = app + .state::() + .slots() + .last_saved + .clone() + .ok_or_else(|| "no installer has been downloaded".to_string())?; + tauri_plugin_opener::reveal_item_in_dir(&saved).map_err(|error| error.to_string()) +} + +#[cfg(test)] +#[path = "new_app_installer_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/new_app_installer_tests.rs b/desktop/src-tauri/src/commands/new_app_installer_tests.rs new file mode 100644 index 0000000000..8d9cbf6f8e --- /dev/null +++ b/desktop/src-tauri/src/commands/new_app_installer_tests.rs @@ -0,0 +1,990 @@ +use std::io; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use axum::body::Body; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum::Router; +use bytes::Bytes; +use futures_util::stream::{self, StreamExt}; +use tokio_util::sync::CancellationToken; +use url::Url; + +use super::*; + +const RELEASE: &str = "https://github.com/block/buzz-app/releases/download/v1.0.0"; +const FILE_NAME: &str = "Buzz_1.0.0_aarch64.dmg"; +const BODY: &[u8] = b"not really a disk image, but enough bytes to count"; + +fn test_limits() -> TransferLimits { + TransferLimits { + max_bytes: 1024, + response_timeout: Duration::from_secs(5), + stall_timeout: Duration::from_millis(200), + total_timeout: Duration::from_secs(5), + } +} + +/// A body that sends `first` and then never sends anything else. +fn stalled_body(first: &'static [u8]) -> Body { + Body::from_stream( + stream::once(async move { Ok::<_, io::Error>(Bytes::from_static(first)) }) + .chain(stream::pending()), + ) +} + +/// A body with no announced length, sent as `chunks` chunks of 100 bytes. +fn unsized_body(chunks: usize) -> Body { + Body::from_stream(stream::iter( + (0..chunks).map(|_| Ok::<_, io::Error>(Bytes::from(vec![7u8; 100]))), + )) +} + +async fn serve() -> SocketAddr { + let app = Router::new() + .route("/ok", get(|| async { BODY })) + .route("/missing", get(|| async { StatusCode::NOT_FOUND })) + .route("/empty", get(|| async { "" })) + .route("/announced-too-large", get(|| async { vec![0u8; 2048] })) + .route("/unsized-ok", get(|| async { unsized_body(3) })) + .route("/unsized-too-large", get(|| async { unsized_body(20) })) + .route("/stalls", get(|| async { stalled_body(b"first") })) + .route( + "/cut-short", + get(|| async { + Response::builder() + .header(header::CONTENT_LENGTH, 1000) + .body(Body::from_stream(stream::iter([ + Ok(Bytes::from_static(b"only this much")), + Err(io::Error::other("connection dropped")), + ]))) + .map_err(|error| error.to_string()) + .into_response() + }), + ) + .route("/redirects", get(|| async { Redirect::temporary("/ok") })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let addr = listener.local_addr().expect("test server address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve test server"); + }); + addr +} + +fn source_at(addr: SocketAddr, path: &str) -> InstallerSource { + InstallerSource { + url: Url::parse(&format!("http://{addr}{path}")).expect("test url"), + file_name: FILE_NAME.to_string(), + extension: ".dmg", + } +} + +fn file_names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("read download dir") + .map(|entry| { + entry + .expect("dir entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + names +} + +/// Set in the copy of a test that [`rerun_with_open_umask`] starts. +#[cfg(unix)] +const OPEN_UMASK_ENV: &str = "BUZZ_TEST_INSTALLER_OPEN_UMASK"; + +/// Runs `test` of this module again in its own process under a umask of 000, +/// and fails if that run fails. A umask belongs to the whole process, so +/// changing it here would loosen the files other tests create meanwhile. +#[cfg(unix)] +fn rerun_with_open_umask(test: &str) { + // libtest names a test by its module path without the crate name. + let module = module_path!().split_once("::").map_or("", |(_, rest)| rest); + let output = std::process::Command::new("sh") + .args(["-c", r#"umask 000 && exec "$0" "$@""#]) + .arg(std::env::current_exe().expect("test binary")) + .args(["--exact", &format!("{module}::{test}"), "--test-threads=1"]) + .env(OPEN_UMASK_ENV, "1") + .output() + .expect("rerun the test under an open umask"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + // "1 passed" proves the name matched a test, not nothing. + output.status.success() && stdout.contains("1 passed"), + "{test} under umask 000:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +struct Run { + result: Result, + events: Vec, + marked: Vec, +} + +/// Runs one download with a plain client and a mark that records its target. +async fn run(addr: SocketAddr, path: &str, dir: &Path, cancel: &CancellationToken) -> Run { + let events = Mutex::new(Vec::new()); + let marked = Mutex::new(Vec::new()); + let result = download_installer( + &reqwest::Client::new(), + &source_at(addr, path), + dir, + test_limits(), + cancel, + |_| {}, + |partial, _| { + marked.lock().expect("marked").push(partial.to_path_buf()); + Ok(()) + }, + |event| events.lock().expect("events").push(event), + ) + .await; + Run { + result, + events: events.into_inner().expect("events"), + marked: marked.into_inner().expect("marked"), + } +} + +#[test] +fn accepts_the_installer_for_each_shipped_target() { + let cases = [ + ("macos-arm64", "Buzz_1.0.0_aarch64.dmg", ".dmg"), + ("macos-x64", "Buzz_1.0.0_x64.dmg", ".dmg"), + ("windows-x64", "Buzz_1.0.0_x64_unsigned.exe", ".exe"), + ("linux-appimage", "Buzz_1.0.0_x86_64.AppImage", ".AppImage"), + ("linux-deb", "Buzz_1.0.0_amd64.deb", ".deb"), + ]; + for (target, file_name, extension) in cases { + let url = format!("{RELEASE}/{file_name}"); + let source = validate_installer_url(&url, target).expect(target); + assert_eq!(source.url.as_str(), url, "{target}"); + assert_eq!(source.file_name, file_name, "{target}"); + assert_eq!(source.extension, extension, "{target}"); + } + // A tag may itself hold slashes; the file name is still the last segment. + let nested = validate_installer_url( + "https://github.com/block/buzz-app/releases/download/desktop/v1.0.0/Buzz.dmg", + "macos-arm64", + ) + .expect("nested tag"); + assert_eq!(nested.file_name, "Buzz.dmg"); + // Every character a tag may hold. + assert!(validate_installer_url( + "https://github.com/block/buzz-app/releases/download/desktop/v1.0.0-rc.1+build_2/Buzz.dmg", + "macos-arm64", + ) + .is_some()); +} + +#[test] +fn failure_reasons_reach_the_webview_under_their_kebab_names() { + let cases = [ + (InstallerFailure::Busy, "busy"), + (InstallerFailure::Rejected, "rejected"), + (InstallerFailure::Network, "network"), + (InstallerFailure::RedirectBlocked, "redirect-blocked"), + (InstallerFailure::TooLarge, "too-large"), + (InstallerFailure::Storage, "storage"), + (InstallerFailure::PermissionDenied, "permission-denied"), + (InstallerFailure::Protection, "protection"), + ]; + for (reason, name) in cases { + assert_eq!( + serde_json::to_value(reason).expect("serialize"), + serde_json::Value::from(name) + ); + } +} + +#[test] +fn rejects_every_url_that_is_not_this_machines_installer() { + let long_name = format!("{}.dmg", "a".repeat(MAX_FILE_NAME_LEN)); + let cases: Vec<(&str, String, &str)> = vec![ + ("not a url", "Buzz.dmg".into(), "macos-arm64"), + ( + "plain http", + format!("{RELEASE}/Buzz.dmg").replace("https:", "http:"), + "macos-arm64", + ), + ( + "another host", + "https://example.org/block/buzz-app/releases/download/v1/Buzz.dmg".into(), + "macos-arm64", + ), + ( + "host hidden behind userinfo", + "https://github.com@example.org/block/buzz-app/releases/download/v1/Buzz.dmg".into(), + "macos-arm64", + ), + ( + "credentials", + "https://user:pass@github.com/block/buzz-app/releases/download/v1/Buzz.dmg".into(), + "macos-arm64", + ), + ( + "non-default port", + "https://github.com:8443/block/buzz-app/releases/download/v1/Buzz.dmg".into(), + "macos-arm64", + ), + ( + "another repository", + "https://github.com/example-org/buzz-app/releases/download/v1/Buzz.dmg".into(), + "macos-arm64", + ), + ( + "dot segments that leave the repository", + format!("{RELEASE}/../../../../../example-org/other/releases/download/v1/Buzz.dmg"), + "macos-arm64", + ), + ( + "encoded dot segments that leave the repository", + "https://github.com/block/buzz-app/releases/download/%2e%2e/%2e%2e/%2e%2e/%2e%2e/example-org/other/Buzz.dmg".into(), + "macos-arm64", + ), + ("no tag segment", "https://github.com/block/buzz-app/releases/download/Buzz.dmg".into(), "macos-arm64"), + ("encoded separator in the tag", format!("{RELEASE}%2F..%2F..%2Fother/Buzz.dmg"), "macos-arm64"), + ("encoded backslash in the tag", format!("{RELEASE}%5Cother/Buzz.dmg"), "macos-arm64"), + ("encoded NUL in the tag", format!("{RELEASE}%00/Buzz.dmg"), "macos-arm64"), + ("semicolon in the tag", format!("{RELEASE};other/Buzz.dmg"), "macos-arm64"), + ("space in the tag", format!("{RELEASE}%20beta/Buzz.dmg"), "macos-arm64"), + ("at sign in the tag", format!("{RELEASE}@other/Buzz.dmg"), "macos-arm64"), + ( + "wrong file type for the target", + format!("{RELEASE}/Buzz_1.0.0_x64_unsigned.exe"), + "macos-arm64", + ), + ( + "extension only in the query", + format!("{RELEASE}/Buzz.exe?name=Buzz.dmg"), + "macos-arm64", + ), + ("query", format!("{RELEASE}/Buzz.dmg?download=1"), "macos-arm64"), + ("fragment", format!("{RELEASE}/Buzz.dmg#top"), "macos-arm64"), + ("extension with nothing before it", format!("{RELEASE}/.dmg"), "macos-arm64"), + ("hidden file", format!("{RELEASE}/.Buzz.dmg"), "macos-arm64"), + ("encoded separator in the name", format!("{RELEASE}/..%2FBuzz.dmg"), "macos-arm64"), + ("encoded backslash in the name", format!("{RELEASE}/..%5CBuzz.dmg"), "macos-arm64"), + ("space in the name", format!("{RELEASE}/Buzz%20App.dmg"), "macos-arm64"), + ("name too long", format!("{RELEASE}/{long_name}"), "macos-arm64"), + ("windows device name", format!("{RELEASE}/CON.exe"), "windows-x64"), + ("windows device name, any case", format!("{RELEASE}/com1.setup.exe"), "windows-x64"), + ("target with no installer", format!("{RELEASE}/Buzz.dmg"), "linux-arm64"), + ]; + for (name, url, target) in cases { + assert_eq!(validate_installer_url(&url, target), None, "{name}"); + } + // Near misses of the device-name rule are ordinary names. + for name in ["COM0.exe", "CONSOLE.exe", "LPT10.exe"] { + assert!( + validate_installer_url(&format!("{RELEASE}/{name}"), "windows-x64").is_some(), + "{name}" + ); + } +} + +#[test] +fn redirects_stay_on_https_release_asset_hosts() { + let cases = [ + ("https://github.com/block/buzz-app/releases/x", true), + ("https://objects.githubusercontent.com/asset", true), + ( + "https://release-assets.githubusercontent.com/asset?sig=1", + true, + ), + ("http://release-assets.githubusercontent.com/asset", false), + ( + "https://release-assets.githubusercontent.com:8443/asset", + false, + ), + ( + "https://user@release-assets.githubusercontent.com/asset", + false, + ), + ("https://evil.githubusercontent.com/asset", false), + ( + "https://release-assets.githubusercontent.com.example.org/asset", + false, + ), + ("https://example.org/asset", false), + ("https://127.0.0.1/asset", false), + ]; + for (url, expected) in cases { + let parsed = Url::parse(url).expect(url); + assert_eq!(redirect_allowed(&parsed), expected, "{url}"); + } +} + +#[tokio::test] +async fn installer_client_refuses_other_hosts_and_plain_http() { + let addr = serve().await; + + // The plain client follows this redirect, so the server side works. + let followed = reqwest::Client::new() + .get(format!("http://{addr}/redirects")) + .send() + .await + .expect("plain client follows the redirect"); + assert_eq!(followed.status(), StatusCode::OK); + + // The installer client's policy stops it: 127.0.0.1 is not an asset host. + let policy_only = installer_client_builder().build().expect("client"); + let error = policy_only + .get(format!("http://{addr}/redirects")) + .send() + .await + .expect_err("redirect to a host outside the allowlist"); + assert!(error.is_redirect(), "{error:?}"); + + // The production client does not speak plain http at all. + let production = build_installer_client().expect("production client"); + let error = production + .get(format!("http://{addr}/ok")) + .send() + .await + .expect_err("plain http"); + assert!(error.is_builder(), "{error:?}"); +} + +#[tokio::test] +async fn saves_the_whole_body_under_the_installers_name() { + let addr = serve().await; + let dir = tempfile::tempdir().expect("download dir"); + let partial_seen = Mutex::new(None); + let events = Mutex::new(Vec::new()); + + let saved = download_installer( + &reqwest::Client::new(), + &source_at(addr, "/ok"), + dir.path(), + test_limits(), + &CancellationToken::new(), + |_| {}, + |partial, origin| { + // The mark is applied to the complete partial file, before any + // file exists under the installer's name. + assert_eq!(std::fs::read(partial).expect("partial"), BODY); + assert_eq!(origin.path(), "/ok"); + assert!(!dir.path().join(FILE_NAME).exists()); + *partial_seen.lock().expect("partial") = Some(partial.to_path_buf()); + Ok(()) + }, + |event| events.lock().expect("events").push(event), + ) + .await + .expect("download"); + + assert_eq!(saved, dir.path().join(FILE_NAME)); + assert_eq!(std::fs::read(&saved).expect("saved"), BODY); + assert_eq!(file_names(dir.path()), [FILE_NAME]); + + let partial = partial_seen.into_inner().expect("partial").expect("marked"); + let partial_name = partial + .file_name() + .expect("partial name") + .to_string_lossy() + .into_owned(); + assert!( + partial_name.starts_with("Buzz_1.0.0_aarch64.dmg.") && partial_name.ends_with(".part"), + "{partial_name}" + ); + + let total = BODY.len() as u64; + let events = events.into_inner().expect("events"); + assert_eq!( + events.first(), + Some(&InstallerDownloadEvent::Progress { + received_bytes: 0, + total_bytes: Some(total), + }) + ); + assert_eq!( + events.last(), + Some(&InstallerDownloadEvent::Progress { + received_bytes: total, + total_bytes: Some(total), + }) + ); + + // The saved file is requested as 0644 and the umask applies on top. It is + // not the private mode of a temporary file. Whatever the umask, nobody but + // the owner can write it and nobody can execute it. + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mode_of = |path: &Path| { + std::fs::metadata(path) + .expect("metadata") + .permissions() + .mode() + & 0o777 + }; + let ordinary = dir.path().join("ordinary-new-file"); + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .open(&ordinary) + .expect("ordinary file"); + let mode = mode_of(&saved); + // A stricter umask is honoured: the same bits come off as for any file. + assert_eq!(mode, mode_of(&ordinary), "{mode:o}"); + assert_eq!(mode & 0o133, 0, "{mode:o}"); + if std::env::var_os(OPEN_UMASK_ENV).is_some() { + // No umask at all: the mode is exactly the one requested. + assert_eq!(mode, 0o644, "{mode:o}"); + } else { + // The default umask hides a looser request, so check without one. + rerun_with_open_umask("saves_the_whole_body_under_the_installers_name"); + } + } +} + +#[tokio::test] +async fn a_redirect_the_policy_refuses_is_its_own_failure() { + let addr = serve().await; + let dir = tempfile::tempdir().expect("download dir"); + + // 127.0.0.1 is not a release asset host, so the policy stops the redirect. + let result = download_installer( + &installer_client_builder().build().expect("client"), + &source_at(addr, "/redirects"), + dir.path(), + test_limits(), + &CancellationToken::new(), + |_| {}, + |_, _| Ok(()), + |_| {}, + ) + .await; + + assert_eq!( + result, + Err(TransferError::Failed(InstallerFailure::RedirectBlocked)) + ); + assert_eq!(file_names(dir.path()), Vec::::new()); +} + +#[tokio::test] +async fn a_folder_that_cannot_be_written_names_its_cause() { + let addr = serve().await; + let dir = tempfile::tempdir().expect("parent dir"); + + // Something that is not a folder sits where the folder should be. + let not_a_folder = dir.path().join("Downloads"); + std::fs::write(¬_a_folder, b"a file").expect("file"); + let run_in = run(addr, "/ok", ¬_a_folder, &CancellationToken::new()).await; + assert_eq!( + run_in.result, + Err(TransferError::Failed(InstallerFailure::Storage)) + ); + + // The system refuses the write: the folder is read-only for this user. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let locked = dir.path().join("locked"); + std::fs::create_dir(&locked).expect("locked dir"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)) + .expect("lock dir"); + // A superuser ignores the mode, and then there is nothing to observe. + if std::fs::write(locked.join("probe"), b"").is_err() { + let run_in = run(addr, "/ok", &locked, &CancellationToken::new()).await; + assert_eq!( + run_in.result, + Err(TransferError::Failed(InstallerFailure::PermissionDenied)) + ); + assert_eq!(file_names(&locked), Vec::::new()); + } + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)) + .expect("unlock dir"); + } +} + +#[test] +fn the_download_folder_falls_back_to_home_downloads_on_linux_only() { + let configured = Some(PathBuf::from("/home/sam/Transfers")); + let home = Some(PathBuf::from("/home/sam")); + // (configured, home, is_linux, expected) + let cases = [ + (configured.clone(), home.clone(), true, configured.clone()), + (configured.clone(), None, false, configured.clone()), + ( + None, + home.clone(), + true, + Some(PathBuf::from("/home/sam/Downloads")), + ), + (None, home.clone(), false, None), + (None, None, true, None), + ]; + for (configured, home, is_linux, expected) in cases { + assert_eq!( + resolve_download_dir(configured.clone(), home.clone(), is_linux), + expected, + "{configured:?} {home:?} {is_linux}" + ); + } +} + +#[tokio::test] +async fn never_overwrites_an_existing_file() { + let addr = serve().await; + let dir = tempfile::tempdir().expect("download dir"); + std::fs::write(dir.path().join(FILE_NAME), b"mine").expect("existing"); + std::fs::write(dir.path().join("Buzz_1.0.0_aarch64 (1).dmg"), b"also mine").expect("existing"); + + let run = run(addr, "/ok", dir.path(), &CancellationToken::new()).await; + + let saved = run.result.expect("download"); + assert_eq!(saved, dir.path().join("Buzz_1.0.0_aarch64 (2).dmg")); + assert_eq!(std::fs::read(&saved).expect("saved"), BODY); + assert_eq!( + std::fs::read(dir.path().join(FILE_NAME)).expect("first"), + b"mine" + ); + assert_eq!( + std::fs::read(dir.path().join("Buzz_1.0.0_aarch64 (1).dmg")).expect("second"), + b"also mine" + ); + assert_eq!(file_names(dir.path()).len(), 3); +} + +#[test] +fn candidate_names_count_up_before_the_extension() { + assert_eq!(candidate_file_name("Buzz.dmg", ".dmg", 0), "Buzz.dmg"); + assert_eq!(candidate_file_name("Buzz.dmg", ".dmg", 1), "Buzz (1).dmg"); + assert_eq!( + candidate_file_name("Buzz_1.0.0_x86_64.AppImage", ".AppImage", 12), + "Buzz_1.0.0_x86_64 (12).AppImage" + ); +} + +#[tokio::test] +async fn failed_transfers_leave_nothing_in_the_folder() { + let addr = serve().await; + let cases = [ + ("/missing", InstallerFailure::Network), + ("/empty", InstallerFailure::Network), + ("/cut-short", InstallerFailure::Network), + ("/stalls", InstallerFailure::Network), + ("/announced-too-large", InstallerFailure::TooLarge), + ("/unsized-too-large", InstallerFailure::TooLarge), + ]; + for (path, reason) in cases { + let dir = tempfile::tempdir().expect("download dir"); + let run = run(addr, path, dir.path(), &CancellationToken::new()).await; + assert_eq!(run.result, Err(TransferError::Failed(reason)), "{path}"); + assert_eq!(file_names(dir.path()), Vec::::new(), "{path}"); + assert_eq!(run.marked, Vec::::new(), "{path}"); + } +} + +#[tokio::test] +async fn a_body_without_a_length_is_saved_with_no_total() { + let addr = serve().await; + let dir = tempfile::tempdir().expect("download dir"); + + let run = run(addr, "/unsized-ok", dir.path(), &CancellationToken::new()).await; + + let saved = run.result.expect("download"); + assert_eq!(std::fs::read(saved).expect("saved").len(), 300); + assert_eq!( + run.events.first(), + Some(&InstallerDownloadEvent::Progress { + received_bytes: 0, + total_bytes: None, + }) + ); +} + +#[tokio::test] +async fn a_file_that_cannot_be_marked_is_not_kept() { + let addr = serve().await; + let dir = tempfile::tempdir().expect("download dir"); + + let result = download_installer( + &reqwest::Client::new(), + &source_at(addr, "/ok"), + dir.path(), + test_limits(), + &CancellationToken::new(), + |_| {}, + |_, _| Err(io::Error::other("volume has no extended attributes")), + |_| {}, + ) + .await; + + assert_eq!( + result, + Err(TransferError::Failed(InstallerFailure::Protection)) + ); + assert_eq!(file_names(dir.path()), Vec::::new()); +} + +#[tokio::test] +async fn cancelling_mid_transfer_removes_the_partial_file() { + let addr = serve().await; + let dir = tempfile::tempdir().expect("download dir"); + let cancel = CancellationToken::new(); + let partial_seen = Mutex::new(None); + let limits = TransferLimits { + stall_timeout: Duration::from_secs(30), + total_timeout: Duration::from_secs(30), + ..test_limits() + }; + let source = source_at(addr, "/stalls"); + let client = reqwest::Client::new(); + + let download = download_installer( + &client, + &source, + dir.path(), + limits, + &cancel, + |partial| *partial_seen.lock().expect("partial") = Some(partial.to_path_buf()), + |_, _| Ok(()), + |_| {}, + ); + // Cancel once the first body bytes are on disk under the partial name. + let cancel_mid_transfer = async { + loop { + let partial = partial_seen.lock().expect("partial").clone(); + let written = partial + .and_then(|partial| std::fs::metadata(partial).ok()) + .map_or(0, |metadata| metadata.len()); + if written > 0 { + assert_eq!(file_names(dir.path()).len(), 1, "only the partial file"); + cancel.cancel(); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + let (result, ()) = tokio::join!(download, cancel_mid_transfer); + + assert_eq!(result, Err(TransferError::Cancelled)); + assert_eq!(file_names(dir.path()), Vec::::new()); +} + +#[tokio::test] +async fn a_cancel_that_lands_before_the_move_leaves_no_installer() { + let addr = serve().await; + let dir = tempfile::tempdir().expect("download dir"); + let cancel = CancellationToken::new(); + + let result = download_installer( + &reqwest::Client::new(), + &source_at(addr, "/ok"), + dir.path(), + test_limits(), + &cancel, + |_| {}, + |_, _| { + cancel.cancel(); + Ok(()) + }, + |_| {}, + ) + .await; + + assert_eq!(result, Err(TransferError::Cancelled)); + assert_eq!(file_names(dir.path()), Vec::::new()); +} + +#[test] +fn only_one_download_holds_the_slot() { + let downloads = InstallerDownloads::default(); + let first = downloads.begin().expect("first download"); + assert!( + downloads.begin().is_none(), + "second download while one runs" + ); + + // Cancel reaches the running download's token. + assert!(!first.cancel.is_cancelled()); + downloads.cancel(); + assert!(first.cancel.is_cancelled()); + + drop(first); + let next = downloads.begin().expect("slot is free again"); + assert!( + !next.cancel.is_cancelled(), + "a new download starts uncancelled" + ); +} + +#[test] +fn shutdown_cancels_and_removes_the_partial_file() { + let dir = tempfile::tempdir().expect("download dir"); + let partial = dir.path().join("Buzz.dmg.abc123.part"); + std::fs::write(&partial, b"half").expect("partial"); + let downloads = InstallerDownloads::default(); + + // Nothing running: nothing to do. + downloads.abort_for_shutdown(); + assert!(partial.exists()); + + let record = dir.path().join("app-data").join("partial-record.txt"); + downloads.recover(record.clone(), Some(dir.path())); + let active = downloads.begin().expect("download"); + downloads.set_partial(&partial); + assert!(record.exists()); + downloads.abort_for_shutdown(); + assert!(active.cancel.is_cancelled()); + assert!(!partial.exists()); + // Nothing is left for the next launch to remove. + assert!(!record.exists()); + // The download task cleaning up afterwards finds nothing, and that is fine. + downloads.abort_for_shutdown(); +} + +#[test] +fn the_next_launch_removes_exactly_the_partial_a_dead_run_recorded() { + let dir = tempfile::tempdir().expect("download dir"); + let record = dir.path().join("app-data").join("partial-record.txt"); + let downloads_dir = dir.path().join("Downloads"); + std::fs::create_dir(&downloads_dir).expect("download dir"); + let partial_name = "Buzz_1.0.0_aarch64.dmg.aB3xYz.part"; + let partial = downloads_dir.join(partial_name); + // Files the cleanup has no business touching, however similar they look. + let bystanders = [ + "Buzz_1.0.0_aarch64.dmg", + "Buzz_1.0.0_aarch64.dmg.zzzzzz.part", + "film.mkv.part", + ]; + std::fs::write(&partial, b"half").expect("partial"); + for name in bystanders { + std::fs::write(downloads_dir.join(name), b"mine").expect(name); + } + + // A download that ends normally leaves no record behind. While it runs + // the record holds the partial file's name and no folder. + let downloads = InstallerDownloads::default(); + downloads.recover(record.clone(), Some(&downloads_dir)); + let active = downloads.begin().expect("download"); + downloads.set_partial(&partial); + assert_eq!( + std::fs::read_to_string(&record).expect("record"), + partial_name + ); + std::fs::remove_file(&partial).expect("the download removes its partial"); + drop(active); + assert!(!record.exists()); + std::fs::write(&partial, b"half").expect("partial"); + + // A run that dies mid-download does: none of its cleanup runs. + let active = downloads.begin().expect("download"); + downloads.set_partial(&partial); + std::mem::forget(active); + assert!(record.exists()); + + // Without a Downloads folder to look in, the record waits. + remove_recorded_partial(&record, None); + assert!(record.exists()); + assert!(partial.exists()); + + // The next launch removes that file, forgets it, and touches nothing else. + InstallerDownloads::default().recover(record.clone(), Some(&downloads_dir)); + assert!(!record.exists()); + assert_eq!(file_names(&downloads_dir), bystanders); + + // A partial file of exactly this module's shape, in a folder that is not + // the Downloads folder. No record can reach it. + let elsewhere = dir.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).expect("elsewhere"); + let outside = elsewhere.join("backup.deb.old1.part"); + std::fs::write(&outside, b"not ours").expect("outside"); + #[cfg(unix)] + std::os::unix::fs::symlink( + downloads_dir.join("Buzz_1.0.0_aarch64.dmg"), + downloads_dir.join("Buzz_1.0.0_aarch64.dmg.linked.part"), + ) + .expect("symlink"); + std::fs::create_dir(downloads_dir.join("Buzz_1.0.0_aarch64.dmg.folder.part")).expect("folder"); + // A record that names anything but a partial file of this module's shape + // directly inside the Downloads folder is dropped, and nothing is removed. + let not_ours = [ + // An edited record that holds a path instead of a name. + outside.to_str().expect("utf-8 path").to_string(), + "../elsewhere/backup.deb.old1.part".to_string(), + partial.to_str().expect("utf-8 path").to_string(), + // The right shape, but only the other folder has such a file. + "backup.deb.old1.part".to_string(), + // Names in the Downloads folder that are not partial files. + "Buzz_1.0.0_aarch64.dmg".to_string(), + "film.mkv.part".to_string(), + // The right shape, but a link and a folder, not a regular file. + "Buzz_1.0.0_aarch64.dmg.linked.part".to_string(), + "Buzz_1.0.0_aarch64.dmg.folder.part".to_string(), + String::new(), + ]; + std::fs::write(&partial, b"half").expect("partial"); + let before = (file_names(&downloads_dir), file_names(&elsewhere)); + for recorded in not_ours { + std::fs::write(&record, &recorded).expect("record"); + remove_recorded_partial(&record, Some(&downloads_dir)); + assert!(!record.exists(), "{recorded}"); + assert_eq!( + (file_names(&downloads_dir), file_names(&elsewhere)), + before, + "{recorded}" + ); + } + // Neither is a record that is not text at all. + std::fs::write(&record, [0xff, 0xfe, 0x00]).expect("record"); + remove_recorded_partial(&record, Some(&downloads_dir)); + assert!(!record.exists()); + assert_eq!(file_names(&downloads_dir), before.0); +} + +#[test] +fn a_partial_that_cannot_be_removed_stays_recorded() { + let dir = tempfile::tempdir().expect("download dir"); + let record = dir.path().join("app-data").join("partial-record.txt"); + let downloads_dir = dir.path().join("Downloads"); + std::fs::create_dir(&downloads_dir).expect("download dir"); + let partial_name = "Buzz_1.0.0_aarch64.dmg.aB3xYz.part"; + let partial = downloads_dir.join(partial_name); + let recorded = || std::fs::read_to_string(&record).ok(); + + // A folder stands in for a file the system refuses to remove, such as one + // a scanner holds open on Windows: removing it as a file fails everywhere. + std::fs::create_dir(&partial).expect("stand-in"); + let downloads = InstallerDownloads::default(); + downloads.recover(record.clone(), Some(&downloads_dir)); + + // A download ends and its own removal of the partial file fails, which + // nothing reports. Freeing the slot must not forget the file. + let active = downloads.begin().expect("download"); + downloads.set_partial(&partial); + drop(active); + assert_eq!(recorded().as_deref(), Some(partial_name)); + + // The same at exit: the removal fails there, and again when the download + // task frees its slot afterwards. + let active = downloads.begin().expect("download"); + downloads.set_partial(&partial); + downloads.abort_for_shutdown(); + assert!(active.cancel.is_cancelled()); + assert_eq!(recorded().as_deref(), Some(partial_name)); + drop(active); + assert_eq!(recorded().as_deref(), Some(partial_name)); + + // The next launch finds the file by that record and removes it. + std::fs::remove_dir(&partial).expect("stand-in"); + std::fs::write(&partial, b"half").expect("partial"); + InstallerDownloads::default().recover(record.clone(), Some(&downloads_dir)); + assert!(!partial.exists()); + assert_eq!(recorded(), None); +} + +#[test] +fn progress_is_reported_at_most_once_per_interval() { + let start = Instant::now(); + let mut throttle = ProgressThrottle::default(); + let total = Some(1000); + + assert!(throttle.admit(0, total, start), "first report"); + assert!(!throttle.admit(10, total, start + Duration::from_millis(10))); + assert!(!throttle.admit(20, total, start + Duration::from_millis(99))); + assert!(throttle.admit(30, total, start + Duration::from_millis(100))); + assert!(!throttle.admit(40, total, start + Duration::from_millis(150))); + // The report that completes the total always goes through. + assert!(throttle.admit(1000, total, start + Duration::from_millis(151))); + // Without a total there is no such exception. + assert!(!throttle.admit(1000, None, start + Duration::from_millis(152))); +} + +#[test] +fn quarantine_value_matches_a_browser_download() { + assert_eq!(quarantine_value(0x6916_0a2b), "0081;69160a2b;Buzz;"); + assert_eq!(quarantine_value(1), "0081;00000001;Buzz;"); +} + +#[test] +fn mark_of_the_web_names_the_internet_zone_and_the_origin() { + let origin = Url::parse(&format!("{RELEASE}/Buzz_1.0.0_x64_unsigned.exe")).expect("origin"); + assert_eq!( + zone_identifier_contents(&origin), + format!("[ZoneTransfer]\r\nZoneId=3\r\nHostUrl={RELEASE}/Buzz_1.0.0_x64_unsigned.exe\r\n") + ); + assert_eq!( + zone_identifier_path(Path::new("Downloads/Buzz.exe.abc.part")), + Path::new("Downloads/Buzz.exe.abc.part:Zone.Identifier") + ); +} + +#[test] +fn mark_of_the_web_write_is_verified_by_reading_it_back() { + let dir = tempfile::tempdir().expect("dir"); + let origin = Url::parse(&format!("{RELEASE}/Buzz.exe")).expect("origin"); + + // Off Windows the stream path is an ordinary sibling file, which is enough + // to exercise the write and the read-back. + let file = dir.path().join("Buzz.exe.part"); + std::fs::write(&file, b"installer").expect("file"); + write_zone_identifier(&file, &origin).expect("mark"); + assert_eq!( + std::fs::read_to_string(zone_identifier_path(&file)).expect("stream"), + zone_identifier_contents(&origin) + ); + + // A stream that cannot be created fails the mark. + let missing = dir.path().join("no-such-dir").join("Buzz.exe.part"); + assert!(write_zone_identifier(&missing, &origin).is_err()); +} + +// Listed on every platform, so a runner that selects this file's tests by +// name finds it; it runs where the quarantine attribute exists. +#[test] +#[cfg_attr( + not(target_os = "macos"), + ignore = "the quarantine attribute exists only on macOS" +)] +fn macos_download_is_quarantined() { + #[cfg(target_os = "macos")] + macos_download_is_quarantined_body(); +} + +#[cfg(target_os = "macos")] +fn macos_download_is_quarantined_body() { + let dir = tempfile::tempdir().expect("dir"); + let file = dir.path().join("Buzz.dmg.part"); + std::fs::write(&file, b"installer").expect("file"); + let origin = Url::parse(&format!("{RELEASE}/Buzz.dmg")).expect("origin"); + + mark_as_downloaded(&file, &origin).expect("mark"); + + let value = xattr::get(&file, QUARANTINE_XATTR) + .expect("read quarantine") + .expect("quarantine attribute is set"); + let value = String::from_utf8(value).expect("utf8"); + let fields: Vec<&str> = value.split(';').collect(); + assert_eq!(fields.len(), 4, "{value}"); + assert_eq!(fields[0], "0081"); + assert!(u64::from_str_radix(fields[1], 16).expect("hex timestamp") > 0x6000_0000); + assert_eq!(fields[2], "Buzz"); + + // The attribute follows the file through the move into place. + let moved = dir.path().join("Buzz.dmg"); + std::fs::rename(&file, &moved).expect("rename"); + assert!(xattr::get(&moved, QUARANTINE_XATTR) + .expect("read") + .is_some()); + + // A file that is gone cannot be marked, and that is an error. + assert!(mark_as_downloaded(&file, &origin).is_err()); +} diff --git a/desktop/src-tauri/src/commands/updater.rs b/desktop/src-tauri/src/commands/updater.rs index b2c60a2e08..42e55bc621 100644 --- a/desktop/src-tauri/src/commands/updater.rs +++ b/desktop/src-tauri/src/commands/updater.rs @@ -22,3 +22,69 @@ pub fn is_auto_update_supported() -> bool { true } } + +/// Maps a platform to its key in the new Buzz app's installer manifest, or +/// `None` when no installer is published for it. +/// +/// On Linux a missing `APPIMAGE` variable only means "not an AppImage", so +/// the deb key also requires dpkg; a source build on another distro gets no +/// key instead of a package it cannot install. +fn installer_target_key( + os: &str, + arch: &str, + is_appimage: bool, + has_dpkg: bool, +) -> Option<&'static str> { + match (os, arch) { + ("macos", "aarch64") => Some("macos-arm64"), + ("macos", "x86_64") => Some("macos-x64"), + ("windows", "x86_64") => Some("windows-x64"), + ("linux", "x86_64") if is_appimage => Some("linux-appimage"), + ("linux", "x86_64") if has_dpkg => Some("linux-deb"), + _ => None, + } +} + +/// Returns the running install's key in the new Buzz app's installer +/// manifest, or `None` when the frontend should link to the website instead. +#[tauri::command] +pub fn new_app_installer_target() -> Option<&'static str> { + installer_target_key( + std::env::consts::OS, + std::env::consts::ARCH, + std::env::var_os("APPIMAGE").is_some(), + std::path::Path::new("/usr/bin/dpkg").exists(), + ) +} + +#[cfg(test)] +mod tests { + use super::installer_target_key; + + #[test] + fn installer_target_key_covers_each_shipped_target_and_nothing_else() { + // (os, arch, is_appimage, has_dpkg, expected) + let cases = [ + ("macos", "aarch64", false, false, Some("macos-arm64")), + ("macos", "x86_64", false, false, Some("macos-x64")), + ("windows", "x86_64", false, false, Some("windows-x64")), + ("linux", "x86_64", true, false, Some("linux-appimage")), + // An AppImage on a Debian host is still an AppImage. + ("linux", "x86_64", true, true, Some("linux-appimage")), + ("linux", "x86_64", false, true, Some("linux-deb")), + // Source build on a distro without dpkg. + ("linux", "x86_64", false, false, None), + // No arm64 Linux or Windows installer is published. + ("linux", "aarch64", true, true, None), + ("windows", "aarch64", false, false, None), + ("freebsd", "x86_64", false, true, None), + ]; + for (os, arch, is_appimage, has_dpkg, expected) in cases { + assert_eq!( + installer_target_key(os, arch, is_appimage, has_dpkg), + expected, + "{os}/{arch} appimage={is_appimage} dpkg={has_dpkg}" + ); + } + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 133cd2a906..7e491e297d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -225,6 +225,7 @@ pub fn run() { }) .manage(build_app_state()) .manage(ClipboardState::new()) + .manage(InstallerDownloads::default()) .manage(PendingCommunityDeepLinks::default()) .manage(PendingNavigationDeepLinks::default()) .manage(PendingEntityDeepLinks::default()) @@ -238,6 +239,7 @@ pub fn run() { .manage(channel_head_cache::ChannelHeadCacheStore::default()) .setup(move |app| { let app_handle = app.handle().clone(); + recover_new_app_installer_downloads(&app_handle); #[cfg(target_os = "macos")] { tray_menu::init(&app_handle)?; @@ -874,6 +876,10 @@ pub fn run() { archive::sync::start_archive_sync, archive::sync::stop_archive_sync, is_auto_update_supported, + new_app_installer_target, + download_new_app_installer, + cancel_new_app_installer_download, + reveal_new_app_installer, set_window_vibrancy, #[cfg(target_os = "macos")] tray_menu::clear_tray_agent_activity, @@ -929,6 +935,7 @@ pub fn run() { .. } if label == "main" => { // Keep the webview alive so Buzz can be reopened from its tray menu. + // An installer download keeps running; its dialog is there on reopen. api.prevent_close(); if let Some(window) = app_handle.get_webview_window("main") { if let Err(error) = window.hide() { @@ -959,6 +966,12 @@ pub fn run() { } } } + // The main webview is gone, and with it the installer download's dialog. + RunEvent::WindowEvent { + label, + event: WindowEvent::Destroyed, + .. + } if label == "main" => app_handle.state::().cancel(), RunEvent::ExitRequested { code, .. } => { if is_restart_request(code) { restart_requested.store(true, Ordering::SeqCst); diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index b1548c6937..9a0e7386ee 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -21,6 +21,9 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a prevent_sleep::release(&app.state::().prevent_sleep); crate::observed_unread::flush(app); crate::channel_head_cache::flush(app); + if let Some(downloads) = app.try_state::() { + downloads.abort_for_shutdown(); + } app.state::() .shutdown_all(); if let Err(error) = shutdown_managed_agents(app) { @@ -44,6 +47,10 @@ pub(crate) fn install_signal_handler( .shutdown_started .store(true, Ordering::SeqCst); if !shutdown_done.swap(true, Ordering::SeqCst) { + // This path ends in `exit`, so the download never cleans up itself. + if let Some(downloads) = app.try_state::() { + downloads.abort_for_shutdown(); + } app.state::() .shutdown_all(); let _ = shutdown_managed_agents(&app); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 97f3767647..d6d7922bef 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -9,6 +9,7 @@ import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays"; import { AppShellChannelSurface } from "@/app/AppShellChannelSurface"; import { AppHuddleShell } from "@/app/AppHuddleShell"; import { AppTopChrome } from "@/app/AppTopChrome"; +import { DeprecationBanner } from "@/app/DeprecationBanner"; import { type TerminalContextOverride, TerminalContextOverrideProvider, @@ -785,6 +786,7 @@ export function AppShell() { onGoForward={goForward} /> ) : null} + {!isHuddleRoom ? : null} {settingsOpen ? (
diff --git a/desktop/src/app/DeprecationBanner.tsx b/desktop/src/app/DeprecationBanner.tsx new file mode 100644 index 0000000000..8b3b0cd503 --- /dev/null +++ b/desktop/src/app/DeprecationBanner.tsx @@ -0,0 +1,154 @@ +import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Sparkles, X } from "lucide-react"; + +import { + DEPRECATION_BANNER_STORAGE_KEY, + shouldShowDeprecationBanner, +} from "@/app/deprecationBannerVisibility"; +import { NewAppDownloadButton } from "@/app/NewAppDownloadButton"; +import { + BUZZ_APP_DOWNLOADS_MANIFEST_URL, + NEW_APP_MANIFEST_TIMEOUT_MS, + resolveInstallerUrl, +} from "@/app/newAppInstaller"; +import { getNewAppInstallerTarget } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; + +const BUZZ_APP_UPGRADE_GUIDE_URL = + "https://github.com/block/buzz-app/blob/main/docs/upgrading.md"; + +// Both banner actions share one look: text only, no icon. +const BANNER_ACTION_CLASS = "h-7 shrink-0"; + +function readDismissedAt(): string | null { + try { + return window.localStorage.getItem(DEPRECATION_BANNER_STORAGE_KEY); + } catch { + return null; + } +} + +/** + * Looks up the new app's installer for this machine. Rejects on any failure; + * resolves null when no installer is published for this platform. + */ +async function fetchInstallerUrl(signal: AbortSignal): Promise { + const target = await getNewAppInstallerTarget(); + if (!target) { + return null; + } + const response = await fetch(BUZZ_APP_DOWNLOADS_MANIFEST_URL, { signal }); + if (!response.ok) { + throw new Error(`installer manifest responded ${response.status}`); + } + const url = resolveInstallerUrl(await response.json(), target); + if (!url) { + throw new Error(`installer manifest has no usable entry for ${target}`); + } + return url; +} + +export function DeprecationBanner() { + const [isVisible, setIsVisible] = React.useState(() => + shouldShowDeprecationBanner(readDismissedAt(), Date.now()), + ); + const [installerUrl, setInstallerUrl] = React.useState(null); + + // One attempt per mount. The click never waits on it: until it resolves, + // and whenever it fails, the button opens the website instead. + React.useEffect(() => { + if (!isVisible) { + return; + } + let isCurrent = true; + const controller = new AbortController(); + const timeout = window.setTimeout( + () => controller.abort(new Error("installer manifest timed out")), + NEW_APP_MANIFEST_TIMEOUT_MS, + ); + fetchInstallerUrl(controller.signal) + .then((url) => { + if (isCurrent) { + setInstallerUrl(url); + } + }) + .catch((error: unknown) => { + if (isCurrent) { + console.warn("Could not resolve the new app installer:", error); + } + }) + .finally(() => window.clearTimeout(timeout)); + return () => { + isCurrent = false; + window.clearTimeout(timeout); + controller.abort(); + }; + }, [isVisible]); + + if (!isVisible) { + return null; + } + + const handleDismiss = () => { + try { + window.localStorage.setItem( + DEPRECATION_BANNER_STORAGE_KEY, + String(Date.now()), + ); + } catch { + // Storage unavailable: hide for this session only. + } + setIsVisible(false); + }; + + return ( +
+ + {/* The actions wrap under the message when the row gets too narrow. */} +
+

+ A new Buzz app is here! This version is being retired, so grab the new + one whenever you get a chance. +

+
+ + +
+
+ +
+ ); +} diff --git a/desktop/src/app/NewAppDownloadButton.tsx b/desktop/src/app/NewAppDownloadButton.tsx new file mode 100644 index 0000000000..ed242ea292 --- /dev/null +++ b/desktop/src/app/NewAppDownloadButton.tsx @@ -0,0 +1,58 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; + +import { BUZZ_APP_WEBSITE_URL } from "@/app/newAppInstaller"; +import { ignoreRepeatedEnter } from "@/app/newAppInstallerDownload"; +import { + newAppInstallerTriggerRef, + startNewAppInstallerDownload, +} from "@/app/useNewAppInstallerDownload"; +import { Button } from "@/shared/ui/button"; + +type NewAppDownloadButtonProps = { + className?: string; + /** This machine's installer, or null while it is unknown or unavailable. */ + installerUrl: string | null; +}; + +/** + * The banner's "Upgrade" button. With an installer URL it starts the download + * inside the app, which `NewAppInstallerDownloadHost` shows in a dialog; + * without one it opens the website in the browser. The click never waits on + * anything. + */ +export function NewAppDownloadButton({ + className, + installerUrl, +}: NewAppDownloadButtonProps) { + const downloadsInApp = installerUrl !== null; + + return ( + + ); +} diff --git a/desktop/src/app/NewAppInstallerDialog.tsx b/desktop/src/app/NewAppInstallerDialog.tsx new file mode 100644 index 0000000000..6e9cee44c4 --- /dev/null +++ b/desktop/src/app/NewAppInstallerDialog.tsx @@ -0,0 +1,269 @@ +import * as React from "react"; + +import { + dialogDescription, + dialogTitle, + downloadPercent, + downloadProgressText, + type FailureAction, + type FileManagerPlatform, + failureActions, + ignoreRepeatedEnter, + type InstallerDialogState, + liveAnnouncement, + REVEAL_FAILED_MESSAGE, + revealLabel, +} from "@/app/newAppInstallerDownload"; +import { isMacPlatform, isWindowsPlatform } from "@/shared/lib/platform"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Progress } from "@/shared/ui/progress"; + +function fileManagerPlatform(): FileManagerPlatform { + if (isMacPlatform()) { + return "macos"; + } + return isWindowsPlatform() ? "windows" : "linux"; +} + +type NewAppInstallerDialogProps = { + state: InstallerDialogState; + /** The control that opened the dialog; focus returns to it on close. */ + returnFocusRef: React.RefObject; + /** Stop the running download. The dialog closes once it has stopped. */ + onCancel: () => void; + onDismiss: () => void; + /** Hand the installer that failed to download to the browser. */ + onDownloadInBrowser: () => void; + onOpenWebsite: () => void; + onRetry: () => void; + onReveal: () => void; +}; + +const FAILURE_BUTTONS: Record< + FailureAction, + { label: string; ariaLabel?: string; testId: string } +> = { + retry: { label: "Try again", testId: "new-app-installer-retry" }, + browser: { + label: "Download in browser", + ariaLabel: "Download the installer in your browser", + testId: "new-app-installer-browser", + }, + website: { + label: "Open buzz.xyz", + ariaLabel: "Open buzz.xyz in your browser", + testId: "new-app-installer-website", + }, +}; + +/** + * Shows the in-app download of the new app's installer: progress while it + * runs, where the file is once it is saved, and what to do when it fails. + */ +export function NewAppInstallerDialog({ + state, + returnFocusRef, + onCancel, + onDismiss, + onDownloadInBrowser, + onOpenWebsite, + onRetry, + onReveal, +}: NewAppInstallerDialogProps) { + const platform = fileManagerPlatform(); + // Keep the last open state on screen while the dialog animates out. + const [shown, setShown] = React.useState(state); + if (state.status !== "closed" && state !== shown) { + setShown(state); + } + const isDownloading = state.status === "downloading"; + + // The focused button goes away whenever the status changes: Cancel when + // the download ends, "Try again" when it restarts. Move focus to the action + // that replaces it instead of letting it fall to the dialog. + const cancelRef = React.useRef(null); + const primaryActionRef = React.useRef(null); + const shownStatus = shown.status; + const focusCurrentAction = React.useCallback(() => { + const action = + shownStatus === "downloading" + ? cancelRef.current + : primaryActionRef.current; + action?.focus(); + return action !== null; + }, [shownStatus]); + React.useEffect(() => { + focusCurrentAction(); + }, [focusCurrentAction]); + + const failure = + shown.status === "failed" ? failureActions(shown.reason) : null; + const failureHandlers: Record void> = { + retry: onRetry, + browser: onDownloadInBrowser, + website: onOpenWebsite, + }; + const failureButton = (action: FailureAction, isPrimary: boolean) => ( + + ); + + return ( + { + if (open) { + return; + } + // Escape and the close button cancel a running download. + if (isDownloading) { + onCancel(); + } else { + onDismiss(); + } + }} + open={state.status !== "closed"} + > + { + // Without a DialogTrigger the dialog would drop focus on the page. + event.preventDefault(); + returnFocusRef.current?.focus(); + }} + // Enter still held from the press that started the download repeats + // onto the button focus has just moved to. It must not press it. + onKeyDown={ignoreRepeatedEnter} + onOpenAutoFocus={(event) => { + if (focusCurrentAction()) { + event.preventDefault(); + } + }} + onInteractOutside={(event) => { + // A stray click beside the dialog must not cancel the download. + if (isDownloading) { + event.preventDefault(); + } + }} + > + + {dialogTitle(shown)} + + {dialogDescription(shown, platform)} + + + + {shown.status === "downloading" ? ( +
+ +

+ {downloadProgressText(shown.receivedBytes, shown.totalBytes)} +

+
+ ) : null} + + {shown.status === "finished" ? ( +
+

+ {shown.folder} +

+ {shown.revealFailed ? ( +

+ {REVEAL_FAILED_MESSAGE} +

+ ) : null} +
+ ) : null} + + {/* The only live region: it changes at milestones, not on every tick. */} +

+ {liveAnnouncement(shown, platform)} +

+ + + {shown.status === "downloading" ? ( + + ) : null} + {shown.status === "finished" ? ( + <> + + + + ) : null} + {failure ? ( + <> + {failure.secondary + ? failureButton(failure.secondary, false) + : null} + {failureButton(failure.primary, true)} + + ) : null} + +
+
+ ); +} diff --git a/desktop/src/app/NewAppInstallerDownloadHost.tsx b/desktop/src/app/NewAppInstallerDownloadHost.tsx new file mode 100644 index 0000000000..2ba33f596f --- /dev/null +++ b/desktop/src/app/NewAppInstallerDownloadHost.tsx @@ -0,0 +1,51 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; + +import { NewAppInstallerDialog } from "@/app/NewAppInstallerDialog"; +import { BUZZ_APP_WEBSITE_URL } from "@/app/newAppInstaller"; +import { + newAppInstallerTriggerRef, + useNewAppInstallerDownload, +} from "@/app/useNewAppInstallerDownload"; + +/** + * Shows the in-app download of the new app's installer in its dialog. + * + * Mounted once for the main window, outside everything that unmounts with + * the community, the identity or onboarding. The banner that starts a + * download can go away while it runs: on sign-out, when a replaced identity + * lands on onboarding, or when a community switch ends on the + * connection-failed screen. The dialog and its Cancel button stay, so no + * download ever runs without them. + */ +export function NewAppInstallerDownloadHost() { + const { state, start, cancel, dismiss, reveal } = + useNewAppInstallerDownload(); + + return ( + { + // The URL the download was started with, which the resolver and + // the backend both accepted. The browser downloads it its own way. + if (state.status === "failed") { + void openUrl(state.url); + } + dismiss(); + }} + onOpenWebsite={() => { + void openUrl(BUZZ_APP_WEBSITE_URL); + dismiss(); + }} + onRetry={() => { + // From the state: the banner that resolved the URL may be gone. + if (state.status === "failed") { + start(state.url); + } + }} + onReveal={reveal} + returnFocusRef={newAppInstallerTriggerRef} + state={state} + /> + ); +} diff --git a/desktop/src/app/deprecationBannerVisibility.test.mjs b/desktop/src/app/deprecationBannerVisibility.test.mjs new file mode 100644 index 0000000000..ee8ea3db73 --- /dev/null +++ b/desktop/src/app/deprecationBannerVisibility.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEPRECATION_BANNER_SNOOZE_MS, + shouldShowDeprecationBanner, +} from "./deprecationBannerVisibility.ts"; + +const NOW = 1_800_000_000_000; + +test("shows the banner when it was never dismissed", () => { + assert.equal(shouldShowDeprecationBanner(null, NOW), true); +}); + +test("shows the banner when the stored value is unreadable", () => { + assert.equal(shouldShowDeprecationBanner("garbage", NOW), true); +}); + +test("hides the banner within a day of dismissal", () => { + const dismissedAt = String(NOW - DEPRECATION_BANNER_SNOOZE_MS + 1); + assert.equal(shouldShowDeprecationBanner(dismissedAt, NOW), false); +}); + +test("shows the banner again once a day has passed", () => { + const dismissedAt = String(NOW - DEPRECATION_BANNER_SNOOZE_MS); + assert.equal(shouldShowDeprecationBanner(dismissedAt, NOW), true); +}); diff --git a/desktop/src/app/deprecationBannerVisibility.ts b/desktop/src/app/deprecationBannerVisibility.ts new file mode 100644 index 0000000000..f343bd7725 --- /dev/null +++ b/desktop/src/app/deprecationBannerVisibility.ts @@ -0,0 +1,15 @@ +export const DEPRECATION_BANNER_STORAGE_KEY = + "buzz.deprecationBanner.dismissedAt"; +export const DEPRECATION_BANNER_SNOOZE_MS = 24 * 60 * 60 * 1000; + +/** Shown unless dismissed less than a day ago. Evaluated once per mount. */ +export function shouldShowDeprecationBanner( + dismissedAt: string | null, + now: number, +): boolean { + const dismissedAtMs = Number(dismissedAt); + if (!dismissedAt || !Number.isFinite(dismissedAtMs)) { + return true; + } + return now - dismissedAtMs >= DEPRECATION_BANNER_SNOOZE_MS; +} diff --git a/desktop/src/app/newAppInstaller.test.mjs b/desktop/src/app/newAppInstaller.test.mjs new file mode 100644 index 0000000000..738c8239bc --- /dev/null +++ b/desktop/src/app/newAppInstaller.test.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveInstallerUrl } from "./newAppInstaller.ts"; + +const RELEASE = "https://github.com/block/buzz-app/releases/download/v1.0.0"; +const DOWNLOADS = { + "macos-arm64": `${RELEASE}/Buzz_1.0.0_aarch64.dmg`, + "macos-x64": `${RELEASE}/Buzz_1.0.0_x64.dmg`, + "windows-x64": `${RELEASE}/Buzz_1.0.0_x64_unsigned.exe`, + "linux-appimage": `${RELEASE}/Buzz_1.0.0_x86_64.AppImage`, + "linux-deb": `${RELEASE}/Buzz_1.0.0_amd64.deb`, +}; + +function manifest(overrides = {}) { + return { + schemaVersion: 1, + channel: "stable", + version: "1.0.0", + release: "v1.0.0", + downloads: DOWNLOADS, + ...overrides, + }; +} + +function withDownload(url) { + return manifest({ downloads: { ...DOWNLOADS, "macos-arm64": url } }); +} + +test("resolves the installer for every shipped target", () => { + for (const [target, url] of Object.entries(DOWNLOADS)) { + assert.equal(resolveInstallerUrl(manifest(), target), url, target); + } +}); + +test("rejects every manifest or entry that is not a buzz-app release asset", () => { + const cases = [ + ["manifest is not an object", "not json", "macos-arm64"], + ["manifest is null", null, "macos-arm64"], + ["newer schema version", manifest({ schemaVersion: 2 }), "macos-arm64"], + [ + "schema version as a string", + manifest({ schemaVersion: "1" }), + "macos-arm64", + ], + ["other channel", manifest({ channel: "beta" }), "macos-arm64"], + ["downloads missing", manifest({ downloads: undefined }), "macos-arm64"], + [ + "target missing from downloads", + manifest({ downloads: { "macos-x64": DOWNLOADS["macos-x64"] } }), + "macos-arm64", + ], + ["target unknown to this app", manifest(), "linux-arm64"], + ["target that is an inherited property", manifest(), "constructor"], + ["entry is not a string", withDownload(42), "macos-arm64"], + ["entry is not a URL", withDownload("Buzz.dmg"), "macos-arm64"], + [ + "plain http", + withDownload(DOWNLOADS["macos-arm64"].replace("https:", "http:")), + "macos-arm64", + ], + [ + "another host", + withDownload( + "https://example.org/block/buzz-app/releases/download/v1.0.0/Buzz.dmg", + ), + "macos-arm64", + ], + [ + "host hidden behind userinfo", + withDownload( + "https://github.com@example.org/block/buzz-app/releases/download/v1.0.0/Buzz.dmg", + ), + "macos-arm64", + ], + [ + "credentials in the URL", + withDownload( + "https://user:pass@github.com/block/buzz-app/releases/download/v1.0.0/Buzz.dmg", + ), + "macos-arm64", + ], + [ + "non-default port", + withDownload( + "https://github.com:8443/block/buzz-app/releases/download/v1.0.0/Buzz.dmg", + ), + "macos-arm64", + ], + [ + "another repository", + withDownload( + "https://github.com/example-org/buzz-app/releases/download/v1.0.0/Buzz.dmg", + ), + "macos-arm64", + ], + [ + "dot segments that leave the repository", + withDownload( + "https://github.com/block/buzz-app/releases/download/../../../../example-org/other/releases/download/v1/Buzz.dmg", + ), + "macos-arm64", + ], + [ + "encoded dot segments that leave the repository", + withDownload( + "https://github.com/block/buzz-app/releases/download/%2e%2e/%2e%2e/%2e%2e/%2e%2e/example-org/other/Buzz.dmg", + ), + "macos-arm64", + ], + [ + "wrong file type for the target", + withDownload(DOWNLOADS["windows-x64"]), + "macos-arm64", + ], + [ + "expected extension only in the query", + withDownload(`${RELEASE}/Buzz_1.0.0_x64_unsigned.exe?name=Buzz.dmg`), + "macos-arm64", + ], + ["query", withDownload(`${RELEASE}/Buzz.dmg?download=1`), "macos-arm64"], + ["empty query", withDownload(`${RELEASE}/Buzz.dmg?`), "macos-arm64"], + ["fragment", withDownload(`${RELEASE}/Buzz.dmg#top`), "macos-arm64"], + [ + "no tag segment", + withDownload( + "https://github.com/block/buzz-app/releases/download/Buzz.dmg", + ), + "macos-arm64", + ], + // The tag reaches GitHub as written, so nothing in it may be an escape + // GitHub's router could decode into another path. + ...[ + "%2F..%2F..%2Fother", + "%5Cother", + "%00", + ";other", + "%20beta", + " beta", + "@other", + ].map((suffix) => [ + `tag ending in ${suffix}`, + withDownload(`${RELEASE}${suffix}/Buzz.dmg`), + "macos-arm64", + ]), + [ + "extension with nothing before it", + withDownload(`${RELEASE}/.dmg`), + "macos-arm64", + ], + ["hidden file", withDownload(`${RELEASE}/.Buzz.dmg`), "macos-arm64"], + [ + "encoded separator in the file name", + withDownload(`${RELEASE}/..%2FBuzz.dmg`), + "macos-arm64", + ], + [ + "space in the file name", + withDownload(`${RELEASE}/Buzz%20App.dmg`), + "macos-arm64", + ], + [ + "file name too long", + withDownload(`${RELEASE}/${"a".repeat(128)}.dmg`), + "macos-arm64", + ], + ]; + for (const [name, input, target] of cases) { + assert.equal(resolveInstallerUrl(input, target), null, name); + } +}); + +test("accepts a tag of slashes and the characters release tags use", () => { + for (const tag of ["desktop/v1.0.0", "desktop/v1.0.0-rc.1+build_2"]) { + const url = `https://github.com/block/buzz-app/releases/download/${tag}/Buzz.dmg`; + assert.equal(resolveInstallerUrl(withDownload(url), "macos-arm64"), url); + } +}); diff --git a/desktop/src/app/newAppInstaller.ts b/desktop/src/app/newAppInstaller.ts new file mode 100644 index 0000000000..79809b63f3 --- /dev/null +++ b/desktop/src/app/newAppInstaller.ts @@ -0,0 +1,93 @@ +/** Installer manifest the block/buzz-app release job publishes per stable release. */ +export const BUZZ_APP_DOWNLOADS_MANIFEST_URL = + "https://raw.githubusercontent.com/block/buzz-app/download-feed/downloads.json"; +export const BUZZ_APP_DOWNLOAD_PATH_PREFIX = + "/block/buzz-app/releases/download/"; +export const NEW_APP_MANIFEST_TIMEOUT_MS = 5_000; +/** Opened in the browser when this machine's installer is not resolved. */ +export const BUZZ_APP_WEBSITE_URL = "https://buzz.xyz"; + +const BUZZ_APP_DOWNLOAD_ORIGIN = "https://github.com"; + +/** The file extension each manifest key must point at. */ +const INSTALLER_EXTENSION_BY_TARGET: Record = { + "macos-arm64": ".dmg", + "macos-x64": ".dmg", + "windows-x64": ".exe", + "linux-appimage": ".AppImage", + "linux-deb": ".deb", +}; + +/** + * The backend saves the installer under the URL's last path segment, so that + * segment must be a plain file name: letters, digits and `._+-` only. + */ +const INSTALLER_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/; +/** + * The release tag reaches GitHub as written, so it holds no percent escape + * for GitHub's router to decode: letters, digits and `._+-/` only. + */ +const INSTALLER_TAG = /^[A-Za-z0-9._+/-]+$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Returns the installer URL the manifest lists for `target`, or null when the + * manifest or the entry is anything other than a stable-channel release asset + * of block/buzz-app with the file type that target installs. The backend + * applies the same URL rules again before it downloads anything. + */ +export function resolveInstallerUrl( + manifest: unknown, + target: string, +): string | null { + if (!Object.hasOwn(INSTALLER_EXTENSION_BY_TARGET, target)) { + return null; + } + if ( + !isRecord(manifest) || + manifest.schemaVersion !== 1 || + manifest.channel !== "stable" || + !isRecord(manifest.downloads) || + !Object.hasOwn(manifest.downloads, target) + ) { + return null; + } + const entry = manifest.downloads[target]; + if (typeof entry !== "string") { + return null; + } + + let url: URL; + try { + url = new URL(entry); + } catch { + return null; + } + // Checked on the parsed URL, so `..` segments, backslashes and userinfo + // tricks are normalised before the origin and path are compared. + if ( + url.origin !== BUZZ_APP_DOWNLOAD_ORIGIN || + url.username !== "" || + url.password !== "" || + !url.pathname.startsWith(BUZZ_APP_DOWNLOAD_PATH_PREFIX) || + !url.pathname.endsWith(INSTALLER_EXTENSION_BY_TARGET[target]) || + /[?#]/.test(url.href) + ) { + return null; + } + // `/`; a tag may hold slashes, the file name may not. + const assetPath = url.pathname.slice(BUZZ_APP_DOWNLOAD_PATH_PREFIX.length); + const separator = assetPath.lastIndexOf("/"); + const fileName = assetPath.slice(separator + 1); + if ( + !INSTALLER_TAG.test(assetPath.slice(0, Math.max(separator, 0))) || + !INSTALLER_FILE_NAME.test(fileName) || + fileName === INSTALLER_EXTENSION_BY_TARGET[target] + ) { + return null; + } + return url.href; +} diff --git a/desktop/src/app/newAppInstallerDownload.test.mjs b/desktop/src/app/newAppInstallerDownload.test.mjs new file mode 100644 index 0000000000..054bc31a80 --- /dev/null +++ b/desktop/src/app/newAppInstallerDownload.test.mjs @@ -0,0 +1,363 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + dialogDescription, + dialogTitle, + downloadPercent, + downloadProgressText, + failureActions, + failureMessage, + folderName, + formatBytes, + INSTALLER_DIALOG_CLOSED, + liveAnnouncement, + REVEAL_FAILED_MESSAGE, + reduceInstallerDialog, + revealLabel, +} from "./newAppInstallerDownload.ts"; + +const FILE = "Buzz_1.0.0_aarch64.dmg"; +const URL = `https://github.com/block/buzz-app/releases/download/v1.0.0/${FILE}`; +const MIB = 1024 * 1024; + +function run(actions, state = INSTALLER_DIALOG_CLOSED) { + return actions.reduce(reduceInstallerDialog, state); +} + +const started = { type: "started", url: URL }; +const named = { type: "event", event: { kind: "started", fileName: FILE } }; +const progress = (receivedBytes, totalBytes) => ({ + type: "event", + event: { kind: "progress", receivedBytes, totalBytes }, +}); +const saved = { + type: "settled", + outcome: { status: "saved", fileName: FILE, folder: "/Users/sam/Downloads" }, +}; +const cancelled = { type: "settled", outcome: { status: "cancelled" } }; +const failed = (reason) => ({ + type: "settled", + outcome: { status: "failed", reason }, +}); + +test("a download runs from start through progress to finished", () => { + let state = run([started]); + assert.deepEqual(state, { + status: "downloading", + url: URL, + fileName: null, + receivedBytes: 0, + totalBytes: null, + isCancelling: false, + }); + + state = run( + [named, progress(0, 80 * MIB), progress(20 * MIB, 80 * MIB)], + state, + ); + assert.equal(state.fileName, FILE); + assert.equal(state.receivedBytes, 20 * MIB); + assert.equal(state.totalBytes, 80 * MIB); + + // A late, lower report never moves the bar back. + state = run([progress(10 * MIB, 80 * MIB)], state); + assert.equal(state.receivedBytes, 20 * MIB); + + state = run([saved], state); + assert.deepEqual(state, { + status: "finished", + fileName: FILE, + folder: "/Users/sam/Downloads", + revealFailed: false, + }); + assert.equal( + run([{ type: "revealSettled", ok: false }], state).revealFailed, + true, + ); + assert.deepEqual( + run([{ type: "dismissed" }], state), + INSTALLER_DIALOG_CLOSED, + ); +}); + +test("a second start while one is running changes nothing", () => { + const running = run([started, named, progress(5, 10)]); + assert.equal(run([started], running), running); +}); + +test("a running download cannot be dismissed, only cancelled", () => { + const running = run([started, named]); + assert.equal(run([{ type: "dismissed" }], running), running); + + const cancelling = run([{ type: "cancelRequested" }], running); + assert.equal(cancelling.isCancelling, true); + assert.deepEqual(run([cancelled], cancelling), INSTALLER_DIALOG_CLOSED); + // A failure that races the cancel is what the user asked for: it closes. + assert.deepEqual( + run([failed("network")], cancelling), + INSTALLER_DIALOG_CLOSED, + ); + // A download that finished before the cancel landed is still reported. + assert.equal(run([saved], cancelling).status, "finished"); + // If the cancel request itself fails, the Cancel button comes back. + assert.equal(run([{ type: "cancelFailed" }], cancelling).isCancelling, false); +}); + +test("a failure can be retried or dismissed", () => { + const failure = run([started, named, failed("network")]); + // The failure keeps the URL, which a retry and the browser route both need. + assert.deepEqual(failure, { status: "failed", reason: "network", url: URL }); + assert.equal(run([started], failure).status, "downloading"); + assert.deepEqual( + run([{ type: "dismissed" }], failure), + INSTALLER_DIALOG_CLOSED, + ); +}); + +test("events and results are ignored once the download is over", () => { + for (const state of [ + INSTALLER_DIALOG_CLOSED, + run([started, saved]), + run([started, failed("storage")]), + ]) { + for (const action of [ + named, + progress(1, 2), + saved, + cancelled, + failed("network"), + { type: "cancelRequested" }, + { type: "cancelFailed" }, + ]) { + assert.equal(reduceInstallerDialog(state, action), state); + } + } +}); + +test("percent is whole, clamped, and absent without a total", () => { + assert.equal(downloadPercent(0, 80), 0); + assert.equal(downloadPercent(79, 80), 98); + assert.equal(downloadPercent(80, 80), 100); + assert.equal(downloadPercent(90, 80), 100); + assert.equal(downloadPercent(10, null), null); + assert.equal(downloadPercent(10, 0), null); +}); + +test("bytes and progress read naturally", () => { + assert.equal(formatBytes(0), "0 B"); + assert.equal(formatBytes(1023), "1023 B"); + assert.equal(formatBytes(1536), "2 KB"); + assert.equal(formatBytes(79 * MIB), "79.0 MB"); + assert.equal(formatBytes(1.5 * 1024 * MIB), "1.50 GB"); + assert.equal( + downloadProgressText(39.5 * MIB, 79 * MIB), + "39.5 MB of 79.0 MB (50%)", + ); + assert.equal(downloadProgressText(12.3 * MIB, null), "12.3 MB downloaded"); +}); + +test("the folder name is the last path segment on any platform", () => { + assert.equal(folderName("/Users/sam/Downloads"), "Downloads"); + assert.equal(folderName("/home/sam/Downloads/"), "Downloads"); + assert.equal(folderName("C:\\Users\\sam\\Downloads"), "Downloads"); + assert.equal(folderName(""), ""); +}); + +test("the reveal button names each platform's file manager", () => { + assert.equal(revealLabel("macos"), "Show in Finder"); + assert.equal(revealLabel("windows"), "Show in File Explorer"); + assert.equal(revealLabel("linux"), "Show in folder"); +}); + +test("every failure reason has its own plain-words message", () => { + const reasons = [ + "busy", + "rejected", + "network", + "redirect-blocked", + "too-large", + "storage", + "permission-denied", + "protection", + "unknown", + ]; + for (const platform of ["macos", "windows", "linux"]) { + const messages = reasons.map((reason) => failureMessage(reason, platform)); + assert.equal(new Set(messages).size, reasons.length); + for (const message of messages) { + assert.match(message, /^[A-Z].*\.$/); + } + } + // Only a dropped connection is blamed on the connection, and only a full + // disk on free space. + assert.deepEqual( + reasons.filter((reason) => + /internet connection/.test(failureMessage(reason, "macos")), + ), + ["network"], + ); + assert.deepEqual( + reasons.filter((reason) => + /free space/.test(failureMessage(reason, "macos")), + ), + ["storage"], + ); + // On macOS a declined consent prompt is not shown again, so the message + // says where to allow it; elsewhere there is no such setting to point at. + assert.match( + failureMessage("permission-denied", "macos"), + /System Settings.*Privacy & Security.*Files and Folders.*Downloads Folder/, + ); + assert.doesNotMatch( + failureMessage("permission-denied", "linux"), + /System Settings/, + ); +}); + +test("a failure leads with the action that can get the user the app", () => { + const actions = Object.fromEntries( + [ + "busy", + "network", + "storage", + "unknown", + "permission-denied", + "redirect-blocked", + "too-large", + "protection", + "rejected", + ].map((reason) => [reason, failureActions(reason)]), + ); + assert.deepEqual(actions, { + // A retry can work. + busy: { primary: "retry", secondary: "website" }, + network: { primary: "retry", secondary: "website" }, + storage: { primary: "retry", secondary: "website" }, + unknown: { primary: "retry", secondary: "website" }, + // A retry works only after the user has granted access. + "permission-denied": { primary: "browser", secondary: "retry" }, + // A retry would fail the same way. + "redirect-blocked": { primary: "browser", secondary: "website" }, + "too-large": { primary: "browser", secondary: "website" }, + protection: { primary: "browser", secondary: "website" }, + // The link was refused, so it is never opened. + rejected: { primary: "website", secondary: null }, + }); + + const describe = (reason) => + dialogDescription(run([started, failed(reason)]), "macos"); + assert.match( + describe("network"), + /You can also get the new app from buzz\.xyz\.$/, + ); + assert.match(describe("protection"), /in your browser instead\.$/); + assert.match(describe("permission-denied"), /in your browser instead\.$/); + assert.match( + describe("rejected"), + /You can get the new app from buzz\.xyz\.$/, + ); +}); + +test("titles and descriptions follow the state", () => { + const starting = run([started]); + assert.equal(dialogTitle(starting), "Downloading the new Buzz app"); + assert.equal(dialogDescription(starting, "macos"), "Starting the download…"); + + const running = run([named], starting); + assert.equal( + dialogDescription(running, "macos"), + `Saving ${FILE} to your Downloads folder.`, + ); + assert.equal( + dialogDescription(run([{ type: "cancelRequested" }], running), "macos"), + "Cancelling the download…", + ); + + const finished = run([saved], running); + assert.equal(dialogTitle(finished), "The new Buzz app is downloaded"); + assert.equal( + dialogDescription(finished, "macos"), + `${FILE} is in your Downloads folder. Open it when you're ready to install.`, + ); + + // An AppImage is saved without the executable bit, so it cannot be opened + // yet: the message gives the step that makes it runnable. + const appImage = run([ + started, + { + type: "settled", + outcome: { + status: "saved", + fileName: "Buzz_1.0.0_amd64.AppImage", + folder: "/home/sam/Downloads", + }, + }, + ]); + const appImageText = dialogDescription(appImage, "linux"); + assert.match( + appImageText, + /^Buzz_1\.0\.0_amd64\.AppImage is in your Downloads folder\./, + ); + assert.match(appImageText, /make it executable/); + assert.match(appImageText, /chmod \+x/); + assert.doesNotMatch(appImageText, /Open it when you're ready/); + + const failure = run([failed("network")], running); + assert.equal(dialogTitle(failure), "The download didn't finish"); + assert.match(dialogDescription(failure, "macos"), /internet connection/); + assert.match(dialogDescription(failure, "macos"), /buzz\.xyz\.$/); + + // The file is where the dialog says; only opening the folder failed. + assert.match(REVEAL_FAILED_MESSAGE, /couldn't open the folder/); + assert.doesNotMatch(REVEAL_FAILED_MESSAGE, /moved|deleted/); +}); + +test("a screen reader hears milestones, not every progress tick", () => { + let state = run([started]); + const heard = [liveAnnouncement(state, "macos")]; + const actions = [named, progress(0, 1000)]; + // A report for every 1% of the file. + for (let received = 10; received <= 1000; received += 10) { + actions.push(progress(received, 1000)); + } + actions.push(saved); + for (const action of actions) { + state = reduceInstallerDialog(state, action); + const announcement = liveAnnouncement(state, "macos"); + if (announcement !== heard.at(-1)) { + heard.push(announcement); + } + } + assert.deepEqual(heard, [ + "Starting the download.", + `Downloading ${FILE}.`, + `Downloading ${FILE}, 25% done.`, + `Downloading ${FILE}, 50% done.`, + `Downloading ${FILE}, 75% done.`, + `Downloading ${FILE}, 100% done.`, + `Download finished. ${FILE} is in your Downloads folder.`, + ]); + + // Without a total there is nothing to count: one announcement while it runs. + state = run([started, named]); + const unsized = new Set([liveAnnouncement(state, "macos")]); + for (let received = 10; received <= 1000; received += 10) { + state = reduceInstallerDialog(state, progress(received, null)); + unsized.add(liveAnnouncement(state, "macos")); + } + assert.deepEqual([...unsized], [`Downloading ${FILE}.`]); + + assert.equal( + liveAnnouncement( + run([started, named, { type: "cancelRequested" }]), + "macos", + ), + "Cancelling the download.", + ); + assert.match( + liveAnnouncement(run([started, failed("storage")]), "macos"), + /^The download didn't finish\. /, + ); + assert.equal(liveAnnouncement(INSTALLER_DIALOG_CLOSED, "macos"), ""); +}); diff --git a/desktop/src/app/newAppInstallerDownload.ts b/desktop/src/app/newAppInstallerDownload.ts new file mode 100644 index 0000000000..76a82bdf42 --- /dev/null +++ b/desktop/src/app/newAppInstallerDownload.ts @@ -0,0 +1,346 @@ +import type { + InstallerDownloadEvent, + InstallerDownloadOutcome, + InstallerFailureReason, +} from "@/shared/api/newAppInstallerDownload"; + +/** `unknown` covers a download command that failed without an outcome. */ +export type InstallerDialogFailure = InstallerFailureReason | "unknown"; + +export type InstallerDialogState = + | { status: "closed" } + | { + status: "downloading"; + /** The installer being downloaded. */ + url: string; + /** Null until the backend has accepted the URL and named the file. */ + fileName: string | null; + receivedBytes: number; + /** Null when the server did not say how big the file is. */ + totalBytes: number | null; + isCancelling: boolean; + } + | { + status: "finished"; + fileName: string; + folder: string; + revealFailed: boolean; + } + | { + status: "failed"; + reason: InstallerDialogFailure; + /** The installer that failed to download, for a retry or the browser. */ + url: string; + }; + +export type InstallerDialogAction = + | { type: "started"; url: string } + | { type: "event"; event: InstallerDownloadEvent } + | { + type: "settled"; + outcome: + | InstallerDownloadOutcome + | { status: "failed"; reason: "unknown" }; + } + | { type: "cancelRequested" } + | { type: "cancelFailed" } + | { type: "revealSettled"; ok: boolean } + | { type: "dismissed" }; + +export const INSTALLER_DIALOG_CLOSED: InstallerDialogState = { + status: "closed", +}; + +export function reduceInstallerDialog( + state: InstallerDialogState, + action: InstallerDialogAction, +): InstallerDialogState { + switch (action.type) { + case "started": + // A download that is already running is never restarted. + if (state.status === "downloading") { + return state; + } + return { + status: "downloading", + url: action.url, + fileName: null, + receivedBytes: 0, + totalBytes: null, + isCancelling: false, + }; + case "event": + if (state.status !== "downloading") { + return state; + } + if (action.event.kind === "started") { + return { ...state, fileName: action.event.fileName }; + } + return { + ...state, + // Channel messages can arrive out of order; progress never goes back. + receivedBytes: Math.max( + state.receivedBytes, + action.event.receivedBytes, + ), + totalBytes: action.event.totalBytes, + }; + case "settled": + if (state.status !== "downloading") { + return state; + } + if (action.outcome.status === "saved") { + // Also when the user had asked to cancel: the file exists, so say so. + return { + status: "finished", + fileName: action.outcome.fileName, + folder: action.outcome.folder, + revealFailed: false, + }; + } + if (action.outcome.status === "cancelled" || state.isCancelling) { + return INSTALLER_DIALOG_CLOSED; + } + return { + status: "failed", + reason: action.outcome.reason, + url: state.url, + }; + case "cancelRequested": + return state.status === "downloading" + ? { ...state, isCancelling: true } + : state; + case "cancelFailed": + // Give the Cancel button back so the user can ask again. + return state.status === "downloading" + ? { ...state, isCancelling: false } + : state; + case "revealSettled": + return state.status === "finished" + ? { ...state, revealFailed: !action.ok } + : state; + case "dismissed": + // A running download is cancelled, never just hidden. + return state.status === "downloading" ? state : INSTALLER_DIALOG_CLOSED; + } +} + +/** Whole percent done, or null when the total is unknown. */ +export function downloadPercent( + receivedBytes: number, + totalBytes: number | null, +): number | null { + if (totalBytes === null || totalBytes <= 0) { + return null; + } + return Math.min( + 100, + Math.max(0, Math.floor((receivedBytes / totalBytes) * 100)), + ); +} + +const KIB = 1024; +const MIB = KIB * 1024; +const GIB = MIB * 1024; + +export function formatBytes(bytes: number): string { + if (bytes < KIB) { + return `${bytes} B`; + } + if (bytes < MIB) { + return `${Math.round(bytes / KIB)} KB`; + } + if (bytes < GIB) { + return `${(bytes / MIB).toFixed(1)} MB`; + } + return `${(bytes / GIB).toFixed(2)} GB`; +} + +/** "39.5 MB of 79.0 MB (50%)", or "39.5 MB downloaded" without a total. */ +export function downloadProgressText( + receivedBytes: number, + totalBytes: number | null, +): string { + const percent = downloadPercent(receivedBytes, totalBytes); + if (totalBytes === null || percent === null) { + return `${formatBytes(receivedBytes)} downloaded`; + } + return `${formatBytes(receivedBytes)} of ${formatBytes(totalBytes)} (${percent}%)`; +} + +/** The last segment of a folder path, whichever separator the platform uses. */ +export function folderName(folder: string): string { + const segments = folder.split(/[\\/]/).filter((segment) => segment !== ""); + return segments.at(-1) ?? folder; +} + +export type FileManagerPlatform = "macos" | "windows" | "linux"; + +export function revealLabel(platform: FileManagerPlatform): string { + switch (platform) { + case "macos": + return "Show in Finder"; + case "windows": + return "Show in File Explorer"; + case "linux": + return "Show in folder"; + } +} + +const FAILURE_MESSAGES: Record< + Exclude, + string +> = { + busy: "Another download of the new app is still running. Give it a moment, then try again.", + rejected: + "Buzz couldn't use the download link for this computer, so nothing was downloaded.", + network: + "The download stopped before it was complete. Check your internet connection and try again.", + "redirect-blocked": + "The download was sent to a site Buzz doesn't recognise, so Buzz stopped it.", + "too-large": + "The file was much bigger than the installer should be, so the download was stopped.", + storage: + "The installer couldn't be saved to your Downloads folder. Check that there is free space and try again.", + protection: + "Buzz couldn't mark the installer as downloaded from the internet, so it didn't keep the file.", + unknown: "Something went wrong while downloading the installer.", +}; + +export function failureMessage( + reason: InstallerDialogFailure, + platform: FileManagerPlatform, +): string { + if (reason !== "permission-denied") { + return FAILURE_MESSAGES[reason]; + } + // On macOS this is what declining the system's "access files in your + // Downloads folder" prompt leads to, and the prompt is not shown again. + return platform === "macos" + ? "Buzz isn't allowed to save to your Downloads folder. To allow it, open System Settings, choose Privacy & Security, then Files and Folders, and turn on Downloads Folder for Buzz." + : "Buzz isn't allowed to save to your Downloads folder. Check that your account can add files to that folder."; +} + +/** What a failed download offers, and which of the two takes focus. */ +export type FailureAction = "retry" | "browser" | "website"; + +/** + * "Try again" leads only where trying again can work. A failure that would + * repeat sends the same installer to the browser, which downloads it its own + * way; a link Buzz refused is never opened, so that one leads to the website. + */ +export function failureActions(reason: InstallerDialogFailure): { + primary: FailureAction; + secondary: FailureAction | null; +} { + switch (reason) { + case "busy": + case "network": + case "storage": + case "unknown": + return { primary: "retry", secondary: "website" }; + case "permission-denied": + // Once access is granted, the same download works. + return { primary: "browser", secondary: "retry" }; + case "redirect-blocked": + case "too-large": + case "protection": + return { primary: "browser", secondary: "website" }; + case "rejected": + return { primary: "website", secondary: null }; + } +} + +const FAILURE_NEXT_STEP: Record = { + retry: "You can also get the new app from buzz.xyz.", + browser: "You can download the installer in your browser instead.", + website: "You can get the new app from buzz.xyz.", +}; + +export function dialogTitle(state: InstallerDialogState): string { + switch (state.status) { + case "finished": + return "The new Buzz app is downloaded"; + case "failed": + return "The download didn't finish"; + default: + return "Downloading the new Buzz app"; + } +} + +export function dialogDescription( + state: InstallerDialogState, + platform: FileManagerPlatform, +): string { + switch (state.status) { + case "closed": + return ""; + case "downloading": + if (state.isCancelling) { + return "Cancelling the download…"; + } + return state.fileName + ? `Saving ${state.fileName} to your Downloads folder.` + : "Starting the download…"; + case "finished": { + const where = `${state.fileName} is in your ${folderName(state.folder)} folder.`; + // An AppImage is saved like any download, so it cannot run yet. + return state.fileName.endsWith(".AppImage") + ? `${where} Before you open it, make it executable: in your file manager, open its Properties and allow it to run as a program, or run chmod +x on it in a terminal.` + : `${where} Open it when you're ready to install.`; + } + case "failed": + return `${failureMessage(state.reason, platform)} ${FAILURE_NEXT_STEP[failureActions(state.reason).primary]}`; + } +} + +/** Shown when the file manager did not open; the file itself is fine. */ +export const REVEAL_FAILED_MESSAGE = + "Buzz couldn't open the folder. The installer is in the folder shown above."; + +/** + * What a screen reader is told. It changes when the download starts, at each + * quarter of the way, and when it finishes, fails or is being cancelled, so a + * live region holding it speaks a handful of times and not on every tick. + */ +export function liveAnnouncement( + state: InstallerDialogState, + platform: FileManagerPlatform, +): string { + switch (state.status) { + case "closed": + return ""; + case "downloading": { + if (state.isCancelling) { + return "Cancelling the download."; + } + if (!state.fileName) { + return "Starting the download."; + } + const percent = downloadPercent(state.receivedBytes, state.totalBytes); + const quarter = percent === null ? 0 : Math.floor(percent / 25) * 25; + return quarter === 0 + ? `Downloading ${state.fileName}.` + : `Downloading ${state.fileName}, ${quarter}% done.`; + } + case "finished": + return `Download finished. ${state.fileName} is in your ${folderName(state.folder)} folder.`; + case "failed": + return `The download didn't finish. ${failureMessage(state.reason, platform)}`; + } +} + +/** + * Keeps a held Enter from pressing a button again. The press that closes the + * dialog, or that starts a download, moves focus to another button while the + * key is still down, and the key's repeat must not press that one too. + */ +export function ignoreRepeatedEnter(event: { + key: string; + repeat: boolean; + preventDefault: () => void; +}) { + if (event.repeat && event.key === "Enter") { + event.preventDefault(); + } +} diff --git a/desktop/src/app/useNewAppInstallerDownload.jsdom-test.mjs b/desktop/src/app/useNewAppInstallerDownload.jsdom-test.mjs new file mode 100644 index 0000000000..6b3cdb5aeb --- /dev/null +++ b/desktop/src/app/useNewAppInstallerDownload.jsdom-test.mjs @@ -0,0 +1,185 @@ +/** + * Exercises the real hook against a fake backend: the download belongs to the + * app, so it outlives the component that started it; mounting stops only a + * download this page knows nothing about; and an earlier attempt's events + * never reach the next attempt. + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +const FILE = "Buzz_1.0.0_aarch64.dmg"; +const URL = `https://github.com/block/buzz-app/releases/download/v1.0.0/${FILE}`; +const DOWNLOAD = "download_new_app_installer"; +const CANCEL = "cancel_new_app_installer_download"; +const REVEAL = "reveal_new_app_installer"; + +const calls = []; +/** One entry per `download_new_app_installer` call, in order. */ +const downloads = []; +const tauriMock = { + invoke(command, args) { + calls.push(command); + if (command === DOWNLOAD) { + return new Promise((settle) => { + downloads.push({ + emit: (event) => args.onEvent.onmessage(event), + settle, + }); + }); + } + if (command === CANCEL || command === REVEAL) { + return Promise.resolve(null); + } + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback() { + return Math.random(); + }, +}; +globalThis.__TAURI_INTERNALS__ = tauriMock; +globalThis.window.__TAURI_INTERNALS__ = tauriMock; + +const React = (await import("react")).default; +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { useNewAppInstallerDownload } = await import( + "./useNewAppInstallerDownload.ts" +); + +let hook = null; +function Probe() { + hook = useNewAppInstallerDownload(); + return null; +} + +let root = null; +let container = null; +async function mount() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root.render(React.createElement(Probe)); + }); +} +async function unmount() { + await act(async () => root.unmount()); + container.remove(); + root = null; + hook = null; +} +/** Runs `step`, then lets its promises and the resulting render settle. */ +async function settled(step) { + await act(async () => { + step(); + }); +} +const count = (command) => calls.filter((call) => call === command).length; + +afterEach(async () => { + if (root) { + await unmount(); + } +}); + +test("a download outlives its component; mounting stops only a stranger", async () => { + // Nothing is running in this page, so a download the backend still holds + // was left by a page that was reloaded: it is stopped. + await mount(); + assert.deepEqual(calls, [CANCEL]); + assert.equal(hook.state.status, "closed"); + + await settled(() => hook.start(URL)); + assert.equal(hook.state.status, "downloading"); + assert.equal(hook.state.url, URL); + await settled(() => downloads[0].emit({ kind: "started", fileName: FILE })); + + // The banner's owner remounts on a community switch or an identity change. + // That neither cancels the download nor loses its dialog. + await unmount(); + assert.equal(count(CANCEL), 1); + downloads[0].emit({ kind: "progress", receivedBytes: 40, totalBytes: 100 }); + await mount(); + assert.equal(count(CANCEL), 1, "a remount must not cancel its own download"); + assert.deepEqual(hook.state, { + status: "downloading", + url: URL, + fileName: FILE, + receivedBytes: 40, + totalBytes: 100, + isCancelling: false, + }); + + // It still progresses and finishes in the new component. + await settled(() => + downloads[0].emit({ kind: "progress", receivedBytes: 90, totalBytes: 100 }), + ); + assert.equal(hook.state.receivedBytes, 90); + await settled(() => + downloads[0].settle({ + status: "saved", + fileName: FILE, + folder: "/Users/sam/Downloads", + }), + ); + assert.equal(hook.state.status, "finished"); + assert.equal(count(REVEAL), 1); + + await settled(() => hook.dismiss()); + assert.equal(hook.state.status, "closed"); + assert.equal(count(DOWNLOAD), 1); +}); + +test("an earlier attempt's events never reach the next attempt", async () => { + await mount(); + const first = downloads.length; + + // Two clicks in one tick start one download. + await settled(() => { + hook.start(URL); + hook.start(URL); + }); + assert.equal(downloads.length, first + 1); + await settled(() => + downloads[first].settle({ status: "failed", reason: "network" }), + ); + assert.deepEqual(hook.state, { + status: "failed", + reason: "network", + url: URL, + }); + + await settled(() => hook.start(hook.state.url)); + assert.equal(downloads.length, first + 2); + const fresh = { ...hook.state }; + assert.equal(fresh.status, "downloading"); + assert.equal(fresh.receivedBytes, 0); + + // Channel messages of the failed attempt can still be in flight. + await settled(() => { + downloads[first].emit({ kind: "started", fileName: "stale.dmg" }); + downloads[first].emit({ + kind: "progress", + receivedBytes: 99, + totalBytes: 100, + }); + }); + assert.deepEqual(hook.state, fresh); + + await settled(() => + downloads[first + 1].emit({ + kind: "progress", + receivedBytes: 10, + totalBytes: 100, + }), + ); + assert.equal(hook.state.receivedBytes, 10); + + // Cancelling asks the backend, and the dialog closes once it has stopped. + const cancels = count(CANCEL); + await settled(() => hook.cancel()); + assert.equal(count(CANCEL), cancels + 1); + assert.equal(hook.state.isCancelling, true); + await settled(() => downloads[first + 1].settle({ status: "cancelled" })); + assert.equal(hook.state.status, "closed"); +}); diff --git a/desktop/src/app/useNewAppInstallerDownload.ts b/desktop/src/app/useNewAppInstallerDownload.ts new file mode 100644 index 0000000000..208946e137 --- /dev/null +++ b/desktop/src/app/useNewAppInstallerDownload.ts @@ -0,0 +1,139 @@ +import * as React from "react"; + +import { + INSTALLER_DIALOG_CLOSED, + type InstallerDialogAction, + type InstallerDialogState, + reduceInstallerDialog, +} from "@/app/newAppInstallerDownload"; +import { + cancelNewAppInstallerDownload, + downloadNewAppInstaller, + revealNewAppInstaller, +} from "@/shared/api/newAppInstallerDownload"; + +// The download belongs to the app, not to a community, so its state lives +// outside React and outside `resetCommunityState()`. The banner that starts +// it comes and goes with the community, the identity and onboarding; the +// dialog that shows it is mounted once, above all of that. +let state: InstallerDialogState = INSTALLER_DIALOG_CLOSED; +// Set synchronously, so two clicks in one tick cannot both start a download. +let isRunning = false; +// Fences the progress events of an earlier attempt, which can arrive after +// that attempt has settled and the next one has started. +let attempt = 0; +const listeners = new Set<() => void>(); + +function dispatch(action: InstallerDialogAction) { + const next = reduceInstallerDialog(state, action); + if (next === state) { + return; + } + state = next; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getState() { + return state; +} + +function reveal() { + revealNewAppInstaller().then( + () => dispatch({ type: "revealSettled", ok: true }), + (error: unknown) => { + console.warn("Could not show the new app installer:", error); + dispatch({ type: "revealSettled", ok: false }); + }, + ); +} + +/** + * The control that offers the download: the banner's button while a banner is + * mounted, null otherwise. The dialog returns focus to it when it closes. + */ +export const newAppInstallerTriggerRef: React.RefObject = + { current: null }; + +/** + * Starts the in-app download of the installer at `url`, which + * `NewAppInstallerDownloadHost` then shows. Does nothing while one runs. + */ +export function startNewAppInstallerDownload(url: string) { + if (isRunning) { + return; + } + isRunning = true; + attempt += 1; + const thisAttempt = attempt; + dispatch({ type: "started", url }); + downloadNewAppInstaller(url, (event) => { + if (attempt === thisAttempt) { + dispatch({ type: "event", event }); + } + }) + .catch((error: unknown) => { + console.warn("The new app installer download failed:", error); + return { status: "failed", reason: "unknown" } as const; + }) + .then((outcome) => { + isRunning = false; + dispatch({ type: "settled", outcome }); + if (outcome.status === "saved") { + reveal(); + } + }); +} + +function cancel() { + dispatch({ type: "cancelRequested" }); + cancelNewAppInstallerDownload().catch((error: unknown) => { + console.warn("Could not cancel the new app installer download:", error); + dispatch({ type: "cancelFailed" }); + }); +} + +function dismiss() { + dispatch({ type: "dismissed" }); +} + +/** + * Holds the state the installer download's dialog shows, and the actions the + * dialog offers. One download at a time, for the whole app. + * + * `NewAppInstallerDownloadHost` is the one place that mounts this, once per + * main window and above everything that remounts, so a running download + * always has a dialog with a Cancel button. The download does not depend on + * the component that started it, and the backend stops it when the main + * window is destroyed or the app exits. + * + * Mounting stops a download this page knows nothing about, which is one left + * running by a page that was reloaded. That assumes a single window mounts + * this hook: a second window would cancel the first window's download on + * mount, so the huddle window must not mount it. + */ +export function useNewAppInstallerDownload() { + const current = React.useSyncExternalStore(subscribe, getState); + + React.useEffect(() => { + if (!isRunning) { + cancelNewAppInstallerDownload().catch(() => {}); + } + }, []); + + return { + state: current, + start: startNewAppInstallerDownload, + cancel, + dismiss, + reveal, + }; +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 0866ee051e..182bc84784 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "@/app/App"; +import { NewAppInstallerDownloadHost } from "@/app/NewAppInstallerDownloadHost"; import { RootErrorBoundary } from "@/app/RootErrorBoundary"; import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog"; import "@fontsource-variable/inter/opsz.css"; @@ -97,6 +98,11 @@ function renderApp() { + {/* The huddle window never shows the banner, and a + second host would cancel the main window's download. */} + {huddleWindowChannelId() === null ? ( + + ) : null} diff --git a/desktop/src/shared/api/newAppInstallerDownload.ts b/desktop/src/shared/api/newAppInstallerDownload.ts new file mode 100644 index 0000000000..f08def41a5 --- /dev/null +++ b/desktop/src/shared/api/newAppInstallerDownload.ts @@ -0,0 +1,49 @@ +import { Channel } from "@tauri-apps/api/core"; + +import { invokeTauri } from "@/shared/api/tauri"; + +/** Progress the backend sends while it downloads the new app's installer. */ +export type InstallerDownloadEvent = + | { kind: "started"; fileName: string } + | { kind: "progress"; receivedBytes: number; totalBytes: number | null }; + +export type InstallerFailureReason = + | "busy" + | "rejected" + | "network" + | "redirect-blocked" + | "too-large" + | "storage" + | "permission-denied" + | "protection"; + +export type InstallerDownloadOutcome = + | { status: "saved"; fileName: string; folder: string } + | { status: "cancelled" } + | { status: "failed"; reason: InstallerFailureReason }; + +/** + * Downloads the new Buzz app's installer into the Downloads folder. The + * backend validates `url` itself, names the file and picks the folder. It + * resolves once the download is saved, cancelled or failed; only one runs at + * a time. + */ +export function downloadNewAppInstaller( + url: string, + onEvent: (event: InstallerDownloadEvent) => void, +): Promise { + return invokeTauri("download_new_app_installer", { + url, + onEvent: new Channel(onEvent), + }); +} + +/** Stops the running installer download, if any, and removes its partial file. */ +export function cancelNewAppInstallerDownload(): Promise { + return invokeTauri("cancel_new_app_installer_download"); +} + +/** Shows the last installer the app saved in the system file manager. */ +export function revealNewAppInstaller(): Promise { + return invokeTauri("reveal_new_app_installer"); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 3824df8389..895148bfba 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1063,3 +1063,9 @@ export const setPreventSleepActive = (active: boolean) => export function isAutoUpdateSupported(): Promise { return invokeTauri("is_auto_update_supported"); } + +/** Returns this install's key in the new Buzz app's installer manifest + * (e.g. `macos-arm64`), or null when no installer is published for it. */ +export function getNewAppInstallerTarget(): Promise { + return invokeTauri("new_app_installer_target"); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5facb404cc..0263295d21 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -33,6 +33,12 @@ import { switchManagedAgentModel } from "@/shared/api/agentControl"; import { getAudioMediaLoadSchedulerSnapshot } from "@/features/messages/lib/audioMediaLoadScheduler"; import { mockSearchHitMatches } from "./e2eBridgeSearch.ts"; import { selectMockHistory } from "./e2eBridgeHistory.ts"; +import { + handleMockInstallerCancel, + handleMockInstallerDownload, + handleMockInstallerReveal, + installMockInstallerDownloadControls, +} from "./e2eBridgeInstallerDownload.ts"; import { createMockSubscription, hasMockSubscription, @@ -499,6 +505,9 @@ type E2eConfig = { * Linux .deb install where Tauri's updater cannot swap the binary). * Defaults to true for all existing tests. */ autoUpdateSupported?: boolean; + /** Manifest key `new_app_installer_target` returns. Defaults to none, so + * the deprecation banner never fetches the installer manifest. */ + newAppInstallerTarget?: string; /** Reject `plugin:opener|open_url` to exercise browser-return fallback UI. */ openerError?: string; /** Delay binding signatures so specs can exercise request supersession. */ @@ -1422,6 +1431,8 @@ declare global { content: string; tags: string[][]; }>; + /** While set, applying a community to the mock backend fails with this message. */ + __BUZZ_E2E_APPLY_COMMUNITY_ERROR__?: string; /** Structured merge error returned by the mock native merge command. */ __BUZZ_E2E_PROJECT_MERGE_ERROR__?: { code: string; @@ -11556,6 +11567,7 @@ export function maybeInstallE2eTauriMocks() { (window as Window & { isTauri?: boolean }).isTauri = true; } mockWindows(config.mock?.windowLabel ?? "main"); + installMockInstallerDownloadControls(); window.__BUZZ_E2E_COMMANDS__ = []; window.__BUZZ_E2E_COMMAND_PAYLOADS__ = []; window.__BUZZ_E2E_COMMAND_LOG__ = []; @@ -12951,6 +12963,9 @@ export function maybeInstallE2eTauriMocks() { return activeConfig?.mock?.linkPreviewMetadata ?? null; } case "apply_workspace": { + if (window.__BUZZ_E2E_APPLY_COMMUNITY_ERROR__) { + throw new Error(window.__BUZZ_E2E_APPLY_COMMUNITY_ERROR__); + } const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; if (applyDelayMs > 0) { return new Promise((resolve) => @@ -14882,6 +14897,14 @@ export function maybeInstallE2eTauriMocks() { // Default true so all existing tests continue to use the auto-update // path. Set mock.autoUpdateSupported: false to simulate a .deb install. return activeConfig?.mock?.autoUpdateSupported !== false; + case "new_app_installer_target": + return activeConfig?.mock?.newAppInstallerTarget ?? null; + case "download_new_app_installer": + return handleMockInstallerDownload(payload); + case "cancel_new_app_installer_download": + return handleMockInstallerCancel(); + case "reveal_new_app_installer": + return handleMockInstallerReveal(); case "relay_reconnect_hook": return null; case "relay_reconnect_hook_configured": diff --git a/desktop/src/testing/e2eBridgeInstallerDownload.ts b/desktop/src/testing/e2eBridgeInstallerDownload.ts new file mode 100644 index 0000000000..f99cc26c8a --- /dev/null +++ b/desktop/src/testing/e2eBridgeInstallerDownload.ts @@ -0,0 +1,112 @@ +import type { + InstallerDownloadEvent, + InstallerDownloadOutcome, + InstallerFailureReason, +} from "@/shared/api/newAppInstallerDownload"; + +type MockChannel = { onmessage?: (event: InstallerDownloadEvent) => void }; + +type ActiveDownload = { + channel: MockChannel | undefined; + fileName: string; + settle: (outcome: InstallerDownloadOutcome) => void; +}; + +declare global { + interface Window { + /** + * Drives the mock `download_new_app_installer` command, which stays + * pending until a spec finishes or fails it, or the app cancels it. + */ + __BUZZ_E2E_INSTALLER_DOWNLOAD__?: { + progress: (receivedBytes: number, totalBytes: number | null) => void; + finish: () => void; + fail: (reason: InstallerFailureReason) => void; + isActive: () => boolean; + /** + * Makes the next cancel lose the race with the final move: the cancel + * call returns, then the download settles as saved. + */ + finishOnNextCancel: () => void; + /** Makes the next `reveal_new_app_installer` call fail. */ + failNextReveal: () => void; + }; + } +} + +const MOCK_DOWNLOADS_FOLDER = "/Users/e2e/Downloads"; + +// Like the backend, the mock runs one download at a time. +let active: ActiveDownload | null = null; +let finishesOnNextCancel = false; +let failsNextReveal = false; + +function settleAsSaved() { + active?.settle({ + status: "saved", + fileName: active.fileName, + folder: MOCK_DOWNLOADS_FOLDER, + }); +} + +export function handleMockInstallerDownload( + payload: unknown, +): Promise { + if (active) { + return Promise.resolve({ status: "failed", reason: "busy" }); + } + const { url, onEvent } = payload as { url: string; onEvent?: MockChannel }; + const fileName = new URL(url).pathname.split("/").at(-1) ?? ""; + return new Promise((resolve) => { + active = { + channel: onEvent, + fileName, + settle: (outcome) => { + active = null; + resolve(outcome); + }, + }; + onEvent?.onmessage?.({ kind: "started", fileName }); + }); +} + +export function handleMockInstallerCancel(): null { + if (finishesOnNextCancel && active) { + finishesOnNextCancel = false; + settleAsSaved(); + } else { + active?.settle({ status: "cancelled" }); + } + return null; +} + +export function handleMockInstallerReveal(): Promise { + if (failsNextReveal) { + failsNextReveal = false; + return Promise.reject(new Error("the file manager did not open")); + } + return Promise.resolve(null); +} + +export function installMockInstallerDownloadControls() { + active = null; + finishesOnNextCancel = false; + failsNextReveal = false; + window.__BUZZ_E2E_INSTALLER_DOWNLOAD__ = { + progress: (receivedBytes, totalBytes) => + active?.channel?.onmessage?.({ + kind: "progress", + receivedBytes, + totalBytes, + }), + finish: settleAsSaved, + fail: (reason) => active?.settle({ status: "failed", reason }), + isActive: () => active !== null, + finishOnNextCancel: () => { + finishesOnNextCancel = true; + }, + failNextReveal: () => { + failsNextReveal = true; + }, + }; +} diff --git a/desktop/tests/e2e/deprecation-banner.spec.ts b/desktop/tests/e2e/deprecation-banner.spec.ts new file mode 100644 index 0000000000..5b0108a36f --- /dev/null +++ b/desktop/tests/e2e/deprecation-banner.spec.ts @@ -0,0 +1,826 @@ +import { expect, type Locator, type Page, test } from "@playwright/test"; + +import { BUZZ_APP_DOWNLOADS_MANIFEST_URL } from "../../src/app/newAppInstaller"; +import { installMockBridge } from "../helpers/bridge"; + +type InstallerFailureReason = + | "network" + | "storage" + | "protection" + | "permission-denied" + | "redirect-blocked" + | "rejected"; + +type BridgeWindow = Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ + command: string; + payload: { url?: string } | null; + }>; + __BUZZ_E2E_INSTALLER_DOWNLOAD__?: { + progress: (receivedBytes: number, totalBytes: number | null) => void; + finish: () => void; + fail: (reason: InstallerFailureReason) => void; + isActive: () => boolean; + finishOnNextCancel: () => void; + failNextReveal: () => void; + }; + __BUZZ_E2E_APPLY_COMMUNITY_ERROR__?: string; +}; + +const UPGRADE_GUIDE_URL = + "https://github.com/block/buzz-app/blob/main/docs/upgrading.md"; +const WEBSITE_URL = "https://buzz.xyz"; +const ARM64_DMG_URL = + "https://github.com/block/buzz-app/releases/download/v1.0.0/Buzz_1.0.0_aarch64.dmg"; +// The feed is served as text/plain with open CORS; mirror both. +const MANIFEST_RESPONSE = { + body: JSON.stringify({ + schemaVersion: 1, + channel: "stable", + version: "1.0.0", + release: "v1.0.0", + downloads: { "macos-arm64": ARM64_DMG_URL }, + }), + contentType: "text/plain; charset=utf-8", + headers: { "access-control-allow-origin": "*" }, +}; + +// The main window cannot go below 900px wide, and Cmd+ zoom tops out at 1.5x, +// which leaves the banner the room of a 600px window at default zoom. +const MIN_WINDOW_WIDTH = 900; +const MIN_WIDTH_AT_MAX_ZOOM = 600; +// Enough for the message to read as a sentence instead of a one-word column. +const MIN_MESSAGE_WIDTH = 240; + +const MIB = 1024 * 1024; +const INSTALLER_FILE = "Buzz_1.0.0_aarch64.dmg"; + +/** Payload URLs of every call to `command`, in order. */ +function urlsSentTo(page: Page, command: string) { + return page.evaluate( + (name) => + ((window as BridgeWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) + .filter((call) => call.command === name) + .map((call) => call.payload?.url), + command, + ); +} + +function openedUrls(page: Page) { + return urlsSentTo(page, "plugin:opener|open_url"); +} + +function downloadRequests(page: Page) { + return urlsSentTo(page, "download_new_app_installer"); +} + +async function commandCount(page: Page, command: string) { + return (await urlsSentTo(page, command)).length; +} + +function isMockDownloadActive(page: Page) { + return page.evaluate(() => + (window as BridgeWindow).__BUZZ_E2E_INSTALLER_DOWNLOAD__?.isActive(), + ); +} + +function emitProgress( + page: Page, + receivedBytes: number, + totalBytes: number | null, +) { + return page.evaluate( + ([received, total]) => + (window as BridgeWindow).__BUZZ_E2E_INSTALLER_DOWNLOAD__?.progress( + received as number, + total, + ), + [receivedBytes, totalBytes] as const, + ); +} + +function finishDownload(page: Page) { + return page.evaluate(() => + (window as BridgeWindow).__BUZZ_E2E_INSTALLER_DOWNLOAD__?.finish(), + ); +} + +function failDownload(page: Page, reason: InstallerFailureReason) { + return page.evaluate( + (why) => + (window as BridgeWindow).__BUZZ_E2E_INSTALLER_DOWNLOAD__?.fail(why), + reason, + ); +} + +const COMMUNITY_A = { + id: "ws-a", + name: "Alpha", + relayUrl: "ws://localhost:3000", + addedAt: "2026-01-01T00:00:00.000Z", +}; +const COMMUNITY_B = { + id: "ws-b", + name: "Bravo", + relayUrl: "ws://localhost:3001", + addedAt: "2026-01-02T00:00:00.000Z", +}; + +/** + * Shows the banner with this machine's installer resolved. With + * `twoCommunities`, the app has a second community to switch to. + */ +async function openBannerWithInstaller( + page: Page, + { twoCommunities = false } = {}, +) { + await page.route(BUZZ_APP_DOWNLOADS_MANIFEST_URL, (route) => + route.fulfill(MANIFEST_RESPONSE), + ); + await installMockBridge( + page, + { newAppInstallerTarget: "macos-arm64" }, + { + seedDeprecationBannerDismissed: false, + skipCommunitySeed: twoCommunities, + }, + ); + if (twoCommunities) { + await page.addInitScript( + ({ list, active }) => { + window.localStorage.setItem("buzz-communities", JSON.stringify(list)); + window.localStorage.setItem("buzz-active-community-id", active); + }, + { list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id }, + ); + } + await page.goto("/"); + const download = page + .getByTestId("deprecation-banner") + .getByRole("button", { name: /^Upgrade,/ }); + await expect(download).toHaveAttribute("data-download-source", "installer"); + return download; +} + +/** + * Presses Enter on the focused dialog action and keeps it down past the + * close. Focus returns to the banner button while the key is still held, and + * the key's repeat must not press that button: no download starts, nothing + * more is opened, and no dialog comes back. + */ +async function closeDialogWithHeldEnter(page: Page, download: Locator) { + const requests = (await downloadRequests(page)).length; + await page.keyboard.down("Enter"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(download).toBeFocused(); + const opened = await openedUrls(page); + await page.keyboard.down("Enter"); + await page.keyboard.up("Enter"); + expect(await downloadRequests(page)).toHaveLength(requests); + expect(await openedUrls(page)).toEqual(opened); + await expect(page.getByRole("dialog")).toHaveCount(0); +} + +/** Counts the banner's fetches of the installer manifest: one per mount. */ +function countManifestRequests(page: Page) { + let count = 0; + page.on("request", (request) => { + if (request.url() === BUZZ_APP_DOWNLOADS_MANIFEST_URL) { + count += 1; + } + }); + return () => count; +} + +/** + * Switches to `community` with a press dispatched straight at its rail + * button, as a deep link or a signer change would land: the open modal keeps + * the pointer off the rail. + */ +async function switchCommunityBehindDialog( + page: Page, + community: { id: string }, +) { + await page + .getByTestId(`community-rail-button-${community.id}`) + .evaluate((button: HTMLElement) => button.click()); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-active-community-id"), + ), + ) + .toBe(community.id); +} + +async function boxOf(locator: Locator) { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + return box ?? { x: 0, y: 0, width: 0, height: 0 }; +} + +test("deprecation banner: both links open externally and the row holds up when narrow", async ({ + page, +}) => { + await installMockBridge(page, undefined, { + seedDeprecationBannerDismissed: false, + }); + await page.goto("/"); + + const banner = page.getByTestId("deprecation-banner"); + const message = banner.getByTestId("deprecation-banner-message"); + // "More info" alone says little out of context, so the accessible name adds + // the topic and that the link leaves the app. + const upgradeGuide = banner.getByRole("button", { + exact: true, + name: "More info about upgrading, opens in your browser", + }); + const download = banner.getByRole("button", { name: /^Upgrade,/ }); + const dismiss = banner.getByRole("button", { name: "Remind me later" }); + await expect(banner).toBeVisible(); + await expect(upgradeGuide).toHaveText("More info"); + // The default mock reports no installer target, so the banner never asks + // for the manifest and the download button keeps the website link. + await expect(download).toHaveAttribute("data-download-source", "fallback"); + // Without an installer the button is a link out, and says so. + await expect(download).toHaveAccessibleName("Upgrade, opens in your browser"); + await expect(download).toHaveText("Upgrade"); + await expect(download).not.toHaveAttribute("aria-haspopup"); + // The two actions carry the same classes, so neither outweighs the other + // at rest or on hover in any theme. Neither has an icon. + expect(await upgradeGuide.getAttribute("class")).toBe( + await download.getAttribute("class"), + ); + await expect(upgradeGuide.locator("svg")).toHaveCount(0); + await expect(download.locator("svg")).toHaveCount(0); + + // Pointer and keyboard both reach the opener, each with its own link. + await upgradeGuide.click(); + await download.focus(); + await page.keyboard.press("Enter"); + expect(await openedUrls(page)).toEqual([UPGRADE_GUIDE_URL, WEBSITE_URL]); + // The website link never starts an in-app download. + expect(await downloadRequests(page)).toEqual([]); + await expect(page.getByRole("dialog")).toHaveCount(0); + + for (const width of [MIN_WINDOW_WIDTH, MIN_WIDTH_AT_MAX_ZOOM]) { + await page.setViewportSize({ width, height: 650 }); + const bannerBox = await boxOf(banner); + expect(bannerBox.x + bannerBox.width, `banner at ${width}`).toBe(width); + expect( + (await boxOf(message)).width, + `message at ${width}`, + ).toBeGreaterThanOrEqual(MIN_MESSAGE_WIDTH); + + // Every control stays whole, inside the banner, and clear of the others. + const controls = [ + await boxOf(upgradeGuide), + await boxOf(download), + await boxOf(dismiss), + ]; + for (const [index, box] of controls.entries()) { + expect(box.x, `control ${index} at ${width}`).toBeGreaterThanOrEqual( + bannerBox.x, + ); + expect( + box.x + box.width, + `control ${index} at ${width}`, + ).toBeLessThanOrEqual(bannerBox.x + bannerBox.width); + const previous = controls[index - 1]; + if (previous) { + expect(box.x, `control ${index} at ${width}`).toBeGreaterThanOrEqual( + previous.x + previous.width, + ); + } + } + } + + await dismiss.click(); + await expect(banner).toBeHidden(); +}); + +test("deprecation banner: the installer downloads in a dialog and the saved file is revealed", async ({ + page, +}) => { + const download = await openBannerWithInstaller(page); + // With an installer the button downloads in the app, and says so. + await expect(download).toHaveAccessibleName( + "Upgrade, downloads the installer", + ); + await expect(download).toHaveAttribute("aria-haspopup", "dialog"); + + await download.click(); + const dialog = page.getByRole("dialog", { + name: "Downloading the new Buzz app", + }); + await expect(dialog).toBeVisible(); + // The download runs in the app: the backend gets the URL, the browser nothing. + expect(await downloadRequests(page)).toEqual([ARM64_DMG_URL]); + expect(await openedUrls(page)).toEqual([]); + await expect(dialog.getByTestId("new-app-installer-description")).toHaveText( + `Saving ${INSTALLER_FILE} to your Downloads folder.`, + ); + // Focus moves into the dialog, onto the one action a running download has. + const cancel = dialog.getByRole("button", { name: "Cancel" }); + await expect(cancel).toBeFocused(); + + // No total yet: the bar is indeterminate and only the byte count shows. + const bar = dialog.getByRole("progressbar", { name: "Download progress" }); + const progressText = dialog.getByTestId("new-app-installer-progress-text"); + await expect(bar).not.toHaveAttribute("aria-valuenow"); + await emitProgress(page, 20 * MIB, null); + await expect(progressText).toHaveText("20.0 MB downloaded"); + await expect(bar).not.toHaveAttribute("aria-valuenow"); + + // With a total: bytes against the total, percent, and a bar with a value. + await emitProgress(page, 39.5 * MIB, 79 * MIB); + await expect(progressText).toHaveText("39.5 MB of 79.0 MB (50%)"); + await expect(bar).toHaveAttribute("aria-valuenow", "50"); + await expect(bar).toHaveAttribute( + "aria-valuetext", + "39.5 MB of 79.0 MB (50%)", + ); + // Located on the page: the dialog's name changes when the download ends. + const announcement = page.getByTestId("new-app-installer-announcement"); + await expect(announcement).toHaveAttribute("aria-live", "polite"); + await expect(announcement).toHaveText( + `Downloading ${INSTALLER_FILE}, 50% done.`, + ); + // A tick inside the same quarter changes the bar, not what is announced. + await emitProgress(page, 45 * MIB, 79 * MIB); + await expect(bar).toHaveAttribute("aria-valuenow", "56"); + await expect(announcement).toHaveText( + `Downloading ${INSTALLER_FILE}, 50% done.`, + ); + + // A second activation while the download runs does not start another one. + // The modal hides the banner from the accessibility tree and from the + // pointer, so the press is dispatched straight at the element. + await page + .getByTestId("deprecation-banner-download") + .evaluate((button: HTMLButtonElement) => button.click()); + await emitProgress(page, 50 * MIB, 79 * MIB); + await expect(bar).toHaveAttribute("aria-valuenow", "63"); + expect(await downloadRequests(page)).toEqual([ARM64_DMG_URL]); + + // Finished: the dialog says where the file is and the app reveals it once. + expect(await commandCount(page, "reveal_new_app_installer")).toBe(0); + await finishDownload(page); + const finished = page.getByRole("dialog", { + name: "The new Buzz app is downloaded", + }); + await expect(finished).toBeVisible(); + await expect( + finished.getByTestId("new-app-installer-description"), + ).toHaveText( + `${INSTALLER_FILE} is in your Downloads folder. Open it when you're ready to install.`, + ); + await expect(finished.getByTestId("new-app-installer-path")).toHaveText( + "/Users/e2e/Downloads", + ); + await expect(announcement).toHaveText( + `Download finished. ${INSTALLER_FILE} is in your Downloads folder.`, + ); + await expect + .poll(() => commandCount(page, "reveal_new_app_installer")) + .toBe(1); + await expect(finished.getByRole("button", { name: "Cancel" })).toHaveCount(0); + + // The reveal button is worded for this platform's file manager and works + // from the keyboard; it reveals the same file again. + const expectedRevealLabel = await page.evaluate(() => { + if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { + return "Show in Finder"; + } + return /^win/i.test(navigator.platform) + ? "Show in File Explorer" + : "Show in folder"; + }); + const reveal = finished.getByTestId("new-app-installer-reveal"); + await expect(reveal).toHaveText(expectedRevealLabel); + await expect(finished.getByRole("button", { name: "Done" })).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(reveal).toBeFocused(); + await page.keyboard.press("Enter"); + await expect + .poll(() => commandCount(page, "reveal_new_app_installer")) + .toBe(2); + const revealFailed = finished.getByTestId("new-app-installer-reveal-failed"); + await expect(revealFailed).toHaveCount(0); + + // When the file manager does not open, the dialog says only that, next to + // the path: the file is still where it was saved. + await page.evaluate(() => + (window as BridgeWindow).__BUZZ_E2E_INSTALLER_DOWNLOAD__?.failNextReveal(), + ); + await page.keyboard.press("Enter"); + await expect(revealFailed).toHaveText( + "Buzz couldn't open the folder. The installer is in the folder shown above.", + ); + await expect(revealFailed).toHaveRole("alert"); + await expect(finished.getByTestId("new-app-installer-path")).toHaveText( + "/Users/e2e/Downloads", + ); + // A reveal that works clears the message. + await page.keyboard.press("Enter"); + await expect(revealFailed).toHaveCount(0); + expect(await commandCount(page, "reveal_new_app_installer")).toBe(4); + + // Closing returns focus to the button that opened the dialog. + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(download).toBeFocused(); + expect(await openedUrls(page)).toEqual([]); + expect(await isMockDownloadActive(page)).toBe(false); +}); + +test("deprecation banner: a failed download leads with the action that can still work", async ({ + page, +}) => { + const download = await openBannerWithInstaller(page); + await download.click(); + await expect( + page.getByRole("dialog", { name: "Downloading the new Buzz app" }), + ).toBeVisible(); + + await failDownload(page, "network"); + const failed = page.getByRole("dialog", { + name: "The download didn't finish", + }); + await expect(failed).toBeVisible(); + await expect(failed.getByTestId("new-app-installer-description")).toHaveText( + "The download stopped before it was complete. Check your internet connection and try again. You can also get the new app from buzz.xyz.", + ); + await expect(failed.getByRole("progressbar")).toHaveCount(0); + + // "Try again" takes focus and starts the same download again by keyboard. + await expect(failed.getByRole("button", { name: "Try again" })).toBeFocused(); + const cancelsBeforeRetry = await commandCount( + page, + "cancel_new_app_installer_download", + ); + await page.keyboard.down("Enter"); + const retrying = page.getByRole("dialog", { + name: "Downloading the new Buzz app", + }); + await expect(retrying).toBeVisible(); + expect(await downloadRequests(page)).toEqual([ARM64_DMG_URL, ARM64_DMG_URL]); + // Focus follows to Cancel, the one action a running download has. Enter is + // still held, and its repeat must not cancel the retry it just started. + await expect(retrying.getByRole("button", { name: "Cancel" })).toBeFocused(); + await page.keyboard.down("Enter"); + await page.keyboard.up("Enter"); + await emitProgress(page, 10 * MIB, 79 * MIB); + await expect(retrying.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "12", + ); + expect(await isMockDownloadActive(page)).toBe(true); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancelsBeforeRetry, + ); + + // A second failure names its own cause, and the website is one click away. + await failDownload(page, "storage"); + await expect( + failed.getByTestId("new-app-installer-description"), + ).toContainText("couldn't be saved to your Downloads folder"); + await failed + .getByRole("button", { name: "Open buzz.xyz in your browser" }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await openedUrls(page)).toEqual([WEBSITE_URL]); + expect(await downloadRequests(page)).toHaveLength(2); + + // A failure that would repeat leads with the browser, which gets the same + // installer its own way. There is no "Try again" to walk back into it. + const retry = failed.getByRole("button", { name: "Try again" }); + const inBrowser = failed.getByRole("button", { + name: "Download the installer in your browser", + }); + const website = failed.getByRole("button", { + name: "Open buzz.xyz in your browser", + }); + const description = failed.getByTestId("new-app-installer-description"); + for (const [reason, message] of [ + [ + "protection", + "Buzz couldn't mark the installer as downloaded from the internet, so it didn't keep the file. You can download the installer in your browser instead.", + ], + [ + "redirect-blocked", + "The download was sent to a site Buzz doesn't recognise, so Buzz stopped it. You can download the installer in your browser instead.", + ], + ] as const) { + await download.click(); + await failDownload(page, reason); + await expect(description).toHaveText(message); + await expect(inBrowser).toBeFocused(); + await expect(inBrowser).toHaveText("Download in browser"); + await expect(retry).toHaveCount(0); + await expect(website).toBeVisible(); + // Enter held on "Download in browser" must not start the download again + // once focus is back on the banner button. + await closeDialogWithHeldEnter(page, download); + } + expect(await openedUrls(page)).toEqual([ + WEBSITE_URL, + ARM64_DMG_URL, + ARM64_DMG_URL, + ]); + + // Without access to the folder the browser still leads, and "Try again" + // stays for the user who has just granted it. + await download.click(); + await failDownload(page, "permission-denied"); + await expect(description).toContainText( + "Buzz isn't allowed to save to your Downloads folder.", + ); + await expect(description).not.toContainText("free space"); + await expect(inBrowser).toBeFocused(); + await expect(website).toHaveCount(0); + await retry.click(); + await expect( + page.getByRole("dialog", { name: "Downloading the new Buzz app" }), + ).toBeVisible(); + expect(await downloadRequests(page)).toHaveLength(6); + + // A link Buzz refused is never opened, in the app or the browser. + await failDownload(page, "rejected"); + await expect(description).toHaveText( + "Buzz couldn't use the download link for this computer, so nothing was downloaded. You can get the new app from buzz.xyz.", + ); + await expect(website).toBeFocused(); + await expect(retry).toHaveCount(0); + await expect(inBrowser).toHaveCount(0); + // The same for Enter held on "Open buzz.xyz". + await closeDialogWithHeldEnter(page, download); + expect(await openedUrls(page)).toEqual([ + WEBSITE_URL, + ARM64_DMG_URL, + ARM64_DMG_URL, + WEBSITE_URL, + ]); +}); + +test("deprecation banner: Cancel and Escape stop a running download, a stray click does not", async ({ + page, +}) => { + const download = await openBannerWithInstaller(page); + // Loading the page clears any download a previous page left running. + const cancelsAtRest = await commandCount( + page, + "cancel_new_app_installer_download", + ); + + // Enter held on the banner button starts one download. Focus moves to + // Cancel while the key is still down, and its repeat must not cancel. + await download.focus(); + await page.keyboard.down("Enter"); + const dialog = page.getByRole("dialog", { + name: "Downloading the new Buzz app", + }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await page.keyboard.down("Enter"); + await page.keyboard.up("Enter"); + await emitProgress(page, 10 * MIB, 79 * MIB); + await expect(dialog.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "12", + ); + expect(await isMockDownloadActive(page)).toBe(true); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancelsAtRest, + ); + + // Escape cancels the download; the dialog closes once it has stopped. + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await isMockDownloadActive(page)).toBe(false); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancelsAtRest + 1, + ); + await expect(download).toBeFocused(); + + // A click beside the dialog leaves the download running. + await download.click(); + await expect(dialog).toBeVisible(); + await emitProgress(page, 10 * MIB, 79 * MIB); + await page.mouse.click(8, 8); + await expect(dialog).toBeVisible(); + expect(await isMockDownloadActive(page)).toBe(true); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancelsAtRest + 1, + ); + + // The Cancel button stops it. + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await isMockDownloadActive(page)).toBe(false); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancelsAtRest + 2, + ); + + // A cancel that loses the race with the final move does not close the + // dialog on a file the user now has: it says where the file is. + await download.click(); + await expect(dialog).toBeVisible(); + await page.evaluate(() => + ( + window as BridgeWindow + ).__BUZZ_E2E_INSTALLER_DOWNLOAD__?.finishOnNextCancel(), + ); + await dialog.getByRole("button", { name: "Cancel" }).click(); + const finished = page.getByRole("dialog", { + name: "The new Buzz app is downloaded", + }); + await expect(finished).toBeVisible(); + await expect( + finished.getByTestId("new-app-installer-description"), + ).toHaveText( + `${INSTALLER_FILE} is in your Downloads folder. Open it when you're ready to install.`, + ); + await expect(finished.getByTestId("new-app-installer-path")).toHaveText( + "/Users/e2e/Downloads", + ); + await expect(finished.getByRole("button", { name: "Done" })).toBeFocused(); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancelsAtRest + 3, + ); + await expect + .poll(() => commandCount(page, "reveal_new_app_installer")) + .toBe(1); + // Enter held on Done closes the dialog and must not download a second copy. + await closeDialogWithHeldEnter(page, download); + + // Each press started exactly one download, and nothing went to the browser. + expect(await downloadRequests(page)).toEqual([ + ARM64_DMG_URL, + ARM64_DMG_URL, + ARM64_DMG_URL, + ]); + expect(await openedUrls(page)).toEqual([]); +}); + +test("deprecation banner: a running download and its dialog survive a community switch", async ({ + page, +}) => { + const manifestRequests = countManifestRequests(page); + const download = await openBannerWithInstaller(page, { + twoCommunities: true, + }); + expect(manifestRequests()).toBe(1); + await download.click(); + const dialog = page.getByRole("dialog", { + name: "Downloading the new Buzz app", + }); + await expect(dialog).toBeVisible(); + await emitProgress(page, 20 * MIB, 79 * MIB); + const cancels = await commandCount(page, "cancel_new_app_installer_download"); + + // The switch remounts everything under the community, the banner included. + await switchCommunityBehindDialog(page, COMMUNITY_B); + await expect( + page.getByTestId(`community-rail-active-${COMMUNITY_B.id}`), + ).toBeVisible(); + // The banner fetches the manifest once per mount, so a second fetch is the + // new banner: the one that started the download is gone. + await expect.poll(manifestRequests).toBe(2); + + // The same download is still running, in the same dialog, where it was. + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "25", + ); + expect(await isMockDownloadActive(page)).toBe(true); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancels, + ); + expect(await downloadRequests(page)).toEqual([ARM64_DMG_URL]); + + // It keeps progressing and finishes in the new community. + await emitProgress(page, 60 * MIB, 79 * MIB); + await expect(dialog.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "75", + ); + await finishDownload(page); + await expect( + page.getByRole("dialog", { name: "The new Buzz app is downloaded" }), + ).toBeVisible(); + await expect + .poll(() => commandCount(page, "reveal_new_app_installer")) + .toBe(1); +}); + +test("deprecation banner: a download whose banner is gone keeps its dialog and its Cancel", async ({ + page, +}) => { + const download = await openBannerWithInstaller(page, { + twoCommunities: true, + }); + await download.click(); + const dialog = page.getByRole("dialog", { + name: "Downloading the new Buzz app", + }); + await expect(dialog).toBeVisible(); + await emitProgress(page, 20 * MIB, 79 * MIB); + const cancels = await commandCount(page, "cancel_new_app_installer_download"); + + // The switch fails, so the app parks on the connection-failed screen. No + // shell is mounted there, and so no banner. + await page.evaluate(() => { + (window as BridgeWindow).__BUZZ_E2E_APPLY_COMMUNITY_ERROR__ = + "relay unreachable"; + }); + await switchCommunityBehindDialog(page, COMMUNITY_B); + const connectionFailed = page.getByTestId("community-apply-error"); + await expect(connectionFailed).toBeVisible(); + await expect(page.getByTestId("deprecation-banner")).toHaveCount(0); + + // The download is not left running out of sight: its dialog is still up, + // still modal, and still follows the progress. + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "25", + ); + expect(await isMockDownloadActive(page)).toBe(true); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancels, + ); + await emitProgress(page, 60 * MIB, 79 * MIB); + await expect(dialog.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "75", + ); + + // Cancel is still there and stops it, by keyboard. + const cancel = dialog.getByRole("button", { name: "Cancel" }); + await cancel.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await isMockDownloadActive(page)).toBe(false); + expect(await commandCount(page, "cancel_new_app_installer_download")).toBe( + cancels + 1, + ); + expect(await downloadRequests(page)).toEqual([ARM64_DMG_URL]); + + // The screen behind the dialog works again: Retry connects, and the banner + // that comes back offers the download as before. + await page.evaluate(() => { + (window as BridgeWindow).__BUZZ_E2E_APPLY_COMMUNITY_ERROR__ = undefined; + }); + await connectionFailed.getByRole("button", { name: "Retry" }).click(); + await expect( + page.getByTestId(`community-rail-active-${COMMUNITY_B.id}`), + ).toBeVisible(); + await expect(download).toHaveAttribute("data-download-source", "installer"); + await download.click(); + await expect(dialog).toBeVisible(); + expect(await downloadRequests(page)).toEqual([ARM64_DMG_URL, ARM64_DMG_URL]); +}); + +test("deprecation banner: the download button opens the website while the manifest is still loading", async ({ + page, +}) => { + let releaseManifest = () => {}; + const manifestReleased = new Promise((resolve) => { + releaseManifest = resolve; + }); + await page.route(BUZZ_APP_DOWNLOADS_MANIFEST_URL, async (route) => { + await manifestReleased; + await route.fulfill(MANIFEST_RESPONSE); + }); + await installMockBridge( + page, + { newAppInstallerTarget: "macos-arm64" }, + { seedDeprecationBannerDismissed: false }, + ); + const manifestRequested = page.waitForRequest( + BUZZ_APP_DOWNLOADS_MANIFEST_URL, + ); + await page.goto("/"); + + const download = page + .getByTestId("deprecation-banner") + .getByRole("button", { name: /^Upgrade,/ }); + // The request is in flight and unanswered: the click does not wait for it. + await manifestRequested; + await download.click(); + expect(await openedUrls(page)).toEqual([WEBSITE_URL]); + expect(await downloadRequests(page)).toEqual([]); + await expect(download).toHaveAttribute("data-download-source", "fallback"); + + // Once answered, the same button downloads the installer in the app. + releaseManifest(); + await expect(download).toHaveAttribute("data-download-source", "installer"); + await download.click(); + await expect( + page.getByRole("dialog", { name: "Downloading the new Buzz app" }), + ).toBeVisible(); + expect(await downloadRequests(page)).toEqual([ARM64_DMG_URL]); + expect(await openedUrls(page)).toEqual([WEBSITE_URL]); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 1e91473614..a7a0933ba0 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,6 +1,7 @@ import type { Page } from "@playwright/test"; import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; import type { MockManagedAgentSeed } from "../../src/testing/e2eBridge"; +import { DEPRECATION_BANNER_STORAGE_KEY } from "../../src/app/deprecationBannerVisibility"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { @@ -399,6 +400,9 @@ type MockBridgeOptions = { /** Set to false to simulate a Linux .deb install where auto-update is not * supported. Defaults to true. See e2eBridge mock.autoUpdateSupported. */ autoUpdateSupported?: boolean; + /** Manifest key the deprecation banner resolves its installer link with. + * Defaults to none. See e2eBridge mock.newAppInstallerTarget. */ + newAppInstallerTarget?: string; /** Reject browser opener calls to exercise manual pairing fallback UI. */ openerError?: string; /** Delay binding signatures so specs can exercise request supersession. */ @@ -668,6 +672,12 @@ type BridgeOptions = { * that exercise the Experiments toggle UI itself. */ seedPreviewFeatures?: boolean; + /** + * When true (default), seed the deprecation banner as just dismissed so it + * stays out of existing layout and visual baselines. Set to false in specs + * that exercise the banner itself. + */ + seedDeprecationBannerDismissed?: boolean; user?: keyof typeof TEST_IDENTITIES; }; @@ -873,6 +883,12 @@ async function seedDefaultCommunity( ); } +async function seedDeprecationBannerDismissed(page: Page) { + await page.addInitScript((key) => { + window.localStorage.setItem(key, String(Date.now())); + }, DEPRECATION_BANNER_STORAGE_KEY); +} + async function seedPreviewFeaturesEnabled(page: Page) { await page.addInitScript( ({ key, ids }) => { @@ -908,6 +924,9 @@ export async function installBridge(page: Page, options: BridgeOptions) { if (options.seedPreviewFeatures !== false) { await seedPreviewFeaturesEnabled(page); } + if (options.seedDeprecationBannerDismissed !== false) { + await seedDeprecationBannerDismissed(page); + } await page.addInitScript( ({ @@ -1012,6 +1031,7 @@ export async function installMockBridge( skipOnboardingSeed?: boolean; skipCommunitySeed?: boolean; seedPreviewFeatures?: boolean; + seedDeprecationBannerDismissed?: boolean; }, ) { await installBridge(page, { @@ -1022,6 +1042,7 @@ export async function installMockBridge( skipOnboardingSeed: options?.skipOnboardingSeed, skipCommunitySeed: options?.skipCommunitySeed, seedPreviewFeatures: options?.seedPreviewFeatures, + seedDeprecationBannerDismissed: options?.seedDeprecationBannerDismissed, }); }