feat(desktop): add dismissible banner pointing to the new buzz app (#9379)

## Summary

The Buzz 1.0 desktop app now lives in
[block/buzz-app](https://github.com/block/buzz-app). This adds a banner
to the 0.X desktop app that tells users, and lets them download the new
app's installer without leaving the app.

- A full-width banner sits under the top chrome: "A new Buzz app is
here! This version is being retired, so grab the new one whenever you
get a chance."
- **Upgrade** downloads the installer for this machine inside the app. A
dialog shows the progress, then the saved file's name and folder with
**Show in Finder** (worded per platform) and **Done**. The file goes to
the Downloads folder and is revealed in the file manager. It is never
opened, mounted or run.
- **Cancel**, Escape or the dialog's close button stops a running
download and removes the partial file.
- When no installer can be resolved for this machine (unsupported
platform, or the manifest fetch fails or is slow), **Upgrade** opens
`https://buzz.xyz` in the browser instead.
- **More info** opens the [upgrade
guide](https://github.com/block/buzz-app/blob/main/docs/upgrading.md) in
the browser.
- Dismissing (×, labelled "Remind me later") stores a timestamp in
`localStorage`. The banner is read once per mount, so it comes back on
the next launch (or community switch) once 24h have passed.
- It is hidden in the huddle room window.

## Demo


https://github.com/user-attachments/assets/1f1916e8-f32d-4ec0-8e60-fe7c85dd4a57

Recorded on macOS (Apple Silicon) from a native debug build with the
real Rust backend: a full download, **Done**, then a second download
cancelled part-way.

- The network is throttled through a local proxy so the progress stays
visible. Unthrottled, the installer arrives in about two seconds.
- The app was launched with a scratch home folder, so the path in the
dialog is a scratch path, and the Finder window that the reveal opens is
left out of the recording.
- Presses are accessibility presses, marked with an orange ring. The OS
cursor is not shown.

## How the download works

- **Which installer:** a Tauri command reports this install's key
(`macos-arm64`, `macos-x64`, `windows-x64`, `linux-appimage` or
`linux-deb`). The webview reads the matching entry from the [buzz-app
download
manifest](https://raw.githubusercontent.com/block/buzz-app/download-feed/downloads.json)
once per banner mount.
- **Validation:** Rust re-validates the URL on every call. It must be
`https://github.com/block/buzz-app/releases/download/<tag>/<file>`, with
a restricted tag and file-name character set and the extension this
machine installs. The webview names a URL and nothing else.
- **Redirects:** a dedicated HTTPS-only client follows at most 5
redirects, and only to `github.com`, `objects.githubusercontent.com` or
`release-assets.githubusercontent.com`.
- **Limits:** 512 MiB, 15 s to connect, 30 s for headers, 30 s without a
byte, 60 min in total. One download at a time.
- **Saving:** the body is written to `<name>.<random>.part` in the
Downloads folder, then renamed to `<name>` (or `<name> (1)` and so on)
without ever replacing an existing file. The partial file is removed on
failure, on cancel and at app exit. A partial left by a crash or
force-quit is removed on the next launch.
- **Downloaded-from-the-internet marking:** macOS gets the
`com.apple.quarantine` attribute a browser would write, and Windows gets
the Mark of the Web (`Zone.Identifier`). If the mark cannot be written,
the download fails and the file is not kept.
- **Failures:** the dialog says what went wrong in plain words.
Transient failures lead with **Try again**. Failures a retry cannot fix
lead with **Download in browser**, which opens the validated installer
URL.

## Testing

- Unit: `deprecationBannerVisibility.test.mjs`,
`newAppInstaller.test.mjs` (manifest and URL rules),
`newAppInstallerDownload.test.mjs` (dialog state), and a jsdom test of
the download hook.
- Rust: `new_app_installer_tests.rs` covers URL validation, the redirect
policy, limits, naming without overwrite, cancel and shutdown cleanup,
launch cleanup, and the quarantine and Mark of the Web values. CI runs
this crate's tests on Linux and on `windows-latest`, so the Mark of the
Web test writes a real alternate data stream there. The quarantine test
runs on macOS only, which CI builds but does not test.
- Smoke: `tests/e2e/deprecation-banner.spec.ts` drives the banner, the
download dialog, each failure's actions, cancel and the keyboard paths
against the mock bridge.
- Native, macOS arm64 debug build: the download was saved with
`com.apple.quarantine` set and revealed in Finder; cancel removed the
partial; the download kept running while the window was hidden; SIGTERM
and a menu quit mid-download removed the partial; after a force kill the
next launch removed it.

Not verified by hand:

- The Windows and Linux flows in a running app, including SmartScreen on
Windows.
- The Gatekeeper prompt when opening the downloaded DMG, and the macOS
Downloads-folder consent prompt, which need a signed build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Kalvin C
2026-11-13 00:08:25 -08:00
committed by GitHub
parent 3d3f9cf153
commit 1fc6a71ef9
29 changed files with 4952 additions and 0 deletions

View File

@@ -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",

View File

@@ -1207,6 +1207,7 @@ dependencies = [
"webkit2gtk",
"window-vibrancy",
"windows-sys 0.61.2",
"xattr",
"zeroize",
"zip 9.0.5",
]

View File

@@ -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"] }

View File

@@ -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::*;

View File

@@ -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<u64>,
},
}
/// 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<InstallerSource> {
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<reqwest::Client> {
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<Instant>,
}
impl ProgressThrottle {
pub(super) fn admit(&mut self, received: u64, total: Option<u64>, 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<u64>),
) -> Result<u64, InstallerFailure> {
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<PathBuf, InstallerFailure> {
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 `<file name>.<random>.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 `<file name>.<random>.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<PathBuf, TransferError> {
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<PathBuf>,
home: Option<PathBuf>,
is_linux: bool,
) -> Option<PathBuf> {
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:
/// `<installer name>.<random>.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<Active>,
last_saved: Option<PathBuf>,
}
struct Active {
cancel: CancellationToken,
partial: Option<PathBuf>,
}
/// The one installer download the app runs at a time, and the last file it
/// saved.
#[derive(Default)]
pub struct InstallerDownloads {
slots: Mutex<Slots>,
client: OnceLock<Option<reqwest::Client>>,
/// Where the running download's partial file is recorded. Unset until
/// [`InstallerDownloads::recover`] runs.
partial_record: OnceLock<PathBuf>,
}
/// 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<ActiveDownload<'_>> {
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<PathBuf> {
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<InstallerDownloadEvent>,
) -> 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<InstallerDownloadEvent>,
) -> InstallerDownloadOutcome {
let downloads = app.state::<InstallerDownloads>();
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::<InstallerDownloads>().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::<InstallerDownloads>().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::<InstallerDownloads>()
.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;

View File

@@ -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<String> {
let mut names: Vec<String> = 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<PathBuf, TransferError>,
events: Vec<InstallerDownloadEvent>,
marked: Vec<PathBuf>,
}
/// 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::<String>::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(&not_a_folder, b"a file").expect("file");
let run_in = run(addr, "/ok", &not_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::<String>::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::<String>::new(), "{path}");
assert_eq!(run.marked, Vec::<PathBuf>::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::<String>::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::<String>::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::<String>::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());
}

View File

@@ -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}"
);
}
}
}

