Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user