1106 lines
42 KiB
Rust
1106 lines
42 KiB
Rust
#![allow(dead_code, unused_imports, unused_variables)]
|
|
mod changelog;
|
|
mod channel_versions;
|
|
#[cfg(target_os = "linux")]
|
|
pub mod linux;
|
|
#[cfg(target_os = "macos")]
|
|
mod mac;
|
|
#[cfg(windows)]
|
|
mod windows;
|
|
|
|
use crate::features::FeatureFlag;
|
|
use crate::send_telemetry_sync_from_app_ctx;
|
|
use crate::server::server_api::ServerApi;
|
|
use crate::server::telemetry::TelemetryEvent;
|
|
use crate::workspace::Workspace;
|
|
use crate::{
|
|
channel::Channel, report_if_error, send_telemetry_from_ctx, server::datetime_ext::DateTimeExt,
|
|
ChannelState,
|
|
};
|
|
use ::channel_versions::{ParsedVersion, VersionInfo};
|
|
use anyhow::{anyhow, Context as _, Result};
|
|
use chrono::{DateTime, FixedOffset, NaiveDate};
|
|
use galaxy_core::execution_mode::AppExecutionMode;
|
|
use galaxyui::platform::TerminationMode;
|
|
use galaxyui::r#async::Timer;
|
|
use galaxyui::windowing::state::ApplicationStage;
|
|
use galaxyui::windowing::{self, WindowManager};
|
|
use galaxyui::{
|
|
accessibility::{AccessibilityContent, WarpA11yRole},
|
|
AppContext,
|
|
};
|
|
use galaxyui::{Entity, ModelContext, SingletonEntity, ViewContext};
|
|
use rand::Rng as _;
|
|
use std::collections::VecDeque;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
pub use self::changelog::get_current_changelog;
|
|
use self::channel_versions::fetch_channel_versions;
|
|
|
|
/// A successfully downloaded and unpacked target update.
|
|
#[derive(Clone, Debug)]
|
|
pub struct DownloadedUpdate {
|
|
pub version: VersionInfo,
|
|
pub update_id: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
|
pub enum AutoupdateStage {
|
|
/// No update available as of the last check with the server.
|
|
#[default]
|
|
NoUpdateAvailable,
|
|
/// Checking for an update and downloading it if one exists.
|
|
CheckingForUpdate,
|
|
/// The new version is being downloaded.
|
|
DownloadingUpdate,
|
|
/// An update exists but the user does not have authorization to install it.
|
|
UnableToUpdateToNewVersion { new_version: VersionInfo },
|
|
/// An update has been downloaded and is ready for relaunch.
|
|
UpdateReady {
|
|
new_version: VersionInfo,
|
|
update_id: String,
|
|
},
|
|
/// A relaunch has been initiated to use the new, downloaded version.
|
|
Updating {
|
|
new_version: VersionInfo,
|
|
update_id: String,
|
|
},
|
|
/// A relaunch was initiated to use the new version, but failed.
|
|
UnableToLaunchNewVersion { new_version: VersionInfo },
|
|
/// A new version was installed, but Warp hasn't restarted yet.
|
|
///
|
|
/// This state is only used on macOS, where the update isn't fully applied until right before
|
|
/// restarting.
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|
UpdatedPendingRestart { new_version: VersionInfo },
|
|
}
|
|
|
|
impl AutoupdateStage {
|
|
/// Returns `true` if we're ready to relaunch and apply an update.
|
|
pub fn ready_for_update(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
AutoupdateStage::UpdateReady { .. } | AutoupdateStage::UpdatedPendingRestart { .. }
|
|
)
|
|
}
|
|
|
|
/// Returns the new version's VersionInfo, if available in the current autoupdate stage.
|
|
pub fn available_new_version(&self) -> Option<&VersionInfo> {
|
|
match self {
|
|
AutoupdateStage::UpdateReady { new_version, .. }
|
|
| AutoupdateStage::Updating { new_version, .. }
|
|
| AutoupdateStage::UpdatedPendingRestart { new_version }
|
|
| AutoupdateStage::UnableToLaunchNewVersion { new_version }
|
|
| AutoupdateStage::UnableToUpdateToNewVersion { new_version } => Some(new_version),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct AutoupdateState {
|
|
/// We only want to hit /client_version/daily about once a day. This field is client state for
|
|
/// implementing the logic so that we (mostly) limit requests to that endpoint to once a day,
|
|
/// though we don't persist that across app-restarts.
|
|
last_successful_daily_update_check: Option<DateTime<FixedOffset>>,
|
|
stage: AutoupdateStage,
|
|
/// The most recently downloaded and extracted update. We need this so that if there are
|
|
/// multiple update checks without a relaunch, we only download the update once.
|
|
downloaded_update: Option<DownloadedUpdate>,
|
|
/// Holds requests for update checks that are awaiting to be executed. We need this because we
|
|
/// prevent update checks from starting if there is another already in-flight. The different
|
|
/// RequestTypes have different behavior and side-effects, so it's important not to skip any
|
|
/// but to queue them instead.
|
|
request_queue: VecDeque<RequestType>,
|
|
server_api: Arc<ServerApi>,
|
|
}
|
|
|
|
impl AutoupdateState {
|
|
pub fn new(server_api: Arc<ServerApi>) -> Self {
|
|
Self {
|
|
server_api,
|
|
last_successful_daily_update_check: None,
|
|
stage: AutoupdateStage::default(),
|
|
downloaded_update: None,
|
|
request_queue: VecDeque::new(),
|
|
}
|
|
}
|
|
|
|
pub fn register(ctx: &mut AppContext, server_api: Arc<ServerApi>) {
|
|
ctx.add_singleton_model(move |_ctx| Self::new(server_api));
|
|
}
|
|
|
|
/// Check if any requests are pending. If there are and we're ready to submit a new request,
|
|
/// run it.
|
|
fn try_execute_request(&mut self, ctx: &mut ModelContext<Self>) {
|
|
if let Some(next_request) = self.get_next_request(ctx) {
|
|
self.check_for_update(next_request, ctx);
|
|
}
|
|
}
|
|
|
|
/// Check if there are any requests in the queue. Return the next one, but only if there isn't
|
|
/// already a request in-flight.
|
|
fn get_next_request(&mut self, _ctx: &mut ModelContext<Self>) -> Option<RequestType> {
|
|
None
|
|
}
|
|
|
|
/// After queueing the request, immediately try executing it.
|
|
fn enqueue_request(&mut self, request_type: RequestType, ctx: &mut ModelContext<Self>) {
|
|
self.request_queue.push_back(request_type);
|
|
self.try_execute_request(ctx);
|
|
}
|
|
|
|
// Poll for updates once per 10 minutes.
|
|
const AUTOUPDATE_POLL: Duration = Duration::from_secs(10 * 60);
|
|
|
|
/// This method recursively calls itself after a delay. Call it once and only once to start the
|
|
/// loop.
|
|
fn poll_for_update(&mut self, ctx: &mut ModelContext<Self>) {
|
|
self.enqueue_request(RequestType::Poll, ctx);
|
|
ctx.spawn(
|
|
async {
|
|
Timer::after(Self::AUTOUPDATE_POLL).await;
|
|
},
|
|
|me, _, ctx| me.poll_for_update(ctx),
|
|
);
|
|
}
|
|
|
|
/// User-initiated check for updates.
|
|
pub fn manually_check_for_update(&mut self, ctx: &mut ModelContext<Self>) {
|
|
self.enqueue_request(RequestType::ManualCheck, ctx);
|
|
}
|
|
|
|
fn should_start_update_check(&self) -> bool {
|
|
!matches!(
|
|
self.stage,
|
|
AutoupdateStage::CheckingForUpdate
|
|
| AutoupdateStage::DownloadingUpdate
|
|
| AutoupdateStage::UpdatedPendingRestart { .. }
|
|
)
|
|
}
|
|
|
|
/// Trigger the update check to /client_version/daily, but only go through with sending the
|
|
/// request if we haven't done that today.
|
|
pub fn maybe_daily_check_for_update(&mut self, ctx: &mut ModelContext<Self>) {
|
|
self.enqueue_request(RequestType::DailyCheck, ctx)
|
|
}
|
|
|
|
/// Check if an update is available.
|
|
///
|
|
/// The caller is responsible for checking that we _should_ check for an update. Generally, the
|
|
/// only caller should be [`Self::try_execute_request`].
|
|
fn check_for_update(&mut self, request_type: RequestType, ctx: &mut ModelContext<Self>) {
|
|
let current_date = DateTime::now().date_naive();
|
|
let is_daily = self.should_make_daily_request(
|
|
request_type,
|
|
¤t_date,
|
|
ctx.windows().app_is_active(),
|
|
);
|
|
|
|
// Other RequestTypes will fallback to hitting `/client_version`, but DailyCheck will not.
|
|
if request_type == RequestType::DailyCheck && !is_daily {
|
|
return;
|
|
}
|
|
|
|
self.stage = AutoupdateStage::CheckingForUpdate;
|
|
ctx.notify();
|
|
|
|
let server_api = self.server_api.clone();
|
|
ctx.spawn(
|
|
async move {
|
|
let update_id = new_update_id();
|
|
let channel = ChannelState::channel();
|
|
log::info!("Checking for update on channel {channel}. Update id is {update_id}");
|
|
let version = fetch_version(&channel, is_daily, &update_id, server_api)
|
|
.await
|
|
.context("Error checking for new version");
|
|
report_if_error!(version);
|
|
(update_id, version)
|
|
},
|
|
move |me, (update_id, version), ctx| {
|
|
me.on_update_check_complete(request_type, update_id, version, is_daily, ctx);
|
|
},
|
|
);
|
|
}
|
|
|
|
// Only make this the `/client_version/daily` request if:
|
|
// 1. We haven't yet done it today.
|
|
// 2. The app is currently active during this polling period, or if we've explicitly
|
|
// triggered the daily request rather than waiting for the poll interval. Note that
|
|
// the app will not be considered active if the system is sleeping.
|
|
fn should_make_daily_request(
|
|
&self,
|
|
request_type: RequestType,
|
|
current_date: &NaiveDate,
|
|
app_is_active: bool,
|
|
) -> bool {
|
|
let daily_check_still_todo =
|
|
self.last_successful_daily_update_check
|
|
.is_none_or(|last_check_datetime| {
|
|
let last_check_date = last_check_datetime.date_naive();
|
|
// Is today's date incremented from the date of the last check?
|
|
*current_date > last_check_date
|
|
});
|
|
|
|
daily_check_still_todo
|
|
&& ((request_type == RequestType::Poll && app_is_active)
|
|
|| request_type == RequestType::ManualCheck
|
|
|| request_type == RequestType::DailyCheck)
|
|
}
|
|
|
|
/// Given a newly-available version, check if we should update.
|
|
fn should_update(&mut self, version: VersionInfo, update_id: String) -> UpdateReady {
|
|
let current_version = match ChannelState::app_version() {
|
|
Some(version) => version,
|
|
None => {
|
|
log::info!("No version tag set, cannot autoupdate");
|
|
return UpdateReady::No;
|
|
}
|
|
};
|
|
|
|
// In case the version didn't update, but the soft cutoff did,
|
|
// let's make sure that's reflected in the last downloaded update that we continue to use.
|
|
if let Some(download) = self.downloaded_update.as_mut() {
|
|
download
|
|
.version
|
|
.soft_cutoff
|
|
.clone_from(&version.soft_cutoff);
|
|
download.version.update_by = version.update_by;
|
|
}
|
|
|
|
if version.version == current_version {
|
|
log::info!("Already up to date with {}", version.version);
|
|
UpdateReady::No
|
|
} else {
|
|
if let Ok(true) =
|
|
self.is_current_version_ahead_of_latest_version(&version, current_version)
|
|
{
|
|
let is_rollback = version.is_rollback.unwrap_or(false);
|
|
if !is_rollback {
|
|
log::info!(
|
|
"Current version ({}) is ahead of version in channel versions({}), not updating",
|
|
current_version,
|
|
version.version
|
|
);
|
|
return UpdateReady::No;
|
|
}
|
|
}
|
|
|
|
// We should update - the only thing left to do is check if this version is already
|
|
// downloaded.
|
|
match &self.downloaded_update {
|
|
Some(downloaded_update) if downloaded_update.version.version == version.version => {
|
|
// This case occurs if a check runs after an update has already been
|
|
// downloaded, but not yet applied, i.e. when opening a new window.
|
|
log::info!(
|
|
"Already downloaded {} in update_id {}",
|
|
version.version,
|
|
downloaded_update.update_id
|
|
);
|
|
UpdateReady::Yes {
|
|
new_version: downloaded_update.version.clone(),
|
|
update_id: downloaded_update.update_id.clone(),
|
|
}
|
|
}
|
|
_ => {
|
|
// Either we haven't downloaded any updates or we've downloaded a different
|
|
// version.
|
|
UpdateReady::CanDownload {
|
|
new_version: version,
|
|
update_id,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns whether the current version is ahead of the version reported by the server as the "latest" version
|
|
/// in channel versions.
|
|
fn is_current_version_ahead_of_latest_version(
|
|
&self,
|
|
new_version: &VersionInfo,
|
|
current_version: &str,
|
|
) -> Result<bool> {
|
|
let current_version = ParsedVersion::try_from(current_version)?;
|
|
let new_version = ParsedVersion::try_from(new_version.version.as_str())?;
|
|
Ok(current_version > new_version)
|
|
}
|
|
|
|
fn on_update_check_complete(
|
|
&mut self,
|
|
request_type: RequestType,
|
|
update_id: String,
|
|
version: Result<VersionInfo>,
|
|
is_daily: bool,
|
|
ctx: &mut ModelContext<AutoupdateState>,
|
|
) {
|
|
if is_daily && version.is_ok() {
|
|
self.last_successful_daily_update_check = Some(DateTime::now());
|
|
}
|
|
|
|
// If one update was already applied, we cannot apply another.
|
|
if matches!(self.stage, AutoupdateStage::UpdatedPendingRestart { .. }) {
|
|
return;
|
|
}
|
|
|
|
let update_available = version.map(|version| self.should_update(version, update_id));
|
|
match &update_available {
|
|
Ok(UpdateReady::CanDownload {
|
|
new_version,
|
|
update_id,
|
|
}) => {
|
|
self.download_new_update(update_id.clone(), request_type, new_version.clone(), ctx);
|
|
// We report the update status after attempting to download the update.
|
|
return;
|
|
}
|
|
Ok(UpdateReady::Yes {
|
|
new_version,
|
|
update_id,
|
|
}) => {
|
|
// UpdateReady::Yes means the update has already been downloaded.
|
|
//
|
|
// If so, and we're already in AutoupdateStage::UpdateReady for this version, that
|
|
// means we'd previously downloaded and unpacked the update but did not restart.
|
|
// We can use the existing download, which will have its own update_id.
|
|
//
|
|
// If we're in a different state, then we downloaded the update but haven't
|
|
// reported it as ready yet.
|
|
// TODO(ben): I'm not sure this state is reachable, try simplifying along with
|
|
// removing DOWNLOADED_UPDATE.
|
|
let already_checked_for_update = matches!(&self.stage,
|
|
AutoupdateStage::UpdateReady {
|
|
new_version: existing_new_version,
|
|
..
|
|
} if new_version == existing_new_version);
|
|
if already_checked_for_update {
|
|
log::info!("Already downloaded update for {}", new_version.version);
|
|
} else {
|
|
self.stage = AutoupdateStage::UpdateReady {
|
|
new_version: new_version.clone(),
|
|
update_id: update_id.clone(),
|
|
};
|
|
}
|
|
ctx.emit(AutoupdateStateEvent::UpdateAvailable);
|
|
}
|
|
Ok(UpdateReady::No) => {
|
|
self.stage = AutoupdateStage::NoUpdateAvailable;
|
|
log::info!("No update available");
|
|
}
|
|
Err(ref e) => {
|
|
// We commonly get errors as the autoupdate code runs when a laptop wakes up
|
|
// briefly while asleep, but the network call to check for updates gets cancelled
|
|
// when returning to sleep. So we fail silently and wait for the next update poll.
|
|
self.stage = AutoupdateStage::NoUpdateAvailable;
|
|
log::warn!("Error checking for update {e:#}");
|
|
}
|
|
};
|
|
|
|
self.on_check_complete(update_available, request_type, ctx);
|
|
}
|
|
|
|
fn download_new_update(
|
|
&mut self,
|
|
update_id: String,
|
|
request_type: RequestType,
|
|
new_version: VersionInfo,
|
|
ctx: &mut ModelContext<AutoupdateState>,
|
|
) {
|
|
self.stage = AutoupdateStage::DownloadingUpdate;
|
|
ctx.notify();
|
|
|
|
// We're downloading `new_version` as update `update_id`.
|
|
//
|
|
// In case we previously downloaded an update and haven't applied it yet,
|
|
// make sure not to clean up the last successful download as part of this one.
|
|
// If the user applies that update while we're downloading this one, we don't
|
|
// want to have cleaned it up.
|
|
let last_successful_update_id =
|
|
self.downloaded_update.as_ref().map(|d| d.update_id.clone());
|
|
let _ = ctx.spawn(
|
|
download_update(
|
|
new_version.clone(),
|
|
update_id.clone(),
|
|
last_successful_update_id,
|
|
self.server_api.clone(),
|
|
),
|
|
move |autoupdate_state, download_ready, ctx| {
|
|
autoupdate_state.on_download_update_complete(
|
|
request_type,
|
|
new_version,
|
|
update_id,
|
|
download_ready,
|
|
ctx,
|
|
)
|
|
},
|
|
);
|
|
}
|
|
|
|
fn on_download_update_complete(
|
|
&mut self,
|
|
request_type: RequestType,
|
|
new_version: VersionInfo,
|
|
update_id: String,
|
|
download_ready: Result<DownloadReady>,
|
|
ctx: &mut ModelContext<AutoupdateState>,
|
|
) {
|
|
let was_update_available = match download_ready {
|
|
Ok(DownloadReady::Yes) => {
|
|
self.clear_old_autoupdate_dirs(&update_id, ctx);
|
|
self.downloaded_update = Some(DownloadedUpdate {
|
|
version: new_version.clone(),
|
|
update_id: update_id.clone(),
|
|
});
|
|
self.stage = AutoupdateStage::UpdateReady {
|
|
new_version: new_version.clone(),
|
|
update_id: update_id.clone(),
|
|
};
|
|
log::info!(
|
|
"Downloaded update to {} at update ID {update_id}",
|
|
new_version.version
|
|
);
|
|
Ok(UpdateReady::Yes {
|
|
new_version,
|
|
update_id,
|
|
})
|
|
}
|
|
Ok(DownloadReady::NeedsAuthorization) => {
|
|
send_telemetry_from_ctx!(TelemetryEvent::UnableToAutoUpdateToNewVersion, ctx);
|
|
self.stage = AutoupdateStage::UnableToUpdateToNewVersion { new_version };
|
|
Ok(UpdateReady::No)
|
|
}
|
|
Ok(DownloadReady::No) => {
|
|
log::info!("Could not download a newer version");
|
|
self.stage = AutoupdateStage::NoUpdateAvailable;
|
|
Ok(UpdateReady::No)
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Error downloading update {e:#}");
|
|
// We commonly get errors as the autoupdate code runs when a laptop wakes up
|
|
// briefly while asleep, but the network call to download gets cancelled when
|
|
// returning to sleep. So we fail silently and wait for the next update poll.
|
|
self.stage = AutoupdateStage::NoUpdateAvailable;
|
|
Err(e)
|
|
}
|
|
};
|
|
|
|
ctx.emit(AutoupdateStateEvent::UpdateAvailable);
|
|
self.on_check_complete(was_update_available, request_type, ctx);
|
|
}
|
|
|
|
/// Clean up all old autoupdate directories except the current one.
|
|
#[cfg_attr(not(target_os = "macos"), expect(unused_variables))]
|
|
fn clear_old_autoupdate_dirs(&self, update_id: &str, ctx: &mut ModelContext<Self>) {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let update_id_owned = update_id.to_owned();
|
|
ctx.spawn(
|
|
async move {
|
|
// Clean up all autoupdate directories except any current ones
|
|
mac::cleanup_all_except(Some(&update_id_owned)).await;
|
|
},
|
|
|_, _, _| {},
|
|
);
|
|
}
|
|
}
|
|
|
|
fn on_check_complete(
|
|
&mut self,
|
|
update_available: Result<UpdateReady>,
|
|
request_type: RequestType,
|
|
ctx: &mut ModelContext<AutoupdateState>,
|
|
) {
|
|
if let Some(content) = accessibility_content(&update_available, request_type) {
|
|
ctx.emit_a11y_content(content);
|
|
}
|
|
|
|
ctx.emit(AutoupdateStateEvent::CheckComplete {
|
|
result: update_available,
|
|
request_type,
|
|
});
|
|
ctx.notify();
|
|
|
|
// A request might've gotten queued while this last one was in-flight. This point is when
|
|
// we'd begin the next one.
|
|
self.try_execute_request(ctx);
|
|
}
|
|
|
|
// Reset the most-recently-downloaded update.
|
|
#[cfg_attr(not(target_os = "macos"), expect(dead_code))]
|
|
fn clear_downloaded_update(&mut self, update_id: &str, ctx: &mut ModelContext<Self>) {
|
|
if self
|
|
.downloaded_update
|
|
.as_ref()
|
|
.is_some_and(|update| update.update_id == update_id)
|
|
{
|
|
self.downloaded_update = None;
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
/// Set the current autoupdate stage. This must *only* be called from within the [`autoupdate`]
|
|
/// module to correctly maintain the update state machine.
|
|
fn set_autoupdate_stage(&mut self, stage: AutoupdateStage, ctx: &mut ModelContext<Self>) {
|
|
self.stage = stage;
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Record that we did not successfully relaunch to update.
|
|
fn set_unable_to_launch_state(
|
|
&mut self,
|
|
get_next_stage: fn(VersionInfo, String) -> AutoupdateStage,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
match &self.stage {
|
|
// If we were relaunching into an already-applied update, we should stay in that state
|
|
// for the *next* relaunch to open it.
|
|
AutoupdateStage::UpdatedPendingRestart { .. } => (),
|
|
// If we were relaunching in the middle of an update, and that failed, use the callback to
|
|
// decide what to do next.
|
|
AutoupdateStage::Updating {
|
|
new_version,
|
|
update_id,
|
|
} => {
|
|
let next_stage = get_next_stage(new_version.clone(), update_id.clone());
|
|
self.set_autoupdate_stage(next_stage, ctx);
|
|
}
|
|
_ => {
|
|
log::warn!(
|
|
"Tried to set the autoupdate state after a relaunch was cancelled, but the previous state was not Updating."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mark both the autoupdate stage and the relaunch status as failed.
|
|
fn relaunch_failed(&mut self, ctx: &mut ModelContext<Self>) {
|
|
self.set_unable_to_launch_state(
|
|
|new_version, _update_id| AutoupdateStage::UnableToLaunchNewVersion { new_version },
|
|
ctx,
|
|
);
|
|
RelaunchModel::handle(ctx).update(ctx, |me, ctx| {
|
|
me.relaunch_status = RelaunchStatus::Failed;
|
|
ctx.notify();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// The set of events that are emitted from the AutoupdateState model.
|
|
pub enum AutoupdateStateEvent {
|
|
/// Emitted when an update check has finished.
|
|
CheckComplete {
|
|
/// Result of the check of whether there is an update available.
|
|
result: Result<UpdateReady>,
|
|
/// Type of request that this check references.
|
|
request_type: RequestType,
|
|
},
|
|
/// Emitted when an update is available.
|
|
UpdateAvailable,
|
|
}
|
|
|
|
impl Entity for AutoupdateState {
|
|
type Event = AutoupdateStateEvent;
|
|
}
|
|
|
|
impl SingletonEntity for AutoupdateState {}
|
|
|
|
/// Set of results from an update check.
|
|
#[allow(dead_code)]
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum UpdateReady {
|
|
/// An update is available and has been downloaded.
|
|
Yes {
|
|
/// The version that has been downloaded.
|
|
new_version: VersionInfo,
|
|
/// Nonce used to identify this update check.
|
|
update_id: String,
|
|
},
|
|
/// An update is available but not yet downloaded.
|
|
CanDownload {
|
|
/// The available version to update to.
|
|
new_version: VersionInfo,
|
|
/// Nonce used to identify this update check.
|
|
update_id: String,
|
|
},
|
|
/// There is no update available.
|
|
No,
|
|
}
|
|
|
|
/// Set of results from downloading an update.
|
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
|
pub enum DownloadReady {
|
|
/// The update was downloaded successfully.
|
|
Yes,
|
|
/// There were insufficient permissions to download the update.
|
|
#[cfg_attr(windows, allow(dead_code))]
|
|
NeedsAuthorization,
|
|
/// A newer version could not be downloaded.
|
|
No,
|
|
}
|
|
|
|
/// Whether or not we're ready to relaunch the app after the user requests that
|
|
/// we apply an update.
|
|
///
|
|
/// This exists for Linux, when the app is installed via a package manager.
|
|
/// Instead of immediately applying the update, we open a new tab and populate
|
|
/// the input field with the command the user needs to run to install the
|
|
/// update via their package manager. After the update completes, we send
|
|
/// ourselves a signal (via a DCS hook) that the update has completed and we're
|
|
/// ready to relaunch.
|
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
|
pub enum ReadyForRelaunch {
|
|
Yes,
|
|
#[cfg_attr(any(target_os = "macos", windows), allow(dead_code))]
|
|
No,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum RequestType {
|
|
/// For when the user triggers the check manually in the settings page.
|
|
ManualCheck,
|
|
/// We automatically poll for updates every AUTOUPDATE_POLL. This can also trigger the daily
|
|
/// request to /client_version/daily if it hasn't been done yet today.
|
|
Poll,
|
|
/// Only go through with the check to /client_version/daily if it hasn't been done yet today.
|
|
/// Otherwise, abort the check. This is useful if we want to eagerly send the check b/c we
|
|
/// don't want to wait for the next polling interval. Relying on the polling interval alone to
|
|
/// send the daily checks could lead to under-counting.
|
|
DailyCheck,
|
|
}
|
|
|
|
// We only want to announce autoupdates when there's manual check. Otherwise, the autoupdate check
|
|
// may clash with other announcements, such as log in form or referral form.
|
|
// Users will still get the autoupdate on the next relaunch anyways, so for now it's ok.
|
|
pub fn accessibility_content(
|
|
update_available: &Result<UpdateReady>,
|
|
request_type: RequestType,
|
|
) -> Option<AccessibilityContent> {
|
|
match (request_type, update_available) {
|
|
// Found autoupdate
|
|
(RequestType::ManualCheck, Ok(UpdateReady::Yes { .. })) => Some(AccessibilityContent::new(
|
|
"Update available.",
|
|
"Use the command palette to install and relaunch Warp",
|
|
WarpA11yRole::HelpRole,
|
|
)),
|
|
// Any non-successful autoupdate check
|
|
(RequestType::ManualCheck, _) => Some(AccessibilityContent::new_without_help(
|
|
"No updates available",
|
|
WarpA11yRole::HelpRole,
|
|
)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn get_update_state(app: &AppContext) -> AutoupdateStage {
|
|
AutoupdateState::as_ref(app).stage.clone()
|
|
}
|
|
|
|
fn get_curr_parsed_version() -> Option<ParsedVersion> {
|
|
let curr_version = ChannelState::app_version();
|
|
curr_version.and_then(|v| ParsedVersion::try_from(v).ok())
|
|
}
|
|
|
|
/// Generate a new random update ID.
|
|
fn new_update_id() -> String {
|
|
let mut rng = rand::thread_rng();
|
|
std::iter::repeat(())
|
|
.map(|()| rng.sample(rand::distributions::Alphanumeric))
|
|
.map(char::from)
|
|
.take(7)
|
|
.collect()
|
|
}
|
|
|
|
/// Fetch the current version on the given channel.
|
|
async fn fetch_version(
|
|
channel: &Channel,
|
|
is_daily: bool,
|
|
update_id: &str,
|
|
server_api: Arc<ServerApi>,
|
|
) -> Result<VersionInfo> {
|
|
let versions = fetch_channel_versions(update_id, server_api.clone(), false, is_daily).await?;
|
|
|
|
let channel_version = match channel {
|
|
Channel::Stable => versions.stable,
|
|
Channel::Preview => versions.preview,
|
|
Channel::Dev => versions.dev,
|
|
Channel::Integration | Channel::Local | Channel::Oss => {
|
|
// These channels don't ship release artifacts, so there's no
|
|
// version to fetch. This branch is normally unreachable because
|
|
// `AutoupdateState::register` gates the poll loop on the
|
|
// `Autoupdate` feature flag, but builds (e.g. local wasm bundles)
|
|
// can end up with `Autoupdate` enabled while running on one of
|
|
// these channels. Return an error rather than panicking so the
|
|
// poll loop just logs and bails.
|
|
anyhow::bail!(
|
|
"Local, integration, and open-source channel binaries don't support autoupdate"
|
|
);
|
|
}
|
|
};
|
|
let version_info = channel_version.version_info();
|
|
Ok(version_info)
|
|
}
|
|
|
|
// This method is unimplemented on wasm, so we allow unused variables.
|
|
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
|
|
async fn download_update(
|
|
version_info: VersionInfo,
|
|
update_id: String,
|
|
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
|
last_successful_update_id: Option<String>,
|
|
server_api: Arc<ServerApi>,
|
|
) -> Result<DownloadReady> {
|
|
if ChannelState::app_version().is_none() {
|
|
log::info!("No tag set, not performing autoupdate.");
|
|
return Ok(DownloadReady::No);
|
|
}
|
|
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(target_os = "macos")] {
|
|
mac::download_update_and_cleanup(&version_info, &update_id, last_successful_update_id.as_deref(), server_api.http_client()).await
|
|
} else if #[cfg(target_os = "linux")] {
|
|
linux::download_update_and_cleanup(&version_info, &update_id, server_api.http_client()).await
|
|
} else if #[cfg(windows)] {
|
|
windows::download_update_and_cleanup(&version_info, &update_id, server_api.http_client()).await
|
|
} else {
|
|
Err(anyhow::anyhow!("Not implemented"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Apply a downloaded update. If this returns `Ok(ReadyForRelaunch::Yes)`, then the app should be
|
|
/// relaunched to complete the update. If this returns `Ok(ReadyForRelaunch::No)`, then more user
|
|
/// action is needed to apply the update, and the app shouldn't relaunch automatically.
|
|
///
|
|
/// The timing of how updates are applied is very platform-specific:
|
|
/// * On macOS, updates are applied asynchronously, _immediately_ before relaunching. This always
|
|
/// returns [`ReadyForRelaunch::Yes`].
|
|
/// * On Windows, updates are applied by a separate installer process, which is spawned
|
|
/// [just before the app terminates](spawn_child_if_necessary).
|
|
/// * On Linux, if using a package manager, we ask the user to install the update via their package
|
|
/// manager, and do not relaunch until that's complete. This returns [`ReadyForRelaunch::No`].
|
|
pub fn apply_update(
|
|
_initiating_workspace: &mut Workspace,
|
|
_ctx: &mut ViewContext<Workspace>,
|
|
) -> Result<ReadyForRelaunch> {
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(any(target_os = "macos", windows))] {
|
|
// macOS applies the update during the download step. Windows does it during
|
|
// `spawn_child_if_necessary`. In either case, simply continue relaunching the app.
|
|
Ok(ReadyForRelaunch::Yes)
|
|
} else if #[cfg(target_os = "linux")] {
|
|
let AutoupdateStage::UpdateReady { update_id, .. } = &AutoupdateState::handle(_ctx).as_ref(_ctx).stage else {
|
|
anyhow::bail!("Trying to apply an update without AutoupdateState being UpdateReady!");
|
|
};
|
|
let update_id = update_id.clone();
|
|
linux::apply_update(_initiating_workspace, &update_id, _ctx)
|
|
} else {
|
|
anyhow::bail!("Not implemented")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Relaunch Warp to apply an update.
|
|
///
|
|
/// This will:
|
|
/// 1. Perform any last update steps.
|
|
/// 2. Request a relaunch.
|
|
/// 3. Terminate the app.
|
|
pub fn initiate_relaunch_for_update(app: &mut AppContext) {
|
|
let autoupdate_stage = &AutoupdateState::as_ref(app).stage;
|
|
|
|
match autoupdate_stage {
|
|
AutoupdateStage::UpdatedPendingRestart { .. } => {
|
|
// The update was already fully applied, so all that's left to do is relaunch.
|
|
log::info!("Relaunching to apply update");
|
|
RelaunchModel::handle(app).update(app, RelaunchModel::request_relaunch);
|
|
app.terminate_app(TerminationMode::Cancellable, None);
|
|
}
|
|
AutoupdateStage::UpdateReady {
|
|
new_version,
|
|
update_id,
|
|
} => {
|
|
// There's a pending update, and we haven't finished applying it.
|
|
let new_version = new_version.clone();
|
|
let new_version_string = new_version.version.clone();
|
|
let update_id = update_id.clone();
|
|
|
|
// First, record that we're applying an update.
|
|
AutoupdateState::handle(app).update(app, |autoupdate_state, ctx| {
|
|
autoupdate_state.set_autoupdate_stage(
|
|
AutoupdateStage::Updating {
|
|
new_version,
|
|
update_id,
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// Record that we should relaunch the app after terminating.
|
|
RelaunchModel::handle(app).update(app, RelaunchModel::request_relaunch);
|
|
|
|
// If there are any update steps that are deferred until relaunching, perform them now
|
|
// (this is only true on macOS). We do this *before* requesting termination so that, if
|
|
// the update fails, we can display a workspace banner to the user.
|
|
finalize_update(app, move |result, app| {
|
|
if result.is_err() {
|
|
// finalize_update reports the error itself.
|
|
return;
|
|
}
|
|
|
|
// Report that we're attempting to relaunch for an update, so that we can track failed
|
|
// relaunches (e.g. if the update got corrupted). This is sent synchronously because
|
|
// the app is about to quit.
|
|
let event = TelemetryEvent::AutoupdateRelaunchAttempt {
|
|
new_version: new_version_string,
|
|
};
|
|
send_telemetry_sync_from_app_ctx!(event, app);
|
|
|
|
// Request termination of the app.
|
|
app.terminate_app(TerminationMode::Cancellable, None);
|
|
});
|
|
}
|
|
_ => {
|
|
log::info!("No update ready to install, not relaunching");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Apply a pending update without relaunching. This is called at shutdown in case the user quit
|
|
/// without updating. Returns `true` if there was a pending update to apply.
|
|
///
|
|
/// The callback is invoked once the update is complete (whether or not it was successful). It is
|
|
/// *not* called if there was no update.
|
|
pub fn apply_pending_update<F>(app: &mut AppContext, on_update_complete: F) -> bool
|
|
where
|
|
F: FnOnce(&mut AppContext) + Send + 'static,
|
|
{
|
|
let has_update = AutoupdateState::handle(app).update(app, |autoupdate_state, ctx| {
|
|
if let AutoupdateStage::UpdateReady {
|
|
new_version,
|
|
update_id,
|
|
} = &autoupdate_state.stage
|
|
{
|
|
let new_stage = AutoupdateStage::Updating {
|
|
new_version: new_version.clone(),
|
|
update_id: update_id.clone(),
|
|
};
|
|
autoupdate_state.set_autoupdate_stage(new_stage, ctx);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
|
|
if !has_update {
|
|
return false;
|
|
}
|
|
|
|
finalize_update(app, move |_result, app| on_update_complete(app));
|
|
true
|
|
}
|
|
|
|
/// Perform any autoupdate steps that must be deferred until we're about to relaunch.
|
|
///
|
|
/// Returns `true` if there was an autoupdate to apply; `false` otherwise.
|
|
///
|
|
/// These steps may involve expensive operations and async work, so the caller must provide a
|
|
/// completion callback.
|
|
fn finalize_update<F>(app: &mut AppContext, callback: F)
|
|
where
|
|
F: FnOnce(Result<()>, &mut AppContext) + Send + 'static,
|
|
{
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(target_os = "macos")] {
|
|
mac::apply_update_async(app, |autoupdate_state, result, ctx| {
|
|
match result {
|
|
Ok(maybe_new_version) => {
|
|
if let Some(new_version) = maybe_new_version {
|
|
log::info!("Pending update applied successfully");
|
|
// Record that this update was applied, so we don't reattempt it.
|
|
autoupdate_state.set_autoupdate_stage(AutoupdateStage::UpdatedPendingRestart { new_version }, ctx);
|
|
}
|
|
callback(Ok(()), ctx);
|
|
},
|
|
Err(err) => {
|
|
autoupdate_state.relaunch_failed(ctx);
|
|
|
|
let err = anyhow!(err).context("Error applying installed update");
|
|
crate::report_error!(&err);
|
|
callback(Err(err), ctx);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
callback(Ok(()), app);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn cancel_relaunch(app: &mut AppContext) {
|
|
let previous_status = RelaunchModel::handle(app).update(app, RelaunchModel::cancel_relaunch);
|
|
|
|
if previous_status == RelaunchStatus::Requested {
|
|
AutoupdateState::handle(app).update(app, |autoupdate_state, ctx| {
|
|
autoupdate_state.set_unable_to_launch_state(
|
|
|new_version: VersionInfo, update_id: String| AutoupdateStage::UpdateReady {
|
|
new_version,
|
|
update_id,
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn spawn_child_if_necessary(app: &mut AppContext) {
|
|
let relaunch_handle = RelaunchModel::handle(app);
|
|
let status = relaunch_handle.as_ref(app).relaunch_status;
|
|
|
|
// TODO: We'd ideally call finalize_update here. Otherwise, if an update was downloaded and the
|
|
// user restarts normally, the update won't be applied. However, finalize_update is async and
|
|
// we don't necessarily want to block termination on it.
|
|
|
|
if status == RelaunchStatus::Requested {
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(target_os = "macos")] {
|
|
let relaunch_status = mac::relaunch();
|
|
} else if #[cfg(target_os = "linux")] {
|
|
let relaunch_status = linux::relaunch();
|
|
} else if #[cfg(windows)] {
|
|
let relaunch_status = windows::relaunch();
|
|
} else {
|
|
let relaunch_status: Result<()> = Err(anyhow!("No autoupdate support on this platform!"));
|
|
}
|
|
}
|
|
match relaunch_status {
|
|
Ok(_) => {
|
|
log::info!("Terminating app for relaunch. Bye!");
|
|
}
|
|
Err(e) => {
|
|
log::error!("Error relaunching app after autoupdate: {e:?}");
|
|
AutoupdateState::handle(app).update(app, |autoupdate_state, ctx| {
|
|
autoupdate_state.relaunch_failed(ctx);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn manually_download_new_version(ctx: &mut AppContext) {
|
|
match get_update_state(ctx) {
|
|
AutoupdateStage::UnableToUpdateToNewVersion { new_version }
|
|
| AutoupdateStage::UnableToLaunchNewVersion { new_version } => {
|
|
manually_download_version(&ChannelState::channel(), &new_version, ctx)
|
|
}
|
|
_ => {
|
|
log::warn!(
|
|
"Tried to manually download update in the wrong autoupdate state, skipping."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(unused_variables)]
|
|
fn manually_download_version(channel: &Channel, version: &VersionInfo, ctx: &mut AppContext) {
|
|
#[cfg(target_os = "macos")]
|
|
mac::manually_download_version(channel, version, ctx);
|
|
}
|
|
|
|
pub(crate) fn check_and_report_update_errors(_ctx: &mut AppContext) {
|
|
#[cfg(windows)]
|
|
windows::check_and_report_update_errors(_ctx);
|
|
}
|
|
|
|
pub fn remove_old_executable() -> Result<()> {
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(target_os = "macos")] {
|
|
mac::remove_old_executable()
|
|
} else if #[cfg(any(target_os = "linux", windows))] {
|
|
// Nothing to do on Linux or Windows; we don't leave anything behind to clean up after
|
|
// a relaunch.
|
|
Ok(())
|
|
} else if #[cfg(target_family = "wasm")] {
|
|
// Nothing to do on web. There's no executables stored somewhere.
|
|
Ok(())
|
|
} else {
|
|
Err(anyhow::anyhow!("Not implemented"))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Default, Eq, PartialEq)]
|
|
pub enum RelaunchStatus {
|
|
#[default]
|
|
None,
|
|
Requested,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Default)]
|
|
pub struct RelaunchModel {
|
|
relaunch_status: RelaunchStatus,
|
|
}
|
|
|
|
impl RelaunchModel {
|
|
pub fn new() -> Self {
|
|
Default::default()
|
|
}
|
|
|
|
/// Request relaunching the app to apply an update.
|
|
///
|
|
/// When terminating the app, we check this state to know whether to launch the updated version
|
|
/// or quit the app normally.
|
|
fn request_relaunch(&mut self, ctx: &mut ModelContext<Self>) {
|
|
self.relaunch_status = RelaunchStatus::Requested;
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Cancels a requested relaunch, if there was one. Returns the previous relaunch status.
|
|
fn cancel_relaunch(&mut self, ctx: &mut ModelContext<Self>) -> RelaunchStatus {
|
|
let previous_status = self.relaunch_status;
|
|
self.relaunch_status = RelaunchStatus::None;
|
|
ctx.notify();
|
|
previous_status
|
|
}
|
|
}
|
|
|
|
impl Entity for RelaunchModel {
|
|
type Event = ();
|
|
}
|
|
|
|
impl SingletonEntity for RelaunchModel {}
|
|
|
|
pub fn is_incoming_version_past_current(version: Option<&str>) -> bool {
|
|
let installed_version = get_curr_parsed_version();
|
|
|
|
let Ok(incoming_version): Result<ParsedVersion> = version
|
|
.ok_or(anyhow!("version is None"))
|
|
.and_then(|cutoff| cutoff.try_into())
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
installed_version.is_some_and(|curr_version| incoming_version > curr_version)
|
|
}
|
|
|
|
/// Returns the base URL that contains release assets for the given version
|
|
/// of this app bundle.
|
|
fn release_assets_directory_url(channel: Channel, version: &str) -> String {
|
|
let releases_base_url = ChannelState::releases_base_url();
|
|
match channel {
|
|
Channel::Stable => {
|
|
format!("{releases_base_url}/stable/{version}")
|
|
}
|
|
Channel::Preview => {
|
|
format!("{releases_base_url}/preview/{version}")
|
|
}
|
|
Channel::Dev => format!("{releases_base_url}/dev/{version}"),
|
|
Channel::Local | Channel::Integration | Channel::Oss => {
|
|
unreachable!("local/integration/oss autoupdate not supported");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mod_test.rs"]
|
|
mod tests;
|