View File

@@ -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::<InstallerDownloads>().cancel(),
RunEvent::ExitRequested { code, .. } => {
if is_restart_request(code) {
restart_requested.store(true, Ordering::SeqCst);

View File

@@ -21,6 +21,9 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a
prevent_sleep::release(&app.state::<AppState>().prevent_sleep);
crate::observed_unread::flush(app);
crate::channel_head_cache::flush(app);
if let Some(downloads) = app.try_state::<crate::commands::InstallerDownloads>() {
downloads.abort_for_shutdown();
}
app.state::<crate::terminal_runtime::TerminalSessions>()
.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::<crate::commands::InstallerDownloads>() {
downloads.abort_for_shutdown();
}
app.state::<crate::terminal_runtime::TerminalSessions>()
.shutdown_all();
let _ = shutdown_managed_agents(&app);

View File

@@ -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 ? <DeprecationBanner /> : null}
{settingsOpen ? (
<div className="flex min-h-0 flex-1 overflow-hidden">
<React.Suspense fallback={null}>

View File

@@ -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<string | null> {
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<string | null>(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 (
<div
className="flex shrink-0 items-center gap-3 border-b border-primary/20 bg-primary/10 px-4 py-2 text-sm text-foreground"
data-testid="deprecation-banner"
role="status"
>
<Sparkles aria-hidden className="size-4 shrink-0 text-primary" />
{/* The actions wrap under the message when the row gets too narrow. */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1.5">
<p
className="min-w-0 grow basis-64"
data-testid="deprecation-banner-message"
>
A new Buzz app is here! This version is being retired, so grab the new
one whenever you get a chance.
</p>
<div className="flex shrink-0 items-center gap-2">
<Button
aria-label="More info about upgrading, opens in your browser"
className={BANNER_ACTION_CLASS}
data-testid="deprecation-banner-upgrade-guide"
onClick={() => {
void openUrl(BUZZ_APP_UPGRADE_GUIDE_URL);
}}
size="sm"
type="button"
variant="outline"
>
More info
</Button>
<NewAppDownloadButton
className={BANNER_ACTION_CLASS}
installerUrl={installerUrl}
/>
</div>
</div>
<Button
aria-label="Remind me later"
className="size-7 shrink-0 text-muted-foreground"
data-testid="deprecation-banner-dismiss"
onClick={handleDismiss}
size="icon"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
</div>
);
}

View File

@@ -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 (
<Button
aria-haspopup={downloadsInApp ? "dialog" : undefined}
aria-label={
downloadsInApp
? "Upgrade, downloads the installer"
: "Upgrade, opens in your browser"
}
className={className}
data-download-source={downloadsInApp ? "installer" : "fallback"}
data-testid="deprecation-banner-download"
onClick={() => {
if (installerUrl) {
startNewAppInstallerDownload(installerUrl);
} else {
void openUrl(BUZZ_APP_WEBSITE_URL);
}
}}
// Closing the dialog with Enter returns focus here with the key still
// down. Its repeat must not start the download again.
onKeyDown={ignoreRepeatedEnter}
ref={newAppInstallerTriggerRef}
size="sm"
type="button"
variant="outline"
>
Upgrade
</Button>
);
}

View File

@@ -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<HTMLElement | null>;
/** 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<HTMLButtonElement>(null);
const primaryActionRef = React.useRef<HTMLButtonElement>(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<FailureAction, () => void> = {
retry: onRetry,
browser: onDownloadInBrowser,
website: onOpenWebsite,
};
const failureButton = (action: FailureAction, isPrimary: boolean) => (
<Button
aria-label={FAILURE_BUTTONS[action].ariaLabel}
data-testid={FAILURE_BUTTONS[action].testId}
key={action}
onClick={failureHandlers[action]}
ref={isPrimary ? primaryActionRef : undefined}
type="button"
variant={isPrimary ? "default" : "outline"}
>
{FAILURE_BUTTONS[action].label}
</Button>
);
return (
<Dialog
onOpenChange={(open) => {
if (open) {
return;
}
// Escape and the close button cancel a running download.
if (isDownloading) {
onCancel();
} else {
onDismiss();
}
}}
open={state.status !== "closed"}
>
<DialogContent
className="max-w-md"
data-status={shown.status}
data-testid="new-app-installer-dialog"
onCloseAutoFocus={(event) => {
// 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();
}
}}
>
<DialogHeader className="pr-8">
<DialogTitle>{dialogTitle(shown)}</DialogTitle>
<DialogDescription data-testid="new-app-installer-description">
{dialogDescription(shown, platform)}
</DialogDescription>
</DialogHeader>
{shown.status === "downloading" ? (
<div className="grid gap-2">
<Progress
aria-label="Download progress"
aria-valuetext={downloadProgressText(
shown.receivedBytes,
shown.totalBytes,
)}
data-testid="new-app-installer-progress"
value={downloadPercent(shown.receivedBytes, shown.totalBytes)}
/>
<p
className="text-xs text-muted-foreground tabular-nums"
data-testid="new-app-installer-progress-text"
>
{downloadProgressText(shown.receivedBytes, shown.totalBytes)}
</p>
</div>
) : null}
{shown.status === "finished" ? (
<div className="grid gap-2">
<p
className="break-all font-mono text-xs text-muted-foreground"
data-testid="new-app-installer-path"
>
{shown.folder}
</p>
{shown.revealFailed ? (
<p
className="text-sm text-destructive"
data-testid="new-app-installer-reveal-failed"
role="alert"
>
{REVEAL_FAILED_MESSAGE}
</p>
) : null}
</div>
) : null}
{/* The only live region: it changes at milestones, not on every tick. */}
<p
aria-live="polite"
className="sr-only"
data-testid="new-app-installer-announcement"
role="status"
>
{liveAnnouncement(shown, platform)}
</p>
<DialogFooter>
{shown.status === "downloading" ? (
<Button
data-testid="new-app-installer-cancel"
disabled={shown.isCancelling}
onClick={onCancel}
ref={cancelRef}
type="button"
variant="outline"
>
Cancel
</Button>
) : null}
{shown.status === "finished" ? (
<>
<Button
data-testid="new-app-installer-reveal"
onClick={onReveal}
type="button"
variant="outline"
>
{revealLabel(platform)}
</Button>
<Button
data-testid="new-app-installer-done"
onClick={onDismiss}
ref={primaryActionRef}
type="button"
>
Done
</Button>
</>
) : null}
{failure ? (
<>
{failure.secondary
? failureButton(failure.secondary, false)
: null}
{failureButton(failure.primary, true)}
</>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<NewAppInstallerDialog
onCancel={cancel}
onDismiss={dismiss}
onDownloadInBrowser={() => {
// 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}
/>
);
}

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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);
}
});

View File

@@ -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<string, string> = {
"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<string, unknown> {
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;
}
// `<tag>/<file name>`; 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;
}

