Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
use std::{iter, sync::Arc};
use anyhow::Result;
use channel_versions::{Changelog, ChannelVersions};
use rand::{distributions::Alphanumeric, thread_rng, Rng as _};
use crate::{
channel::{Channel, ChannelState},
server::server_api::ServerApi,
};
use super::channel_versions::fetch_channel_versions;
use super::release_assets_directory_url;
pub async fn get_current_changelog(server_api: Arc<ServerApi>) -> Result<Option<Changelog>> {
let rand: String = {
let mut rng = thread_rng();
iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(7)
.collect()
};
let channel = ChannelState::channel();
if should_fetch_changelog_json(channel) {
log::info!("Attempting to fetch changelog.json");
match fetch_current_changelog(server_api.http_client(), rand.as_str()).await {
changelog_result @ Ok(_) => {
return changelog_result.map(Option::Some);
}
Err(error) => log::error!("Failed to fetch changelog.json: {error}"),
};
}
let versions: ChannelVersions =
fetch_channel_versions(rand.as_str(), server_api, true, false).await?;
let res = versions.changelogs.and_then(|changelogs| {
match channel {
Channel::Stable => Some(changelogs.stable),
Channel::Preview => Some(changelogs.preview),
Channel::Dev | Channel::Local => Some(changelogs.dev),
// Integration tests and the open-source build don't support autoupdate.
Channel::Integration | Channel::Oss => None,
}
.and_then(|versions| {
ChannelState::app_version()
.and_then(|running_version| versions.get(running_version))
.cloned()
})
});
Ok(res)
}
/// Fetches the changelog for the running release bundle, using the given http
/// client and cache-busting nonce.
async fn fetch_current_changelog(client: &http_client::Client, nonce: &str) -> Result<Changelog> {
let app_version = ChannelState::app_version().unwrap_or_default();
let url = format!(
"{}?r={}",
changelog_url(ChannelState::channel(), app_version),
nonce
);
let res = client.get(url.as_str()).send().await?;
let changelog: Changelog = res.json().await?;
log::info!("Received changelog.json for {app_version}");
Ok(changelog)
}
/// Returns the URL to the changelog for the given version of this release
/// bundle.
fn changelog_url(channel: Channel, version: &str) -> String {
format!(
"{}/changelog.json",
release_assets_directory_url(channel, version)
)
}
/// Returns whether the app should fetch changelog.json for the current
/// build (true), or use the changelog information embedded in
/// channel_versions.json (false).
pub fn should_fetch_changelog_json(channel: Channel) -> bool {
channel == Channel::Dev
}
+75
View File
@@ -0,0 +1,75 @@
use std::{env, fs::read_to_string, sync::Arc};
use anyhow::{Context as _, Result};
use channel_versions::ChannelVersions;
use crate::{
channel::{Channel, ChannelState},
report_error,
server::server_api::{ServerApi, FETCH_CHANNEL_VERSIONS_TIMEOUT},
};
// Fetches channel versions asynchronously from the Warp server. If the Warp server request fails,
// then fetches from GCP JSON storage as a fallback.
pub async fn fetch_channel_versions(
nonce: &str,
server_api: Arc<ServerApi>,
include_changelogs: bool,
is_daily: bool,
) -> Result<ChannelVersions> {
if let Ok(path) = env::var("WARP_CHANNEL_VERSIONS_PATH") {
// Load channel versions from local filesystem. Used for testing both
// autoupdate and changelog behavior.
let path = shellexpand::tilde(&path);
let channel_versions_string = read_to_string::<&str>(&path)?;
return serde_json::from_str(channel_versions_string.as_str())
.context("Failed to parse channel versions JSON");
}
let channel_versions = server_api
.fetch_channel_versions(include_changelogs, is_daily)
.await
.context("Failed to retrieve channel versions from Warp server");
match channel_versions {
channel_versions @ Ok(_) => channel_versions,
Err(err) => {
match ChannelState::channel() {
// Only log an error on Dev and Preview -- if this is failing, its likely to be
// failing for all users, and Stable has too many users (this error would flood
// our Sentry logs).
Channel::Dev | Channel::Preview => report_error!(err),
_ => log::warn!(
"Failed to retrieve channel versions from Warp server, falling \
back to GCP JSON storage."
),
}
fetch_channel_versions_from_json_storage(server_api.http_client(), nonce).await
}
}
}
// Synchronously fetches updated Warp [`ChannelVersions`] from GCP JSON storage. This will soon
// be deprecated in favor of retrieving updated channel versions from the Warp Server.
// Note, in order to run against a test file you can use the "channel_versions_test.json" file
// and update the file using gsutil cp channel_versions_test.json gs://warp-releases/channel_versions_test.json
async fn fetch_channel_versions_from_json_storage(
client: &http_client::Client,
nonce: &str,
) -> Result<ChannelVersions> {
log::info!("Fetching channel versions from GCP JSON storage");
let res = client
.get(
format!(
"{}/channel_versions.json?r={}",
ChannelState::releases_base_url(),
nonce
)
.as_str(),
)
.timeout(FETCH_CHANNEL_VERSIONS_TIMEOUT)
.send()
.await?;
let versions: ChannelVersions = res.json().await?;
log::info!("Received channel versions from GCP JSON storage: {versions}");
Ok(versions)
}
+675
View File
@@ -0,0 +1,675 @@
use std::io::Write;
use std::path::PathBuf;
use anyhow::{bail, Context as _, Result};
use channel_versions::VersionInfo;
use instant::Duration;
use warp_core::channel::{Channel, ChannelState};
use warp_terminal::shell::ShellType;
use warpui::ViewContext;
use crate::workspace::Workspace;
use super::release_assets_directory_url;
use super::{DownloadReady, ReadyForRelaunch};
lazy_static::lazy_static! {
/// Stores the path to the current executable.
///
/// We cache this before running auto-update because the returned path for
/// a deleted file includes " (deleted)" _in the file name_, which breaks
/// the relaunch logic.
static ref CURRENT_EXE: std::io::Result<PathBuf> = std::env::current_exe();
}
pub(super) async fn download_update_and_cleanup(
version_info: &VersionInfo,
_update_id: &str,
client: &http_client::Client,
) -> Result<DownloadReady> {
match UpdateMethod::detect() {
UpdateMethod::Unknown => Ok(DownloadReady::NeedsAuthorization),
UpdateMethod::AppImage(appimage_path) => {
appimage::download_update_and_cleanup(version_info, &appimage_path, client).await
}
UpdateMethod::PackageManager(package_manager) => {
log::info!("Detected that Warp was installed using {package_manager:?}");
Ok(DownloadReady::Yes)
}
}
}
pub(super) fn apply_update(
initiating_workspace: &mut Workspace,
update_id: &str,
ctx: &mut ViewContext<Workspace>,
) -> Result<ReadyForRelaunch> {
// Make sure CURRENT_EXE is initialized before we actually apply the update.
let _ = CURRENT_EXE.as_ref();
match UpdateMethod::detect() {
UpdateMethod::Unknown => bail!("Cannot apply update for unknown update method!"),
UpdateMethod::AppImage(_) => Ok(ReadyForRelaunch::Yes),
UpdateMethod::PackageManager(package_manager) => {
let context_block =
ctx.add_view(|_| package_manager::AutoupdateContextBlock::new(package_manager));
let owned_update_id = update_id.to_owned();
initiating_workspace.add_tab_for_assisted_autoupdate(
move |shell_type| package_manager.update_command(shell_type, &owned_update_id),
context_block,
ctx,
);
Ok(ReadyForRelaunch::No)
}
}
}
pub(super) fn relaunch() -> Result<()> {
match UpdateMethod::detect() {
UpdateMethod::Unknown => bail!("Don't know how to relaunch for an unknown update method!"),
UpdateMethod::AppImage(appimage_path) => appimage::relaunch(&appimage_path),
UpdateMethod::PackageManager(_) => package_manager::relaunch(),
}
}
mod appimage {
use std::path::Path;
use super::*;
pub(super) async fn download_update_and_cleanup(
version_info: &VersionInfo,
appimage_path: &Path,
client: &http_client::Client,
) -> Result<DownloadReady> {
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
// Compute the URL where we can download the new release.
let Some(appimage_name) = option_env!("APPIMAGE_NAME") else {
bail!("APPIMAGE_NAME environment variable was not set at compile time!");
};
let url = format!(
"{}/{}",
release_assets_directory_url(ChannelState::channel(), &version_info.version),
appimage_name
);
// Create a temporary file that we'll write the download into.
let mut new_appimage = tempfile::NamedTempFile::new()?;
log::info!("Downloading {url} to {}...", new_appimage.path().display());
let response = client
.get(&url)
.timeout(DOWNLOAD_TIMEOUT)
.send()
.await?
.error_for_status()?;
new_appimage
.as_file_mut()
.write_all(&response.bytes().await?)?;
log::info!(
"Copying downloaded AppImage from {} to {}",
new_appimage.path().display(),
appimage_path.display()
);
// Copy permissions to new app before moving it to ensure we don't leave it
// in a bad state if the move succeeds but we are unable to update the
// permissions afterwards.
new_appimage
.as_file_mut()
.set_permissions(appimage_path.metadata()?.permissions())?;
// Move new AppImage over the one that launched the current Warp instance.
let new_appimage_path = new_appimage.into_temp_path();
let mv_status = command::r#async::Command::new("mv")
.arg(new_appimage_path.as_os_str())
.arg(appimage_path)
.output()
.await?
.status;
if !mv_status.success() {
bail!("Failed to move new AppImage over the old one: {mv_status}");
}
// Ensure we don't accidentally drop `new_appimage_path` before we finish
// moving it to its final location.
let _ = new_appimage_path;
Ok(DownloadReady::Yes)
}
pub(super) fn relaunch(appimage_path: &Path) -> Result<()> {
let mut command = command::blocking::Command::new(appimage_path);
// Pass a flag to the app to let it know it was restarted as part of the
// autoupdate process.
command.arg(warp_cli::finish_update_flag());
// If we're testing with a local copy of channel_versions.json, have the
// newly-started binary also reference that same file (so we can test
// displaying an updated changelog after an autoupdate).
if let Ok(path) = std::env::var("WARP_CHANNEL_VERSIONS_PATH") {
command.env("WARP_CHANNEL_VERSIONS_PATH", path);
}
log::info!("Relaunching warp for update...");
command.spawn()?;
Ok(())
}
}
mod package_manager {
use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextHeader, FormattedTextLine,
};
use warpui::{
elements::{Container, FormattedTextElement, HighlightedHyperlink},
Element, SingletonEntity as _,
};
use crate::appearance::Appearance;
use super::*;
pub struct AutoupdateContextBlock {
package_manager: PackageManager,
hyperlink: HighlightedHyperlink,
}
impl AutoupdateContextBlock {
pub fn new(package_manager: PackageManager) -> Self {
AutoupdateContextBlock {
package_manager,
hyperlink: Default::default(),
}
}
}
impl warpui::Entity for AutoupdateContextBlock {
type Event = ();
}
impl warpui::View for AutoupdateContextBlock {
fn ui_name() -> &'static str {
"AutoupdateContextBlock"
}
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let package_manager_name = self.package_manager.to_string();
let mut lines = vec![
FormattedTextLine::Heading(FormattedTextHeader {
// Make this an <h3>
heading_size: 3,
text: vec![FormattedTextFragment::bold(format!(
"Run {package_manager_name} to update"
))],
}),
FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("If you installed Warp using "),
FormattedTextFragment::bold(package_manager_name),
FormattedTextFragment::plain_text(
" or a compatible tool, the pre-filled command will update Warp for you.",
),
]),
];
if self.package_manager.needs_repository_configuration() {
lines.push(FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(
"\nThe command below includes a one-time configuration of the Warp package repository and PGP signing key.",
),
]));
}
if self
.package_manager
.distribution_update_disabled_repository()
{
lines.push(FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(
"\nThe ",
),
FormattedTextFragment::inline_code("warp_handle_dist_upgrade"),
FormattedTextFragment::plain_text(
" function ensures the Warp package repository is enabled, as we've detected you recently upgraded your distribution.",
),
]));
}
lines.push(FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("\nReview the command below, then "),
FormattedTextFragment::bold("press enter"),
FormattedTextFragment::plain_text(" to install the update and re-launch Warp. "),
FormattedTextFragment::hyperlink(
"Please report any issues",
"https://github.com/warpdotdev/Warp/issues/new/choose",
),
]));
let formatted_text = FormattedText::new(lines);
let inline_code_bg_color = appearance.theme().surface_3().into_solid();
let text = FormattedTextElement::new(
formatted_text,
appearance.monospace_font_size(),
appearance.monospace_font_family(),
appearance.monospace_font_family(),
theme.active_ui_text_color().into_solid(),
self.hyperlink.clone(),
)
.with_inline_code_properties(
Some(theme.nonactive_ui_text_color().into()),
Some(inline_code_bg_color),
)
.register_default_click_handlers(|url, _, ctx| {
ctx.open_url(&url.url);
})
.finish();
Container::new(text)
.with_background(theme.surface_2())
.with_uniform_padding(16.)
.finish()
}
}
pub(super) fn relaunch() -> Result<()> {
let Ok(program) = CURRENT_EXE.as_ref() else {
bail!(
"Failed to get path to current executable to relaunch after completing auto-update"
);
};
log::info!("Relaunching using path: {program:?}");
let mut command = command::blocking::Command::new(program);
// Add any arguments that were passed to warp, skipping the first
// argument (the name of the executable) and dropping the flag for
// finishing an update.
let finish_update_flag = warp_cli::finish_update_flag();
command.args(
std::env::args()
.skip(1)
.filter(|arg| arg != &finish_update_flag),
);
// Pass a flag to the app to let it know it was restarted as part of the
// autoupdate process.
command.arg(finish_update_flag);
// If we're testing with a local copy of channel_versions.json, have the
// newly-started binary also reference that same file (so we can test
// displaying an updated changelog after an autoupdate).
if let Ok(path) = std::env::var("WARP_CHANNEL_VERSIONS_PATH") {
command.env("WARP_CHANNEL_VERSIONS_PATH", path);
}
log::info!("Relaunching warp for update...");
command.spawn()?;
Ok(())
}
}
/// Returns which method should be used to update Warp.
#[derive(Debug)]
pub(crate) enum UpdateMethod {
/// We don't know how to update Warp.
Unknown,
/// Warp is running as an AppImage and should be updated in-place.
AppImage(PathBuf),
/// Warp can be updated using the given package manager.
PackageManager(PackageManager),
}
impl UpdateMethod {
pub(crate) fn detect() -> Self {
if let Some(appimage_path) = std::env::var_os("APPIMAGE").map(PathBuf::from) {
return Self::AppImage(appimage_path);
}
if let Ok(package_manager) = PackageManager::detect() {
return Self::PackageManager(package_manager);
}
Self::Unknown
}
}
/// Package managers that we understand and can assist with auto-update
/// for.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum PackageManager {
Apt {
distribution_update_disabled_repository: bool,
},
Yum,
Dnf,
Zypper,
Pacman {
is_repo_configured: bool,
is_signing_key_configured: bool,
},
}
impl PackageManager {
pub fn update_command(&self, shell_type: ShellType, update_id: &str) -> String {
let package_name = Self::package_name();
let repo_name = Self::repo_name();
let and = shell_type.and_combiner();
let or = shell_type.or_combiner();
let base_command = match self {
PackageManager::Apt {
distribution_update_disabled_repository,
} => {
let dist_upgrade_fn = match shell_type {
ShellType::Zsh | ShellType::Bash | ShellType::Fish => {
"warp_handle_dist_upgrade"
}
ShellType::PowerShell => "Warp-Handle-DistUpgrade",
};
// If running with apt, attempt to handle a distribution update that may rename the
// warp source file to `{repo_name}.distUpgrade`.
// We explicitly use `or` here instead of `and` to limit the blast radius of this
// change, if handling a dist upgrade was unsuccessful we still want to try to
// install the new version.
let command = format!("sudo apt update{and}sudo apt install {package_name}");
if *distribution_update_disabled_repository {
format!("{dist_upgrade_fn} {repo_name}{or}{command}")
} else {
command
}
}
PackageManager::Yum => {
format!("sudo yum --refresh --repo {repo_name} upgrade {package_name}")
}
PackageManager::Dnf => {
format!("sudo dnf --refresh --repo {repo_name} upgrade {package_name}")
}
PackageManager::Zypper => {
format!("sudo zypper update {package_name}")
}
PackageManager::Pacman {
is_repo_configured,
is_signing_key_configured,
} => {
let repo_prefix = if !is_repo_configured {
let cache_dir = warp_core::paths::cache_dir();
let cache_dir_str = cache_dir.display();
// Back up the existing pacman.conf file just in case
// anything goes wrong, then add the repository config.
format!("mkdir -p {cache_dir_str}{and}\\\ncp /etc/pacman.conf {cache_dir_str}{and}\\\nsudo sh -c \"echo '\n[{repo_name}]\nServer = https://releases.warp.dev/linux/pacman/\\$repo/\\$arch' >> /etc/pacman.conf\"{and}\\\n")
} else {
String::new()
};
let key_prefix = if !is_signing_key_configured {
// Retrieve our key from keys.openpgp.org and locally sign
// it before retrieving the package repository and
// installing the updated package.
format!("sudo pacman-key -r \"linux-maintainers@warp.dev\" --keyserver hkp://keys.openpgp.org:80{and}\\\nsudo pacman-key --lsign-key \"linux-maintainers@warp.dev\"{and}\\\n")
} else {
String::new()
};
format!("{key_prefix}{repo_prefix}sudo pacman -Sy {package_name}")
}
};
let finish_update_fn = match shell_type {
ShellType::Zsh | ShellType::Bash | ShellType::Fish => "warp_finish_update",
ShellType::PowerShell => "Warp-Finish-Update",
};
format!("{base_command}{and}{finish_update_fn} {update_id}")
}
fn package_name() -> &'static str {
package_name(ChannelState::channel())
}
fn repo_name() -> String {
repo_name(ChannelState::channel())
}
fn detect() -> Result<Self> {
let package_name = Self::package_name();
let detect_script = r#"
command -p pacman -Qi $PACKAGE_NAME >/dev/null 2>/dev/null
if [ $? -eq 0 ]; then
echo "pacman"
exit
fi
command -p zypper search --match-exact --installed-only $PACKAGE_NAME >/dev/null 2>/dev/null
if [ $? -eq 0 ]; then
echo "zypper"
exit
fi
command -p dnf list --installed $PACKAGE_NAME >/dev/null 2>/dev/null
if [ $? -eq 0 ]; then
echo "dnf"
exit
fi
command -p yum list installed $PACKAGE_NAME >/dev/null 2>/dev/null
if [ $? -eq 0 ]; then
echo "yum"
exit
fi
if [ "$(command -p dpkg-query --show --showformat='${db:Status-Status}' $PACKAGE_NAME 2>/dev/null)" = "installed" ]; then
echo "apt"
exit
fi
exit 1
"#;
let output = command::blocking::Command::new("sh")
.args(["-c", detect_script])
.env("PACKAGE_NAME", package_name)
.output();
match output {
Ok(output) => {
if !output.status.success() {
bail!("Failed to determine which package manager was used to install warp");
}
let Ok(stdout) = std::str::from_utf8(&output.stdout) else {
bail!("Could not parse package manager detection script output as UTF-8");
};
match stdout.trim() {
"pacman" => {
let is_repo_configured = is_pacman_repo_installed(package_name);
let is_signing_key_configured = is_pacman_signing_key_installed();
Ok(Self::Pacman {
is_repo_configured,
is_signing_key_configured,
})
}
"zypper" => Ok(Self::Zypper),
"dnf" => Ok(Self::Dnf),
"yum" => Ok(Self::Yum),
"apt" => {
let distribution_update_disabled_repository =
is_apt_repository_disabled_due_to_version_update(&Self::repo_name());
Ok(Self::Apt {
distribution_update_disabled_repository,
})
}
_ => bail!(
"Received unexpected output from the package manager detection script"
),
}
}
Err(err) => Err(err).context("Failed to run package manager detection script"),
}
}
fn distribution_update_disabled_repository(&self) -> bool {
match self {
PackageManager::Apt {
distribution_update_disabled_repository,
} => *distribution_update_disabled_repository,
_ => false,
}
}
fn needs_repository_configuration(&self) -> bool {
match self {
PackageManager::Pacman {
is_repo_configured,
is_signing_key_configured,
} => !is_repo_configured || !is_signing_key_configured,
// We only need to perform in-app post-installation repo configuration
// when using pacman, and not with other package managers.
PackageManager::Apt { .. }
| PackageManager::Yum
| PackageManager::Dnf
| PackageManager::Zypper => false,
}
}
}
impl std::fmt::Display for PackageManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PackageManager::Apt { .. } => write!(f, "apt"),
PackageManager::Yum => write!(f, "yum"),
PackageManager::Dnf => write!(f, "dnf"),
PackageManager::Zypper => write!(f, "zypper"),
PackageManager::Pacman { .. } => write!(f, "pacman"),
}
}
}
/// Returns whether the warp apt repository is disabled due to a version update.
/// This occurs if there's a `warpdotdev.list.distUpgrade` file but no `warpdotdev.sources` or
/// `warpdotdev.list` file.
/// In a traditional Ubuntu distro update, Ubuntu renames each source file from `foo.list` to
/// `foo.list.distUpgrade`. It then creates a new version of `foo.list` (or `foo.sources` if
/// updating to Ubuntu 24+) with the repo disabled.
///
/// However, Ubuntu incorrectly thinks the Warp source file is invalid (due to the addition of the
/// `signed-by` key) so it only leaves the `*.distUpgrade` source file. We use the existence of this
/// file to determine whether we need to run the special `warp_handle_dist_upgrade` function to copy
/// `warpdotdev.list.distUpgrade` back to `warpdotdev.list` to re-enable the repository.
fn is_apt_repository_disabled_due_to_version_update(repo_name: &str) -> bool {
let apt_sources_directory = match get_apt_sources_directory() {
Ok(apt_sources_directory) => apt_sources_directory,
Err(err) => {
log::warn!("Failed to compute default apt source list directory: {err:#}");
log::warn!("Falling back to /etc/apt/sources.list.d/...");
PathBuf::from("/etc/apt/sources.list.d/")
}
};
!apt_sources_directory
.join(format!("{repo_name}.list"))
.exists()
&& !apt_sources_directory
.join(format!("{repo_name}.sources"))
.exists()
&& apt_sources_directory
.join(format!("{repo_name}.list.distUpgrade"))
.exists()
}
/// Returns the directory that contains apt sources.
fn get_apt_sources_directory() -> Result<PathBuf> {
let output = command::blocking::Command::new("sh")
.arg("-c")
.arg("eval $(apt-config shell APT_SOURCESDIR \"Dir::Etc::sourceparts/d\"); echo $APT_SOURCESDIR")
.output()?;
let stdout = std::str::from_utf8(&output.stdout)
.context("FAiled to parse apt sources directory script output")?;
Ok(PathBuf::from(stdout.trim()))
}
fn is_pacman_repo_installed(package_name: &str) -> bool {
match command::blocking::Command::new("pacman")
.arg("-S")
.arg("--print")
.arg(package_name)
.output()
{
Ok(output) => output.status.success(),
Err(err) => {
log::warn!("Failed to determine if pacman repository is configured: {err:#}");
// Fail open, to ensure we don't insert duplicate entries in /etc/pacman.conf.
true
}
}
}
fn is_pacman_signing_key_installed() -> bool {
// Check if the key exists and get its expiry date from pacman's GPG keyring.
let output = match command::blocking::Command::new("gpg")
.args([
"--homedir",
"/etc/pacman.d/gnupg",
"--list-keys",
"--with-colons",
"linux-maintainers@warp.dev",
])
.output()
{
Ok(output) if output.status.success() => output,
Ok(_) => return false, // Key not found.
Err(err) => {
log::warn!("Failed to check pacman signing key: {err:#}");
// If we're not sure, try to refresh the key.
return false;
}
};
let Ok(stdout) = std::str::from_utf8(&output.stdout) else {
return false;
};
// Parse the expiry timestamp from the pub: line (field 7, 1-indexed).
let Some(expiry_field) = stdout
.lines()
.find(|line| line.starts_with("pub:"))
.and_then(|line| line.split(':').nth(6))
else {
// Couldn't find pub line, try to refresh.
return false;
};
// An empty field or "0" means the key has no expiration date.
if expiry_field.is_empty() || expiry_field == "0" {
return true;
}
let Ok(expiry_timestamp) = expiry_field.parse::<i64>() else {
// Couldn't parse expiry, try to refresh.
return false;
};
// If the key expires within 60 days, consider it as needing refresh.
let sixty_days_from_now = chrono::Utc::now() + chrono::Duration::days(60);
expiry_timestamp > sixty_days_from_now.timestamp()
}
fn package_name(channel: Channel) -> &'static str {
match channel {
Channel::Stable => "warp-terminal",
Channel::Preview => "warp-terminal-preview",
Channel::Dev => "warp-terminal-dev",
Channel::Integration => "warp-terminal-integration",
Channel::Local => "warp-terminal-local",
Channel::Oss => "warp-oss",
}
}
fn repo_name(channel: Channel) -> String {
let package_name = package_name(channel);
let channel_suffix = package_name
.strip_prefix("warp-terminal")
.unwrap_or_default();
format!("warpdotdev{channel_suffix}")
}
#[cfg(test)]
#[path = "linux_test.rs"]
mod tests;
+7
View File
@@ -0,0 +1,7 @@
use super::*;
#[test]
fn test_repo_name() {
assert_eq!(repo_name(Channel::Dev), "warpdotdev-dev");
assert_eq!(repo_name(Channel::Stable), "warpdotdev");
}
+761
View File
@@ -0,0 +1,761 @@
#![allow(deprecated)]
use command::{blocking, r#async::Command};
use futures::{StreamExt, TryStreamExt as _};
use futures_lite::future;
use instant::Instant;
use std::{
env,
ffi::{CString, OsString},
fs,
os::unix::{ffi::OsStrExt as _, fs::MetadataExt, io::AsRawFd as _},
path::{Path, PathBuf},
str,
time::Duration,
};
use warp_core::safe_error;
use anyhow::{anyhow, bail, ensure, Context, Result};
use channel_versions::VersionInfo;
use nix::unistd::{fchown, getgid};
use nix::{errno::Errno, unistd::getuid};
use warp_core::macos::get_bundle_path;
use warpui::{AppContext, ModelContext, SingletonEntity};
use crate::{
appearance::AppearanceManager,
autoupdate::{AutoupdateStage, AutoupdateState},
channel::{Channel, ChannelState},
safe_info,
};
use super::{release_assets_directory_url, DownloadReady};
// Relative path to the directory containing old executables from before an autoupdate.
//
// TODO(vorporeal): This and relevant code should be deleted after auto-updates have been
// storing the old executable in the user application data directory for a couple
// releases.
const OLD_EXECUTABLE_PATH: &str = "Contents/MacOS/old";
// Name of the old executable file that was kept around during an autoupdate.
const OLD_EXECUTABLE_FILE_NAME: &str = "old";
// Tmp file name used to check if the user has the correct permissions for autoupdate.
const PERMISSIONS_TMP_FILE_NAME: &str = "permission_test";
fn old_executable_file_path() -> PathBuf {
warp_core::paths::state_dir().join(OLD_EXECUTABLE_FILE_NAME)
}
/// Removes the old executable dir from the app bundle. This is necessary because after an
/// autoupdate deleting the running executable causes the pty to not start for a reason we don't
/// fully understand. This allows to clean up old executables when the app is first launched.
pub(super) fn remove_old_executable() -> Result<()> {
// TODO(vorporeal): This code should be deleted after auto-updates have been
// storing the old executable in the user application data directory for
// a couple releases.
log::info!("Removing old executable dir...");
let old_executable_path = PathBuf::from(get_bundle_path()?).join(OLD_EXECUTABLE_PATH);
if let Ok(metadata) = fs::metadata(&old_executable_path) {
if metadata.is_dir() {
fs::remove_dir_all(old_executable_path)?;
}
}
log::info!("Removing old executable file...");
let old_executable_file_path = old_executable_file_path();
if let Ok(metadata) = fs::metadata(&old_executable_file_path) {
if metadata.is_file() {
fs::remove_file(old_executable_file_path)?;
}
}
Ok(())
}
pub(super) fn manually_download_version(
channel: &Channel,
version_info: &VersionInfo,
ctx: &mut AppContext,
) {
let url = update_url(*channel, version_info.version.as_str());
ctx.open_url(&url);
}
/// If the autoupdate state is ready, asynchronously apply the update and cleanup the autoupdate artifacts.
///
/// The completion callback is invoked with `Ok(Some(version))` if an update was applied, and `Ok(None)` if there was no update.
/// If there was an update, but applying it failed, it's invoked with `Err(err)`.
pub(super) fn apply_update_async<F>(app: &mut AppContext, callback: F)
where
F: FnOnce(
&mut AutoupdateState,
Result<Option<VersionInfo>>,
&mut ModelContext<AutoupdateState>,
) + Send
+ 'static,
{
AutoupdateState::handle(app).update(app, |autoupdate_state, ctx| {
match autoupdate_state.stage.clone() {
AutoupdateStage::UpdateReady {
new_version,
update_id,
}
| AutoupdateStage::Updating {
new_version,
update_id,
} => {
let update_id_clone = update_id.clone();
// Apply the update in a background thread.
ctx.spawn(
async move {
let result =
apply_update(ChannelState::channel(), &new_version, &update_id)
.await
.map(|_| Some(new_version));
cleanup(&update_id).await;
result
},
move |autoupdate_state, result, ctx| {
if result.is_ok() {
// Reset app icon to previously selected app icon
AppearanceManager::as_ref(ctx).set_app_icon(ctx);
}
autoupdate_state.clear_downloaded_update(&update_id_clone, ctx);
callback(autoupdate_state, result, ctx);
},
);
}
_ => {
callback(autoupdate_state, Ok(None), ctx);
}
}
})
}
pub(super) fn relaunch() -> Result<()> {
let bundle_path = PathBuf::from(get_bundle_path()?);
// Set the -n option to open a new instance of the app even if one is
// running so we still launch the new version even if the user was running
// multiple instances of Warp.
let mut launch_command = OsString::from("/usr/bin/open -n ");
launch_command.push(bundle_path.as_os_str());
// Pass a flag to the app to let it know it was restarted as part of the
// autoupdate process.
launch_command.push(format!(" --args {}", warp_cli::finish_update_flag()));
// If we're testing with a local copy of channel_versions.json, have the
// newly-started binary also reference that same file (so we can test
// displaying an updated changelog after an autoupdate).
if let Ok(path) = env::var("WARP_CHANNEL_VERSIONS_PATH") {
launch_command.push(format!(" --env WARP_CHANNEL_VERSIONS_PATH={path}"));
}
// We need to make sure that the current Warp process is no longer running
// before we spawn the new one, otherwise we can end up showing multiple
// icons in the macOS dock. To do this, we use an intermediary /bin/sh
// process that watches for this process to terminate, and then spawns a
// new Warp process.
//
// Wait until the current process is no longer running, checking every
// 200ms. Once the current process has terminated, launch the new one.
let pid = std::process::id();
let mut relaunch_command = OsString::from(format!(
"while ps -p {pid} >/dev/null 2>&1; do sleep 0.2; done; "
));
relaunch_command.push(launch_command);
log::info!("Executing relaunch command {relaunch_command:?}");
blocking::Command::new("sh")
.arg("-c")
.arg(relaunch_command)
.spawn()?;
Ok(())
}
pub async fn cleanup(update_id: &str) {
let download_dir = get_download_dir(update_id);
if download_dir.exists() {
log::info!("Cleaning up download dir {:?}", &download_dir);
if let Err(e) = async_fs::remove_dir_all(&download_dir).await {
safe_error!(
safe: ("Error cleaning up download dir: {e:?}"),
full: ("Error cleaning up download dir {:?}: {:?}", &download_dir, e)
);
}
}
}
/// Clean up all autoupdate directories except the specified one.
/// This helps prevent accumulation of old update directories from failed downloads,
/// race conditions, or incomplete cleanups.
pub async fn cleanup_all_except(preserve_update_id: Option<&str>) {
let mut autoupdate_dir = warp_core::paths::cache_dir();
autoupdate_dir.push("autoupdate");
if !autoupdate_dir.exists() {
return;
}
log::debug!("Cleaning up all autoupdate directories except {preserve_update_id:?}");
let mut entries = match async_fs::read_dir(&autoupdate_dir).await {
Ok(entries) => entries,
Err(e) => {
log::warn!("Could not read autoupdate directory {autoupdate_dir:?}: {e:?}");
return;
}
};
while let Some(entry) = entries.next().await {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
log::warn!("Error reading autoupdate directory entry: {e:?}");
continue;
}
};
let path = entry.path();
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => continue,
};
// Skip the directory we want to preserve
if let Some(preserve_id) = preserve_update_id {
if file_name == preserve_id {
log::debug!("Preserving autoupdate directory: {path:?}");
continue;
}
}
let metadata = match async_fs::metadata(&path).await {
Ok(metadata) => metadata,
Err(e) => {
log::warn!("Could not get metadata for {path:?}: {e:?}");
continue;
}
};
if metadata.is_dir() {
log::debug!("Removing old autoupdate directory: {path:?}");
if let Err(e) = async_fs::remove_dir_all(&path).await {
log::warn!("Failed to remove autoupdate directory {path:?}: {e:?}");
}
}
}
}
/// Determines if the user needs authorization in order to update Warp.
async fn needs_authorization(bundle_path: &Path) -> Result<bool> {
// For the bundle path itself, check permissions without creating a test file so as to not
// interfere with code signing.
let bundle_dir_writable = permissions::is_writable(bundle_path)?;
if !bundle_dir_writable {
log::info!("App location is not writable, needs authorization");
return Ok(true);
} else {
log::info!("App location is writable");
}
if let Some(bundle_parent_path) = bundle_path.parent() {
if !is_directory_writable(bundle_parent_path).await? {
log::info!("App parent location is not writable, needs authorization");
return Ok(true);
} else {
log::info!("App parent location is writable");
}
}
Ok(false)
}
/// Determines if a directory is writable as part of an update. This means:
/// * Warp can create files in the directory
/// * Warp can modify the permissions of created files
async fn is_directory_writable(directory: &Path) -> Result<bool> {
// Just because we have writability access does not mean we can set the correct owner/group.
// Test if we can set the owner/group on a temporarily created file. If we can, then we can
// probably perform an update without authorization.
let tmp_file_name = directory.join(PERMISSIONS_TMP_FILE_NAME);
safe_info!(
safe: ("Writing to a tmp file to determine if permissions are correct"),
full: ("Writing to a tmp file to determine if permissions are correct in {}", directory.display())
);
let needs_authorization = match async_fs::File::create(&tmp_file_name).await {
Ok(file) => {
let fchown_result = fchown(file.as_raw_fd(), Some(getuid()), Some(getgid()));
if let Err(err) = &fchown_result {
log::warn!("Could not set permissions on tmp file: {err:#}");
}
// Only remove the tmp file if it was created - otherwise, we'll mask permission
// errors.
async_fs::remove_file(&tmp_file_name).await?;
fchown_result.is_ok()
}
Err(e) => {
// Obvious indicator we may need authorization.
log::warn!("Could not create tmp file: {e:#}");
false
}
};
Ok(needs_authorization)
}
/// Verifies that the staged bundle path has a valid macOS code signature, and that its
/// team identifier matches Warp's team identifier.
async fn verify_code_signature(component: &str, path: &Path) -> Result<()> {
// Verify the signature of the staged update bundle with team identifier
let codesign_verify_output = Command::new("/usr/bin/codesign")
.arg("-v")
.arg(format!(
"-R=certificate leaf[subject.OU] = \"{}\"",
warp_core::macos::APPLE_TEAM_ID
))
.arg(path)
.output()
.await?;
ensure!(
codesign_verify_output.status.success(),
"Failed to verify code signature for {component} with team identifier: {codesign_verify_output:?}"
);
safe_info!(
safe: ("Code signature is valid for {component}"),
full: ("Code signature is valid for {}", path.display())
);
Ok(())
}
pub(super) async fn download_update_and_cleanup(
version_info: &VersionInfo,
update_id: &str,
last_successful_update_id: Option<&str>,
client: &http_client::Client,
) -> Result<DownloadReady> {
let result =
download_and_extract_binary(ChannelState::channel(), version_info, update_id, client).await;
if result.is_err() {
cleanup_all_except(last_successful_update_id).await;
}
result
}
/// Apply the downloaded update.
///
/// This is async and should be run in a background task.
async fn apply_update(channel: Channel, version_info: &VersionInfo, update_id: &str) -> Result<()> {
let update_start = Instant::now();
let bundle_path = PathBuf::from(get_bundle_path()?);
let bundle_parent_path = bundle_path
.parent()
.ok_or_else(|| anyhow!("Could not get parent directory of application bundle"))?;
// Double-check that we have permissions to apply the update.
if !permissions::is_writable(&bundle_path)? {
bail!("App location is not writable, cannot apply update");
}
if !is_directory_writable(bundle_parent_path).await? {
bail!("App parent location is not writable, cannot apply update");
}
// Read a file out of the old bundle to ensure that we've triggered macOS' directory
// permissions checks.
let old_info_plist = bundle_path.join("Contents/Info.plist");
if async_fs::File::open(&old_info_plist).await.is_err() {
bail!("App location is not readable, cannot apply update");
}
let dmg_path = dmg_path(&channel, version_info, update_id);
let temp_app_path = temporary_target_path(channel, version_info, &dmg_path)?;
let staged_bundle =
StagedBundle::for_bundle_path(channel, version_info, temp_app_path, &bundle_path).await?;
// Copy permissions to new app
let bundle_metadata = async_fs::metadata(&bundle_path).await?;
async_fs::set_permissions(&staged_bundle.path, bundle_metadata.permissions()).await?;
// Verify that the new version actually exists before proceeding
let executable_path_buf = staged_bundle.path.join(executable_path(channel));
if !executable_path_buf.exists() {
bail!(
"New executable does not exist at path: {:?}",
executable_path_buf
);
}
// Atomically rename the new app to have the same name as the old one.
log::info!("Renaming new app to original app name");
let from = CString::new(staged_bundle.path.as_os_str().as_bytes())?;
let to = CString::new(bundle_path.as_os_str().as_bytes())?;
Errno::result(unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_SWAP) })
.context("Error swapping old and new app bundles")?;
// Move the current running executable into a temporary directory so we can delete the
// rest of the old bundle without removing the running executable (since removing it
// causes the `fork` syscall to fail).
let executable_temp_file = old_executable_file_path();
if async_fs::metadata(executable_temp_file.as_path())
.await
.is_ok()
{
// If we performed this process already but didn't relaunch Warp, the old executable will
// still be located in the user application data directory. In that case, leave it there.
log::info!("Already autoupdated without relaunching; ignoring executable from old bundle");
} else {
// Compute the location of the old executable (which, after the swap of the app contents,
// is located in the "new app" directory).
let new_app_executable_path = staged_bundle.path.join(executable_path(channel));
log::info!(
"Moving old executable at path {new_app_executable_path:?} into user application data dir at path {executable_temp_file:?}"
);
let mv_output = Command::new("mv")
.arg(new_app_executable_path)
.arg(executable_temp_file)
.output()
.await?;
ensure!(
mv_output.status.success(),
"Failed to move old executable: {mv_output:?}"
);
}
log::info!("Setting installed version to {:?}", &version_info);
log::info!("Applied update in {:?}", update_start.elapsed());
Ok(())
}
/// The staged app bundle that we're about to install. It's copied out of the `.dmg` file into a
/// temporary location.
struct StagedBundle {
/// Path to the on-disk temporary bundle.
path: PathBuf,
/// Whether or not the temporary bundle was copied into the same directory as the existing app.
/// This is only necessary if `$TMPDIR` and the app are on different filesystems.
in_app_directory: bool,
}
impl StagedBundle {
async fn for_bundle_path(
channel: Channel,
version_info: &VersionInfo,
temp_app_path: PathBuf,
bundle_path: &Path,
) -> Result<Self> {
let temp_device_id = async_fs::metadata(&temp_app_path)
.await
.context("Could not get metadata for temporary app bundle")?
.dev();
let bundle_device_id = async_fs::metadata(bundle_path)
.await
.context("Could not get metadata for app bundle")?
.dev();
if temp_device_id == bundle_device_id {
// The old and new app bundles are on the same filesystem (this is the expected case).
Ok(Self {
path: temp_app_path,
in_app_directory: false,
})
} else {
let bundle_parent_path = bundle_path
.parent()
.ok_or_else(|| anyhow!("Could not get parent directory of application bundle"))?;
log::info!("Copying app contents from {temp_app_path:?} to {bundle_parent_path:?}");
let cp_output = Command::new("cp")
// Recursively copy the directory, preserving symlinks.
.arg("-R")
// Overwrite files at the destination.
.arg("-f")
.arg(&temp_app_path)
.arg(bundle_parent_path)
.output()
.await?;
ensure!(
cp_output.status.success(),
"Failed to copy app contents from temporary directory into bundle directory: {cp_output:?}"
);
Ok(Self {
path: bundle_parent_path.join(versioned_app_name(channel, &version_info.version)),
in_app_directory: true,
})
}
}
}
impl Drop for StagedBundle {
fn drop(&mut self) {
// Clean up in the destructor so that it happens even if the installation errors.
// If we used the original temporary app bundle, it'll get removed by the final cleanup
// step, along with the dmg.
if self.in_app_directory {
log::info!("Removing temporary app bundle");
if let Err(err) = fs::remove_dir_all(&self.path) {
log::error!("Failed to remove temporary bundle: {err:#}");
}
}
}
}
async fn download_and_extract_binary(
channel: Channel,
version_info: &VersionInfo,
update_id: &str,
client: &http_client::Client,
) -> Result<DownloadReady> {
let bundle_path = PathBuf::from(get_bundle_path()?);
let needs_authorization = needs_authorization(bundle_path.as_path())
.await
.unwrap_or(true);
if needs_authorization {
return Ok(DownloadReady::NeedsAuthorization);
}
log::info!(
"Downloading update, version {} on channel {channel}",
&version_info.version,
);
let download_dir = get_download_dir(update_id);
log::info!("Creating download dir {:?}", &download_dir);
async_fs::create_dir_all(&download_dir).await?;
let dmg_path = download_dmg(&channel, version_info, update_id, client).await?;
// Mount the downloaded dmg so we can copy out the binary.
let mountpoint = mount_dmg(&dmg_path, update_id).await?;
let target = temporary_target_path(channel, version_info, &dmg_path)?;
// Copy the binary into the temporary directory where we downloaded the dmg.
copy_app_from_dmg(&channel, &mountpoint, &target).await?;
// Unmount the dmg once we no longer need it. This prevents lingering images from unapplied
// updates.
if let Err(err) = unmount_dmg(mountpoint).await {
let err = err.context("Error unmounting dmg for update");
crate::report_error!(&err);
}
// Ensure that the new app we just downloaded has both integrity (e.g. no corrupted files)
// and validity (it was signed by us).
// Store the executable path in a variable to prevent temporary value issues.
let executable_path_buf = target.join(executable_path(channel));
let verification_start = Instant::now();
future::try_zip(
verify_code_signature("bundle", &target),
verify_code_signature("executable", executable_path_buf.as_path()),
)
.await?;
log::info!(
"Verified new app code signature in {:?}",
verification_start.elapsed()
);
Ok(DownloadReady::Yes)
}
async fn unmount_dmg(mountpoint: PathBuf) -> Result<()> {
let mut hdiutil_cmd = Command::new("/usr/bin/hdiutil");
hdiutil_cmd.arg("detach");
hdiutil_cmd.arg(&mountpoint);
hdiutil_cmd.arg("-force");
log::info!("Attempting to detach dmg with command \"{hdiutil_cmd:?}\"");
let output = hdiutil_cmd.output().await?;
ensure!(output.status.success(), "Failed to detach dmg: {output:?}");
log::info!("hdiutil detach succeeded: {output:?}");
Ok(())
}
async fn copy_app_from_dmg(channel: &Channel, mountpoint: &Path, target: &Path) -> Result<()> {
let mounted_app_path = mountpoint.join(app_name(*channel));
log::info!("Copying dmg contents from {mounted_app_path:?} to {target:?}");
let cp_output = Command::new("cp")
// Recursively copy the directory, preserving symlinks.
.arg("-R")
.arg(mounted_app_path)
.arg(target)
.output()
.await?;
ensure!(
cp_output.status.success(),
"Failed to copy app out of mounted dmg: {cp_output:?}"
);
Ok(())
}
// 10 minutes
const DMG_TIMEOUT_S: u64 = 600;
/// The temporary path for downloading the new dmg into.
fn dmg_path(channel: &Channel, version_info: &VersionInfo, update_id: &str) -> PathBuf {
let mut dir = get_download_dir(update_id);
let file_name = format!(
"{}.{}.dmg",
&version_info.version,
app_name_prefix(*channel)
);
dir.push(file_name);
dir
}
/// The temporary path for placing our downloaded app binary.
fn temporary_target_path(
channel: Channel,
version_info: &VersionInfo,
dmg_path: &Path,
) -> Result<PathBuf> {
Ok(dmg_path
.parent()
.ok_or_else(|| anyhow!("Could not get parent directory of downloaded DMG"))?
.join(versioned_app_name(channel, &version_info.version)))
}
async fn download_dmg(
channel: &Channel,
version_info: &VersionInfo,
update_id: &str,
client: &http_client::Client,
) -> Result<PathBuf> {
// TODO: Use a streaming fetch and and provide an api for tracking progress
let update_url = update_url(*channel, &version_info.version);
log::info!("Fetching new dmg at {update_url}");
let res = client
.get(&update_url)
.timeout(Duration::from_secs(DMG_TIMEOUT_S))
.send()
.await?;
let dmg_file = dmg_path(channel, version_info, update_id);
let mut file = async_fs::File::create(&dmg_file).await?;
futures_lite::io::copy(
res.bytes_stream()
.map_err(std::io::Error::other)
.into_async_read(),
&mut file,
)
.await?;
file.sync_data().await?;
log::info!("Wrote DMG to tempfile at {:?}", &dmg_file);
Ok(dmg_file)
}
fn get_download_dir(update_id: &str) -> PathBuf {
let mut dir = warp_core::paths::cache_dir();
dir.push("autoupdate");
dir.push(update_id);
dir
}
fn get_mountpoint(update_id: &str) -> PathBuf {
let mut volume = PathBuf::from("/Volumes");
volume.push(update_id);
volume
}
async fn mount_dmg(dmg_dir: &Path, update_id: &str) -> Result<PathBuf> {
let volume = get_mountpoint(update_id);
let mut hdiutil_cmd = Command::new("/usr/bin/hdiutil");
hdiutil_cmd.args(["attach", "-mountpoint"]);
hdiutil_cmd.arg(&volume);
// Explanation of flags:
// -nobrowse: Do not show the Warp DMG in Finder or similar apps.
// -noautoopen: Do not open the Warp DMG in Finder.
// -readonly: For safety, we mount read-only since there's no need to modify the new app version.
// -autofsck: Ensure that the DMG contents are verified. This is on by default for quarantined images, but macOS
// doesn't necessarily recognize our download as such.
hdiutil_cmd.args(["-nobrowse", "-noautoopen", "-readonly", "-autofsck"]);
hdiutil_cmd.arg(dmg_dir);
log::info!("Attempting to mount dmg with command \"{hdiutil_cmd:?}\"");
let output = hdiutil_cmd.output().await?;
ensure!(output.status.success(), "Failed to mount dmg: {output:?}");
log::info!("hdiutil mount succeeded");
Ok(volume)
}
fn update_url(channel: Channel, version: &str) -> String {
format!(
"{}/{}",
release_assets_directory_url(channel, version),
dmg_name(channel)
)
}
fn app_name(channel: Channel) -> String {
format!("{}.app", app_name_prefix(channel))
}
fn versioned_app_name(channel: Channel, version: &str) -> String {
format!("{}({}).app", app_name_prefix(channel), version)
}
fn dmg_name(channel: Channel) -> String {
// If the user is on an Apple Silicon Mac, download an arm64-only bundle.
let is_arm64 = command::blocking::Command::new("uname")
.arg("-m")
.output()
.is_ok_and(|output| output.stdout.starts_with(b"arm64"));
if is_arm64 {
return format!("{}-arm64.dmg", app_name_prefix(channel));
}
// Otherwise, download a universal bundle.
format!("{}.dmg", app_name_prefix(channel))
}
fn app_name_prefix(channel: Channel) -> &'static str {
match channel {
Channel::Stable => "Warp",
Channel::Preview => "WarpPreview",
Channel::Local => "warp",
Channel::Integration => "integration",
Channel::Dev => "WarpDev",
Channel::Oss => "warp-oss",
}
}
fn executable_name(channel: Channel) -> &'static str {
match channel {
Channel::Stable => "stable",
Channel::Preview => "preview",
Channel::Local => "warp",
Channel::Integration => "integration",
Channel::Dev => "dev",
Channel::Oss => "warp-oss",
}
}
fn executable_path(channel: Channel) -> String {
if ChannelState::is_release_bundle() {
format!("Contents/MacOS/{}", executable_name(channel))
} else {
executable_name(channel).to_owned()
}
}
File diff suppressed because it is too large Load Diff
+584
View File
@@ -0,0 +1,584 @@
use chrono::{Local, TimeZone};
use warpui::{App, ModelHandle, ReadModel, UpdateModel};
use crate::{
auth::{AuthManager, AuthStateProvider},
server::{
server_api::ServerApiProvider, telemetry::context_provider::AppTelemetryContextProvider,
},
};
use warp_core::execution_mode::{AppExecutionMode, ExecutionMode};
use super::*;
fn initialize_app(app: &mut App) -> ModelHandle<AutoupdateState> {
let server_api_provider = app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(AuthManager::new_for_test);
let server_api = app.read_model(&server_api_provider, |server_api_provider, _| {
server_api_provider.get()
});
app.add_model(|_| AutoupdateState::new(server_api))
}
#[test]
fn test_queueing_behavior() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
app.update_model(&autoupdate_state, |autoupdate, ctx| {
assert_eq!(autoupdate.get_next_request(ctx), None);
autoupdate.request_queue.push_back(RequestType::DailyCheck);
assert_eq!(autoupdate.request_queue.len(), 1);
autoupdate.stage = AutoupdateStage::DownloadingUpdate;
assert_eq!(autoupdate.get_next_request(ctx), None);
assert_eq!(autoupdate.request_queue.len(), 1);
autoupdate.stage = AutoupdateStage::NoUpdateAvailable;
assert_eq!(
autoupdate.get_next_request(ctx),
Some(RequestType::DailyCheck)
);
assert_eq!(autoupdate.request_queue.len(), 0);
autoupdate.request_queue.push_back(RequestType::Poll);
autoupdate.request_queue.push_back(RequestType::DailyCheck);
autoupdate.request_queue.push_back(RequestType::ManualCheck);
assert_eq!(autoupdate.request_queue.len(), 3);
assert_eq!(autoupdate.get_next_request(ctx), Some(RequestType::Poll));
autoupdate.stage = AutoupdateStage::CheckingForUpdate;
assert_eq!(autoupdate.get_next_request(ctx), None);
assert_eq!(autoupdate.request_queue.len(), 2);
autoupdate.stage = AutoupdateStage::NoUpdateAvailable;
assert_eq!(
autoupdate.get_next_request(ctx),
Some(RequestType::DailyCheck)
);
assert_eq!(
autoupdate.get_next_request(ctx),
Some(RequestType::ManualCheck)
);
assert_eq!(autoupdate.request_queue.len(), 0);
});
});
}
#[test]
fn test_queue_behavior_sdk_mode() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::Sdk, false, ctx));
let autoupdate_state = initialize_app(&mut app);
app.update_model(&autoupdate_state, |autoupdate, ctx| {
assert_eq!(autoupdate.get_next_request(ctx), None);
autoupdate.request_queue.push_back(RequestType::DailyCheck);
assert_eq!(autoupdate.request_queue.len(), 1);
// The daily check should be ignored in SDK mode.
assert_eq!(autoupdate.get_next_request(ctx), None);
// Polling should be ignored in SDK mode.
autoupdate.request_queue.push_back(RequestType::Poll);
assert_eq!(autoupdate.request_queue.len(), 1);
assert_eq!(autoupdate.get_next_request(ctx), None);
// Manual checks should not be ignored in SDK mode.
autoupdate.request_queue.push_back(RequestType::ManualCheck);
assert_eq!(autoupdate.request_queue.len(), 1);
assert_eq!(
autoupdate.get_next_request(ctx),
Some(RequestType::ManualCheck)
);
// If there are ignored requests, the queue skips to the next non-ignored request.
autoupdate.request_queue.push_back(RequestType::Poll);
autoupdate.request_queue.push_back(RequestType::Poll);
autoupdate.request_queue.push_back(RequestType::DailyCheck);
autoupdate.request_queue.push_back(RequestType::ManualCheck);
assert_eq!(autoupdate.request_queue.len(), 4);
assert_eq!(
autoupdate.get_next_request(ctx),
Some(RequestType::ManualCheck)
);
assert_eq!(autoupdate.request_queue.len(), 0);
});
});
}
/// In SDK (CLI) mode, `poll_for_update` must not cause any actual update check to run.
/// Poll and DailyCheck requests are discarded by `get_next_request` so that the autoupdate
/// state machine stays at `NoUpdateAvailable`. This ensures the CLI never kicks off a
/// background update-check loop.
#[test]
fn test_cli_sdk_mode_prevents_autoupdate_polling() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::Sdk, false, ctx));
let autoupdate_state = initialize_app(&mut app);
app.update_model(&autoupdate_state, |autoupdate, ctx| {
// Simulate what the autoupdate poll loop would do: call poll_for_update.
// In SDK mode, the Poll request enqueued by poll_for_update must be discarded
// immediately without initiating a check (stage stays NoUpdateAvailable).
autoupdate.poll_for_update(ctx);
assert!(
matches!(autoupdate.stage, AutoupdateStage::NoUpdateAvailable),
"Stage must not advance to CheckingForUpdate in SDK mode"
);
assert_eq!(
autoupdate.request_queue.len(),
0,
"Poll request must be discarded, not left in the queue"
);
// DailyCheck requests must also be discarded.
autoupdate.request_queue.push_back(RequestType::DailyCheck);
autoupdate.try_execute_request(ctx);
assert!(
matches!(autoupdate.stage, AutoupdateStage::NoUpdateAvailable),
"DailyCheck must not trigger a check in SDK mode"
);
assert_eq!(autoupdate.request_queue.len(), 0);
});
});
}
/// Some user interactions like focusing/activating the app may trigger an update check
/// if the daily check hasn't been performed today. The daily check runs regardless of
/// login state so the server can track retention for anonymous users.
#[test]
fn test_user_usage_triggered_daily_check() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
let some_date = NaiveDate::from_ymd_opt(1991, 8, 22).unwrap();
app.update_model(&autoupdate_state, |autoupdate, _| {
assert!(
autoupdate.should_make_daily_request(RequestType::DailyCheck, &some_date, true),
"do daily check regardless of login state"
);
// same date with arbitrary time
set_last_successful_daily_update_check(autoupdate, 1991, 8, 22, 4, 24, 19);
assert!(
!autoupdate.should_make_daily_request(RequestType::DailyCheck, &some_date, true),
"don't do daily check again on same day"
);
assert!(
autoupdate.should_make_daily_request(
RequestType::DailyCheck,
&NaiveDate::from_ymd_opt(1991, 8, 23).unwrap(),
false
),
"do daily check on next day"
);
});
});
}
#[test]
fn test_polling_triggered_daily_check() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
let some_date = NaiveDate::from_ymd_opt(1991, 8, 22).unwrap();
app.update_model(&autoupdate_state, |autoupdate, _| {
assert!(
!autoupdate.should_make_daily_request(RequestType::Poll, &some_date, false),
"don't do daily check on poll without focus"
);
assert!(
autoupdate.should_make_daily_request(RequestType::Poll, &some_date, true),
"do daily check on poll with focus"
);
// same date with arbitrary time
set_last_successful_daily_update_check(autoupdate, 1991, 8, 22, 23, 57, 22);
assert!(
!autoupdate.should_make_daily_request(RequestType::Poll, &some_date, true),
"don't do daily check on same day"
);
assert!(
autoupdate.should_make_daily_request(
RequestType::Poll,
&NaiveDate::from_ymd_opt(1991, 8, 23).unwrap(),
true
),
"do daily check on next day"
);
});
});
}
#[test]
fn test_manually_triggered_daily_check() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
let some_date = NaiveDate::from_ymd_opt(1991, 8, 22).unwrap();
app.update_model(&autoupdate_state, |autoupdate, _| {
assert!(
autoupdate.should_make_daily_request(RequestType::ManualCheck, &some_date, true),
"do daily check regardless of login state"
);
// same date with arbitrary time
set_last_successful_daily_update_check(autoupdate, 1991, 8, 22, 1, 31, 53);
assert!(
!autoupdate.should_make_daily_request(RequestType::ManualCheck, &some_date, true),
"don't do daily check on same day"
);
assert!(
autoupdate.should_make_daily_request(
RequestType::ManualCheck,
&NaiveDate::from_ymd_opt(1991, 8, 23).unwrap(),
true
),
"do daily check on next day"
);
});
});
}
/// Helper function to assign a DateTime in EST
fn set_last_successful_daily_update_check(
autoupdate_state: &mut AutoupdateState,
year: i32,
month: u32,
day: u32,
hour: u32,
min: u32,
sec: u32,
) {
let local = Local
.from_local_datetime(
&NaiveDate::from_ymd_opt(year, month, day)
.unwrap()
.and_hms_opt(hour, min, sec)
.unwrap(),
)
.unwrap();
autoupdate_state.last_successful_daily_update_check = Some(local.with_timezone(local.offset()));
}
fn make_version_info(version_string: impl Into<String>, is_rollback: bool) -> VersionInfo {
VersionInfo {
version: version_string.into(),
update_by: None,
soft_cutoff: None,
last_prominent_update: None,
is_rollback: Some(is_rollback),
version_for_new_users: None,
cli_version: None,
}
}
/// When a download fails, `downloaded_update` must stay None so the next poll retries.
/// This is the state-machine behavior underlying a disk-space issue where,
/// without cleanup, every failed download retry would leave lots of failed artifacts behind,
/// eventually filling the user's cache directory.
#[test]
fn test_download_failure_allows_retry() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
app.update_model(&autoupdate_state, |autoupdate, ctx| {
ChannelState::set_app_version(Some("v0.2023.05.15.08.04.stable_01"));
let target_version = "v0.2023.05.15.08.04.stable_02";
autoupdate.on_download_update_complete(
RequestType::Poll,
make_version_info(target_version, false),
"failed_id".to_string(),
Err(anyhow!("simulated download failure")),
ctx,
);
// After failure: downloaded_update must remain None.
assert!(
autoupdate.downloaded_update.is_none(),
"downloaded_update should not be set after a failed download"
);
assert_eq!(
autoupdate.stage,
AutoupdateStage::NoUpdateAvailable,
"Stage should reset to NoUpdateAvailable after download failure"
);
// The next should_update call must return CanDownload (allowing retry).
let version = make_version_info(target_version, false);
let result = autoupdate.should_update(version, "retry_id".to_string());
assert!(
matches!(result, UpdateReady::CanDownload { .. }),
"Should allow retry download after failure"
);
});
});
}
/// After a successful download, `downloaded_update` is set and subsequent `should_update`
/// calls return `UpdateReady::Yes` — preventing re-downloads on the next poll.
#[test]
fn test_successful_download_prevents_redownload() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
app.update_model(&autoupdate_state, |autoupdate, ctx| {
ChannelState::set_app_version(Some("v0.2023.05.15.08.04.stable_01"));
let target_version = "v0.2023.05.15.08.04.stable_02";
autoupdate.on_download_update_complete(
RequestType::Poll,
make_version_info(target_version, false),
"success_id".to_string(),
Ok(DownloadReady::Yes),
ctx,
);
// After success: downloaded_update must be set.
let download = autoupdate
.downloaded_update
.as_ref()
.expect("downloaded_update should be set after successful download");
assert_eq!(download.version.version, target_version);
assert_eq!(download.update_id, "success_id");
assert!(
matches!(autoupdate.stage, AutoupdateStage::UpdateReady { .. }),
"Stage should be UpdateReady after successful download"
);
// The next should_update call must return Yes, NOT CanDownload.
let version = make_version_info(target_version, false);
let result = autoupdate.should_update(version, "another_id".to_string());
match result {
UpdateReady::Yes { update_id, .. } => {
assert_eq!(
update_id, "success_id",
"Should reuse the existing update_id, not trigger a new download"
);
}
other => panic!(
"Expected UpdateReady::Yes but got {other:?} — this would cause re-downloading!"
),
}
});
});
}
/// After a successful download of v2, if a download of v3 fails, `downloaded_update`
/// must still point to v2. This is the state-machine invariant that enables the filesystem
/// fix: `download_new_update` captures `last_successful_update_id` from `downloaded_update`,
/// so failure cleanup preserves the old download's directory on disk.
#[test]
fn test_failed_download_preserves_previous_successful_download() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
app.update_model(&autoupdate_state, |autoupdate, ctx| {
ChannelState::set_app_version(Some("v0.2023.05.15.08.04.stable_01"));
let v2 = "v0.2023.05.15.08.04.stable_02";
let v3 = "v0.2023.05.15.08.04.stable_03";
// Successful download of v2.
autoupdate.on_download_update_complete(
RequestType::Poll,
make_version_info(v2, false),
"id_v2".to_string(),
Ok(DownloadReady::Yes),
ctx,
);
assert_eq!(
autoupdate.downloaded_update.as_ref().unwrap().update_id,
"id_v2"
);
assert!(matches!(
autoupdate.stage,
AutoupdateStage::UpdateReady { .. }
));
// Failed download of v3.
autoupdate.on_download_update_complete(
RequestType::Poll,
make_version_info(v3, false),
"id_v3_fail".to_string(),
Err(anyhow!("simulated network failure")),
ctx,
);
// v2 download must be preserved.
let download = autoupdate
.downloaded_update
.as_ref()
.expect("downloaded_update should still reference the v2 download");
assert_eq!(download.version.version, v2);
assert_eq!(download.update_id, "id_v2");
assert_eq!(autoupdate.stage, AutoupdateStage::NoUpdateAvailable);
// Re-checking for v2 should return Yes (already downloaded).
let result =
autoupdate.should_update(make_version_info(v2, false), "check_v2".to_string());
assert!(
matches!(result, UpdateReady::Yes { ref update_id, .. } if update_id == "id_v2"),
"should_update for the preserved v2 should return Yes with the original update_id"
);
// Checking for v3 should return CanDownload (retry).
let result =
autoupdate.should_update(make_version_info(v3, false), "check_v3".to_string());
assert!(
matches!(result, UpdateReady::CanDownload { .. }),
"should_update for v3 should allow a retry download"
);
});
});
}
/// Full cycle: success → failure (preserves) → retry success (replaces).
/// Verifies that after a failed download preserves an earlier success, a subsequent
/// successful download correctly replaces it.
#[test]
fn test_successful_download_after_failure_replaces_preserved_download() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
app.update_model(&autoupdate_state, |autoupdate, ctx| {
ChannelState::set_app_version(Some("v0.2023.05.15.08.04.stable_01"));
let v2 = "v0.2023.05.15.08.04.stable_02";
let v3 = "v0.2023.05.15.08.04.stable_03";
// Step 1: Successful download of v2.
autoupdate.on_download_update_complete(
RequestType::Poll,
make_version_info(v2, false),
"id_v2".to_string(),
Ok(DownloadReady::Yes),
ctx,
);
assert_eq!(autoupdate.downloaded_update.as_ref().unwrap().update_id, "id_v2");
// Step 2: Failed download of v3 — v2 preserved.
autoupdate.on_download_update_complete(
RequestType::Poll,
make_version_info(v3, false),
"id_v3_fail".to_string(),
Err(anyhow!("network error")),
ctx,
);
assert_eq!(autoupdate.downloaded_update.as_ref().unwrap().update_id, "id_v2");
// Step 3: Retry v3 succeeds — replaces v2.
autoupdate.on_download_update_complete(
RequestType::Poll,
make_version_info(v3, false),
"id_v3_success".to_string(),
Ok(DownloadReady::Yes),
ctx,
);
let download = autoupdate.downloaded_update.as_ref().unwrap();
assert_eq!(download.version.version, v3);
assert_eq!(download.update_id, "id_v3_success");
assert!(matches!(autoupdate.stage, AutoupdateStage::UpdateReady { .. }));
// v3 is now the downloaded version.
let result = autoupdate.should_update(make_version_info(v3, false), "check".to_string());
assert!(
matches!(result, UpdateReady::Yes { ref update_id, .. } if update_id == "id_v3_success"),
"should_update for v3 should return Yes with the new update_id"
);
});
});
}
#[test]
fn test_should_update() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let autoupdate_state = initialize_app(&mut app);
app.update_model(&autoupdate_state, |autoupdate, _| {
// Test 1: No version tag set
ChannelState::set_app_version(None);
let version = make_version_info(
"v0.2023.05.15.08.04.stable_01",
false, /* is_rollback */
);
let result = autoupdate.should_update(version, "update1".to_string());
assert!(
matches!(result, UpdateReady::No),
"Should not update when no version tag is set"
);
// Test 2: Already up to date
ChannelState::set_app_version(Some("v0.2023.05.15.08.04.stable_01"));
let version = make_version_info(
"v0.2023.05.15.08.04.stable_01",
false, /* is_rollback */
);
let result = autoupdate.should_update(version, "update2".to_string());
assert!(
matches!(result, UpdateReady::No),
"Should not update when already on the latest version"
);
// Test 3: Current version ahead of server version (no rollback)
ChannelState::set_app_version(Some("v0.2023.05.15.08.04.stable_02"));
let version = make_version_info(
"v0.2023.05.15.08.04.stable_01",
false, /* is_rollback */
);
let result = autoupdate.should_update(version, "update3".to_string());
assert!(
matches!(result, UpdateReady::No),
"Should not update when current version is ahead and no rollback"
);
// Test 4: Current version ahead of server version (with rollback)
ChannelState::set_app_version(Some("v0.2023.05.15.08.04.stable_02"));
let version =
make_version_info("v0.2023.05.15.08.04.stable_01", true /* is_rollback */);
let result = autoupdate.should_update(version, "update4".to_string());
assert!(
matches!(result, UpdateReady::CanDownload { .. }),
"Should update when current version is ahead but rollback is true"
);
// Test 5: New update available for download
ChannelState::set_app_version(Some("v0.2023.05.15.08.04.stable_01"));
let version = make_version_info(
"v0.2023.05.15.08.04.stable_02",
false, /* is_rollback */
);
let result = autoupdate.should_update(version.clone(), "updateid".to_string());
match result {
UpdateReady::CanDownload {
new_version,
update_id,
} => {
assert_eq!(
new_version.version, "v0.2023.05.15.08.04.stable_02",
"New version should match server version"
);
assert_eq!(
update_id, "updateid",
"Update ID should match provided update ID"
);
}
_ => panic!("Expected UpdateReady::CanDownload for new update"),
}
});
});
}
+264
View File
@@ -0,0 +1,264 @@
use crate::server::telemetry::TelemetryEvent;
use anyhow::anyhow;
use anyhow::{bail, Result};
use channel_versions::VersionInfo;
use command::blocking::Command;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use std::fs::File;
use std::path::PathBuf;
use std::sync::Arc;
use std::{fs, io};
use std::{io::Write as _, time::Duration};
use tempfile::TempPath;
use warp_core::channel::{Channel, ChannelState};
use warpui::AppContext;
use super::{release_assets_directory_url, DownloadReady};
use crate::util::windows::install_dir;
lazy_static! {
/// The path to the temporary file that stores the installer for the new update.
static ref INSTALLER_PATH: Arc<Mutex<Option<TempPath>>> = Default::default();
}
/// Download the Inno Setup install wizard, the same one users run on the first Warp install, and
/// place it into the "data dir".
pub(super) async fn download_update_and_cleanup(
version_info: &VersionInfo,
_update_id: &str,
client: &http_client::Client,
) -> Result<DownloadReady> {
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
let installer_file_name = installer_file_name()?;
let url = format!(
"{}/{}",
release_assets_directory_url(ChannelState::channel(), &version_info.version),
installer_file_name
);
// Create a temporary file that we'll write the download into.
let mut already_exists = false;
let mut new_installer = tempfile::Builder::new()
.rand_bytes(0)
.suffix(&format!("{}-{}", version_info.version, installer_file_name))
.make(|path| {
already_exists = path.is_file();
if already_exists {
File::open(path)
} else {
File::create(path)
}
})?;
if !already_exists {
log::info!("Downloading {url} to {}...", new_installer.path().display());
let response = client
.get(&url)
.timeout(DOWNLOAD_TIMEOUT)
.send()
.await?
.error_for_status()?;
new_installer
.as_file_mut()
.write_all(&response.bytes().await?)?;
}
*INSTALLER_PATH.lock() = Some(new_installer.into_temp_path());
Ok(DownloadReady::Yes)
}
const UPDATE_LOG_FILENAME: &str = "warp_update.log";
fn autoupdate_log_file() -> Result<PathBuf> {
warp_logging::log_directory().map(|dir| dir.join(UPDATE_LOG_FILENAME))
}
/// Checks the autoupdate log file from a previous update attempt.
/// Sends telemetry for specific known issues, and sends a Sentry event if errors are found.
/// The log file is renamed after processing to avoid duplicate reports on subsequent launches.
pub(super) fn check_and_report_update_errors(ctx: &mut AppContext) {
let log_path = match autoupdate_log_file() {
Ok(path) => path,
Err(e) => {
log::warn!("Failed to determine autoupdate log file path: {e:#}");
return;
}
};
// Inno Setup logs use the system's active codepage (often Windows-1252), not UTF-8.
// We read as raw bytes to avoid silently skipping non-UTF-8 log files.
let contents = match fs::read(&log_path) {
Ok(contents) => contents,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
log::info!("No autoupdate logs found");
return;
}
Err(e) => {
log::warn!("Failed to read autoupdate log file: {e:#}");
return;
}
};
let contents_lowercase = contents.to_ascii_lowercase();
let has_unable_to_close = memchr::memmem::find(
&contents_lowercase,
b"setup was unable to automatically close all applications",
)
.is_some();
if has_unable_to_close {
crate::send_telemetry_sync_from_app_ctx!(
TelemetryEvent::AutoupdateUnableToCloseApplications,
ctx
);
}
let has_file_in_use = memchr::memmem::find(
&contents_lowercase,
b"the process cannot access the file because it is being used by another process",
)
.is_some();
if has_file_in_use {
crate::send_telemetry_sync_from_app_ctx!(TelemetryEvent::AutoupdateFileInUse, ctx);
}
// Fired when the mutex polling loop timed out and a force-kill was attempted.
let has_mutex_timeout =
memchr::memmem::find(&contents_lowercase, b"warp mutex still held after timeout").is_some();
if has_mutex_timeout {
crate::send_telemetry_sync_from_app_ctx!(TelemetryEvent::AutoupdateMutexTimeout, ctx);
}
// Fired when taskkill returned non-zero after the mutex timeout.
let has_forcekill_failed =
memchr::memmem::find(&contents_lowercase, b"force-kill failed for").is_some();
if has_forcekill_failed {
crate::send_telemetry_sync_from_app_ctx!(TelemetryEvent::AutoupdateForcekillFailed, ctx);
}
#[cfg(feature = "crash_reporting")]
{
use sentry::protocol::{Attachment, AttachmentType};
// Patterns for known benign errors that should not trigger Sentry reporting.
const IGNOREABLE_ERRORS: &[&[u8]] = &[
// User running out of disk space is not an error we need concern ourselves with.
// This message occurs after "An error occurred while trying to copy a file:"
b"there is not enough space on the disk",
// Recent Inno Setup versions try to enable a security feature which is unavailable on
// Windows 10 versions prior to 22H2 and this call fails. The failure is benign.
b"setprocessmitigationpolicy failed with error code 87",
];
let mut error_count = memchr::memmem::find_iter(&contents_lowercase, b"error").count();
for pattern in IGNOREABLE_ERRORS {
let ignoreable_count = memchr::memmem::find_iter(&contents_lowercase, pattern).count();
error_count = error_count.saturating_sub(ignoreable_count);
}
if error_count > 0 {
log::warn!("Autoupdate log file contains errors; reporting to Sentry");
let attachment = Attachment {
buffer: contents,
filename: UPDATE_LOG_FILENAME.to_string(),
ty: Some(AttachmentType::Attachment),
..Default::default()
};
sentry::with_scope(
|scope| {
scope.add_attachment(attachment);
},
|| sentry::capture_message("Windows auto-update error", sentry::Level::Error),
);
}
}
// Rename the log file to avoid duplicate reports on subsequent launches.
// We keep the file around so the user can still view it or attach it to a GitHub issue.
let reported_path = log_path.with_extension("log.reported");
if let Err(e) = fs::rename(&log_path, &reported_path) {
log::warn!("Failed to rename autoupdate log file after reporting: {e:#}");
}
}
pub(super) fn relaunch() -> Result<()> {
let install_dir = install_dir()?;
let Some(installer_path) = INSTALLER_PATH.lock().take() else {
bail!("No installer path");
};
let log_arg = match autoupdate_log_file() {
Ok(dir) => format!("/LOG={}", dir.display()),
Err(e) => {
log::warn!("Failed to determine location for autoupdate logs: {e:#}");
"/LOG".to_string()
}
};
// The Inno Setup install wizard will run without user input. It will re-launch Warp after
// installing the update files.
// https://jrsoftware.org/ishelp/index.php?topic=setupcmdline
Command::new(&installer_path)
.args([
// Skip asking the user to confirm.
"/SP-",
// Do not prompt the user for anything. Note that we do not use "VERYSILENT" so that a
// progress bar is still shown. This is useful since the update process may take a few
// seconds.
"/SILENT",
// Do not provide a cancel button on the progress bar page.
"/NOCANCEL",
// Indicate that restarting Windows is not necessary.
"/NORESTART",
&log_arg,
"/update=1",
// Do not forcibly kill Warp via RestartManager. The installer will wait for
// Warp to exit naturally by polling the single-instance mutex instead.
"/NOCLOSEAPPLICATIONS",
&format!("/DIR={}", install_dir.display()),
])
.spawn()?;
// DEV ONLY: Sleep after spawning the installer so this process is still alive
// when Inno Setup tries to overwrite files. This reliably reproduces the
// auto-update race condition (APP-3702) for testing.
if matches!(ChannelState::channel(), Channel::Dev) {
log::info!("DEV: Sleeping 10s after spawning installer to reproduce update race");
std::thread::sleep(Duration::from_secs(10));
}
Ok(())
}
fn installer_file_name() -> Result<String> {
let app_name_prefix = app_name_prefix(ChannelState::channel());
// For example, on arm64 this is WarpSetup-arm64.exe and on x64 this is
// WarpSetup.exe.
if cfg!(target_arch = "aarch64") {
Ok(format!("{app_name_prefix}Setup-arm64.exe"))
} else if cfg!(target_arch = "x86_64") {
Ok(format!("{app_name_prefix}Setup.exe"))
} else {
Err(anyhow!(
"Could not construct setup file name for unsupported architecture"
))
}
}
fn app_name_prefix(channel: Channel) -> &'static str {
match channel {
Channel::Stable => "Warp",
Channel::Preview => "WarpPreview",
Channel::Local => "warp",
Channel::Integration => "integration",
Channel::Dev => "WarpDev",
Channel::Oss => "warp-oss",
}
}