View File

@@ -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"), "");
});

View File

@@ -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<InstallerDialogFailure, "permission-denied">,
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<FailureAction, string> = {
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();
}
}

View File

@@ -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");
});

View File

@@ -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<HTMLButtonElement | null> =
{ 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,
};
}

View File

@@ -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() {
<UpdaterProvider>
<App />
<NostrBindConsentDialog />
{/* The huddle window never shows the banner, and a
second host would cancel the main window's download. */}
{huddleWindowChannelId() === null ? (
<NewAppInstallerDownloadHost />
) : null}
</UpdaterProvider>
<Toaster />
</PoofBurstProvider>

View File

@@ -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<InstallerDownloadOutcome> {
return invokeTauri<InstallerDownloadOutcome>("download_new_app_installer", {
url,
onEvent: new Channel<InstallerDownloadEvent>(onEvent),
});
}
/** Stops the running installer download, if any, and removes its partial file. */
export function cancelNewAppInstallerDownload(): Promise<void> {
return invokeTauri<void>("cancel_new_app_installer_download");
}
/** Shows the last installer the app saved in the system file manager. */
export function revealNewAppInstaller(): Promise<void> {
return invokeTauri<void>("reveal_new_app_installer");
}

View File

@@ -1063,3 +1063,9 @@ export const setPreventSleepActive = (active: boolean) =>
export function isAutoUpdateSupported(): Promise<boolean> {
return invokeTauri<boolean>("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<string | null> {
return invokeTauri<string | null>("new_app_installer_target");
}

View File

@@ -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":

View File

@@ -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<InstallerDownloadOutcome> {
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<null> {
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;
},
};
}

View File

@@ -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<void>((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]);
});

View File

@@ -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,
});
}