refactor auto update
This commit is contained in:
@@ -152,6 +152,18 @@ jobs:
|
|||||||
echo "Artifacts structure:"
|
echo "Artifacts structure:"
|
||||||
find artifacts -type f -exec ls -la {} \;
|
find artifacts -type f -exec ls -la {} \;
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
run: |
|
||||||
|
# One `<sha256> <path>` line per artifact. The in-app updater reads
|
||||||
|
# this to verify a download before installing it, so it must be
|
||||||
|
# published alongside every release. Written outside the directory
|
||||||
|
# being hashed so the checksums file never includes itself.
|
||||||
|
find artifacts -type f ! -name SHA256SUMS -print0 \
|
||||||
|
| sort -z \
|
||||||
|
| xargs -0 sha256sum > SHA256SUMS.raw
|
||||||
|
mv SHA256SUMS.raw artifacts/SHA256SUMS
|
||||||
|
cat artifacts/SHA256SUMS
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
|||||||
Generated
+23
-19
@@ -267,7 +267,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assets"
|
name = "assets"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -529,12 +529,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "auto_update"
|
name = "auto_update"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-updater",
|
"gpui-updater-core",
|
||||||
"instant",
|
"instant",
|
||||||
"log",
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1061,7 +1064,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chat"
|
name = "chat"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
@@ -1083,7 +1086,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chat_ui"
|
name = "chat_ui"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chat",
|
"chat",
|
||||||
@@ -1266,7 +1269,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
@@ -1394,7 +1397,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coop"
|
name = "coop"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assets",
|
"assets",
|
||||||
"auto_update",
|
"auto_update",
|
||||||
@@ -1420,7 +1423,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coop_web"
|
name = "coop_web"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assets",
|
"assets",
|
||||||
"chat",
|
"chat",
|
||||||
@@ -1780,7 +1783,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "device"
|
name = "device"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
@@ -2749,11 +2752,11 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-updater"
|
name = "gpui-updater-core"
|
||||||
version = "0.0.6"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/AprilNEA/gpui-updater?tag=v0.0.6#e8a85768793ac3238b0c1bc1f32d6a1544667ec3"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49f3ec64b674794b6b8b6fb36d40974d3506bd254212c3d5f65fcc9eac6542cb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5066,7 +5069,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "person"
|
name = "person"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
@@ -6491,7 +6494,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "settings"
|
name = "settings"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
@@ -6735,7 +6738,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "state"
|
name = "state"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -7089,7 +7092,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theme"
|
name = "theme"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -7629,7 +7632,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ui"
|
name = "ui"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
@@ -7770,6 +7773,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq-proto",
|
"ureq-proto",
|
||||||
@@ -9026,7 +9030,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "workspace"
|
name = "workspace"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"auto_update",
|
"auto_update",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ webbrowser = "1.0.4"
|
|||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
errno = { version = "0.3.14", default-features = false }
|
errno = { version = "0.3.14", default-features = false }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
|
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
# Use stacker's psm version which may have better WASM support
|
# Use stacker's psm version which may have better WASM support
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ publish.workspace = true
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
ureq.workspace = true
|
||||||
|
|
||||||
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
gpui-updater-core = "0.1"
|
||||||
|
|||||||
+279
-96
@@ -1,20 +1,35 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
use std::sync::Arc;
|
||||||
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
||||||
|
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
||||||
use instant::{Duration, Instant};
|
use instant::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
||||||
|
|
||||||
|
mod source;
|
||||||
|
|
||||||
|
/// Observable auto-update status — re-exported from
|
||||||
|
/// [`gpui_updater_core::UpdateStatus`].
|
||||||
|
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
|
||||||
|
|
||||||
|
/// Coop publishes its releases to its own Gitea instance rather than GitHub, so
|
||||||
|
/// release metadata is read from the Gitea API.
|
||||||
|
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
|
||||||
|
const GITEA_REPO_OWNER: &str = "reya";
|
||||||
|
const GITEA_REPO_NAME: &str = "coop";
|
||||||
|
|
||||||
|
/// Delay before the automatic check that runs on startup.
|
||||||
|
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// How long a failure stays visible before the status reverts to "Up to date".
|
||||||
|
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
||||||
|
|
||||||
fn get_github_repo_owner() -> String {
|
|
||||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_github_repo_name() -> String {
|
|
||||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether updates are managed by an external distribution channel
|
/// Whether updates are managed by an external distribution channel
|
||||||
/// (Flatpak/Snap), in which case the in-app updater must not run.
|
/// (Flatpak/Snap), in which case the in-app updater must not run.
|
||||||
fn uses_managed_updates() -> bool {
|
fn uses_managed_updates() -> bool {
|
||||||
@@ -29,39 +44,59 @@ fn uses_managed_updates() -> bool {
|
|||||||
/// Initialize the auto-update system.
|
/// Initialize the auto-update system.
|
||||||
///
|
///
|
||||||
/// Skips initialization when updates are handled by an external distribution
|
/// Skips initialization when updates are handled by an external distribution
|
||||||
/// channel (Flatpak/Snap). Otherwise creates the global [`AutoUpdater`]
|
/// channel (Flatpak/Snap) or when nothing installable is published for this
|
||||||
/// entity and schedules a check for updates after a 2-minute delay.
|
/// target. Otherwise creates the global [`AutoUpdater`] entity and schedules a
|
||||||
|
/// check for updates after a 2-minute delay.
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
if uses_managed_updates() {
|
if uses_managed_updates() {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skipping auto-update initialization: App is installed via a managed distribution channel (Flatpak/Snap)"
|
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
|
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
|
||||||
|
let Some(filter) = asset_filter_for(os, arch) else {
|
||||||
|
log::info!(
|
||||||
|
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
|
||||||
|
log::error!(
|
||||||
|
"Skipping auto-update initialization: crate version {:?} is not valid semver",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
AutoUpdater::set_global(
|
||||||
|
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
|
|
||||||
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
|
|
||||||
|
|
||||||
/// The global auto-updater entity.
|
/// The global auto-updater entity.
|
||||||
///
|
///
|
||||||
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
|
/// Wraps the blocking update engine from `gpui-updater-core` with Coop's
|
||||||
/// (GitHub repo, Flatpak detection, delayed auto-check).
|
/// release source, verification policy and delayed auto-check.
|
||||||
///
|
///
|
||||||
/// Retrieve the global instance via [`AutoUpdater::global`].
|
/// Retrieve the global instance via [`AutoUpdater::global`].
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
/// The underlying gpui-updater entity that does the heavy lifting.
|
/// The blocking engine, driven on the background executor.
|
||||||
pub updater: Entity<Updater>,
|
engine: Arc<UpdateEngine<GiteaSource>>,
|
||||||
|
status: UpdateStatus,
|
||||||
|
/// The newer release found by the last successful check, if any.
|
||||||
|
available: Option<Release>,
|
||||||
/// Currently running app version.
|
/// Currently running app version.
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
/// Keeps the observer subscription alive.
|
/// The in-flight check or download, if any.
|
||||||
_subscription: Subscription,
|
task: Option<Task<()>>,
|
||||||
/// When the last error was recorded, so we can reset to idle after 5s.
|
/// When the last error was recorded, so we can reset to idle after 5s.
|
||||||
error_time: Option<Instant>,
|
error_time: Option<Instant>,
|
||||||
}
|
}
|
||||||
@@ -69,8 +104,10 @@ pub struct AutoUpdater {
|
|||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
/// Whether auto-update is available for this installation.
|
/// Whether auto-update is available for this installation.
|
||||||
///
|
///
|
||||||
/// Returns `false` on managed distribution channels (Flatpak/Snap), where
|
/// Returns `false` on managed distribution channels (Flatpak/Snap) and on
|
||||||
/// updates are handled by the channel and no global updater is created.
|
/// targets without an installable artifact (see
|
||||||
|
/// [`asset_filter_for`](crate::source::asset_filter_for)), where no
|
||||||
|
/// updater is created at all.
|
||||||
pub fn is_available(cx: &App) -> bool {
|
pub fn is_available(cx: &App) -> bool {
|
||||||
cx.try_global::<GlobalAutoUpdater>().is_some()
|
cx.try_global::<GlobalAutoUpdater>().is_some()
|
||||||
}
|
}
|
||||||
@@ -96,92 +133,60 @@ impl AutoUpdater {
|
|||||||
cx.set_global(GlobalAutoUpdater(state));
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(
|
||||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
window: &mut Window,
|
||||||
|
version: Version,
|
||||||
|
filter: AssetFilter,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
||||||
|
|
||||||
let repo_owner = get_github_repo_owner();
|
// Artifacts come from our own host, but a truncated or substituted
|
||||||
let repo_name = get_github_repo_name();
|
// download would otherwise be installed silently, so require the
|
||||||
|
// published SHA-256 to match. The release workflow publishes a
|
||||||
let source =
|
// `SHA256SUMS` asset for every release.
|
||||||
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
|
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
||||||
"macos" => "macos",
|
let engine = Arc::new(UpdateEngine::new(source, config));
|
||||||
"linux" => "linux",
|
|
||||||
_ => "",
|
|
||||||
});
|
|
||||||
|
|
||||||
let updater: Entity<Updater> =
|
|
||||||
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
|
|
||||||
|
|
||||||
// When an update becomes available, automatically download and install it.
|
|
||||||
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
|
|
||||||
let status = this.updater.read(cx).status().clone();
|
|
||||||
|
|
||||||
if matches!(status, UpdateStatus::Available(_)) {
|
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.download_and_install(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(status, UpdateStatus::Errored(_)) {
|
|
||||||
this.error_time = Some(Instant::now());
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
|
||||||
this.update(cx, |_this, cx| cx.notify()).ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
} else {
|
|
||||||
this.error_time = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
||||||
// end of the current frame so the window is fully set up).
|
// end of the current frame so the window is fully set up).
|
||||||
cx.defer_in(window, |_this, _window, cx| {
|
cx.defer_in(window, |_this, _window, cx| {
|
||||||
let duration = Duration::from_secs(120);
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(duration).await;
|
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| this.check(cx)).ok();
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
updater,
|
engine,
|
||||||
|
status: UpdateStatus::Idle,
|
||||||
|
available: None,
|
||||||
version,
|
version,
|
||||||
_subscription: subscription,
|
task: None,
|
||||||
error_time: None,
|
error_time: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn idle(&self, cx: &App) -> bool {
|
/// Whether nothing is happening, so the UI can hide the status line.
|
||||||
let status = self.updater.read(cx).status();
|
pub fn idle(&self) -> bool {
|
||||||
if status == &UpdateStatus::Idle {
|
match self.status {
|
||||||
return true;
|
UpdateStatus::Idle => true,
|
||||||
|
UpdateStatus::Errored(_) => self.error_is_stale(),
|
||||||
|
_ => false,
|
||||||
}
|
}
|
||||||
if matches!(status, UpdateStatus::Errored(_))
|
|
||||||
&& self
|
|
||||||
.error_time
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self, cx: &App) -> SharedString {
|
/// Whether a verified update is installed and waiting for a restart.
|
||||||
let status = self.updater.read(cx).status();
|
pub fn staged(&self) -> bool {
|
||||||
|
matches!(self.status, UpdateStatus::Staged(_))
|
||||||
|
}
|
||||||
|
|
||||||
match status {
|
/// A short, human-readable description of the current status.
|
||||||
UpdateStatus::Idle => "Up to date".into(),
|
pub fn status(&self) -> SharedString {
|
||||||
|
match &self.status {
|
||||||
|
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
|
||||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||||
UpdateStatus::UpToDate => "Up to date".into(),
|
|
||||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||||
UpdateStatus::Downloading { downloaded, total } => {
|
UpdateStatus::Downloading { downloaded, total } => {
|
||||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||||
@@ -195,16 +200,194 @@ impl AutoUpdater {
|
|||||||
UpdateStatus::Staged(version) => {
|
UpdateStatus::Staged(version) => {
|
||||||
format!("Version {version} ready — restart to apply").into()
|
format!("Version {version} ready — restart to apply").into()
|
||||||
}
|
}
|
||||||
UpdateStatus::Errored(msg) => {
|
UpdateStatus::Errored(message) => {
|
||||||
if self
|
if self.error_is_stale() {
|
||||||
.error_time
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
|
||||||
{
|
|
||||||
"Up to date".into()
|
"Up to date".into()
|
||||||
} else {
|
} else {
|
||||||
format!("Update failed: {msg}").into()
|
format!("Update failed: {message}").into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check the release host for a newer version, then download and install it
|
||||||
|
/// without further prompting. No-op while a check or install is in flight.
|
||||||
|
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.status.is_busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.set_status(UpdateStatus::Checking, cx);
|
||||||
|
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
|
||||||
|
self.task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_executor()
|
||||||
|
.spawn(async move { engine.check() })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
match result {
|
||||||
|
Ok(Some(release)) => {
|
||||||
|
log::info!("Update {} is available", release.version);
|
||||||
|
let version = release.version.clone();
|
||||||
|
this.available = Some(release);
|
||||||
|
this.set_status(UpdateStatus::Available(version), cx);
|
||||||
|
this.download_and_install(cx);
|
||||||
|
}
|
||||||
|
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update check failed: {error}");
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the available update, verify it, and swap it into place.
|
||||||
|
///
|
||||||
|
/// No-op unless a newer release is available and no other operation is in
|
||||||
|
/// flight. On success the status becomes [`UpdateStatus::Staged`] and the
|
||||||
|
/// app's restart path is set to the new binary; call [`restart`](Self::restart).
|
||||||
|
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.status.is_busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(release) = self.available.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
self.set_status(
|
||||||
|
UpdateStatus::Downloading {
|
||||||
|
downloaded: 0,
|
||||||
|
total: None,
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
self.task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
// The blocking download runs on a background thread and reports
|
||||||
|
// progress into shared atomics; this foreground task polls them so
|
||||||
|
// the UI updates live without a notification per 64 KiB chunk.
|
||||||
|
let downloaded = Arc::new(AtomicU64::new(0));
|
||||||
|
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let download_task = {
|
||||||
|
let (engine, release) = (engine.clone(), release.clone());
|
||||||
|
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
||||||
|
cx.background_executor().spawn(async move {
|
||||||
|
let result = engine.download(&release, |got, expected| {
|
||||||
|
downloaded.store(got, Ordering::Relaxed);
|
||||||
|
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
result
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let got = downloaded.load(Ordering::Relaxed);
|
||||||
|
let total = total.load(Ordering::Relaxed);
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(
|
||||||
|
UpdateStatus::Downloading {
|
||||||
|
downloaded: got,
|
||||||
|
total: (total != 0).then_some(total),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
if done.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_millis(120))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifact = match download_task.await {
|
||||||
|
Ok(artifact) => artifact,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update download failed: {error}");
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let installed = {
|
||||||
|
let engine = engine.clone();
|
||||||
|
cx.background_executor()
|
||||||
|
.spawn(async move { engine.install(&artifact) })
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
match installed {
|
||||||
|
Ok(installed) => {
|
||||||
|
if let Some(path) = installed.restart_path {
|
||||||
|
cx.set_restart_path(path);
|
||||||
|
}
|
||||||
|
let version = release.version.clone();
|
||||||
|
this.set_status(UpdateStatus::Staged(version), cx);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update install failed: {error}");
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relaunch into the staged update.
|
||||||
|
///
|
||||||
|
/// On macOS and Linux the new files are already in place, so this is what
|
||||||
|
/// starts running them. No-op unless an update is [`staged`](Self::staged).
|
||||||
|
pub fn restart(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.staged() {
|
||||||
|
log::warn!("Ignoring restart request: no update is staged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cx.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
||||||
|
let errored = matches!(status, UpdateStatus::Errored(_));
|
||||||
|
self.status = status;
|
||||||
|
|
||||||
|
if errored {
|
||||||
|
self.error_time = Some(Instant::now());
|
||||||
|
// Re-render once the error has been on screen long enough to make
|
||||||
|
// way for "Up to date".
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||||
|
this.update(cx, |_this, cx| cx.notify()).ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
} else {
|
||||||
|
self.error_time = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_is_stale(&self) -> bool {
|
||||||
|
self.error_time
|
||||||
|
.is_some_and(|time| time.elapsed() >= ERROR_DISPLAY_DURATION)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||||
|
const RELEASE_PAGE_SIZE: usize = 20;
|
||||||
|
|
||||||
|
/// Which published artifact belongs to a target platform.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AssetFilter {
|
||||||
|
extension: &'static str,
|
||||||
|
arch: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetFilter {
|
||||||
|
/// Whether `name` is the installable artifact for this target.
|
||||||
|
fn matches(&self, name: &str) -> bool {
|
||||||
|
let name = name.to_ascii_lowercase();
|
||||||
|
name.ends_with(self.extension) && name.contains(self.arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
||||||
|
let extension = match os {
|
||||||
|
"macos" => ".dmg",
|
||||||
|
"linux" => ".tar.gz",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let arch = match (os, arch) {
|
||||||
|
// cargo-packager names the disk images `aarch64`/`x64`.
|
||||||
|
("macos", "aarch64") => "aarch64",
|
||||||
|
("macos", "x86_64") => "x64",
|
||||||
|
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||||
|
("linux", "aarch64") => "aarch64",
|
||||||
|
("linux", "x86_64") => "x86_64",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(AssetFilter { extension, arch })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads releases from a Gitea repository's Releases.
|
||||||
|
pub struct GiteaSource {
|
||||||
|
api_base: String,
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
filter: AssetFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GiteaSource {
|
||||||
|
/// Build a source for `owner/repo` on the Gitea instance at `api_base`
|
||||||
|
/// (e.g. `https://git.reya.info/api/v1`).
|
||||||
|
pub fn new(
|
||||||
|
api_base: impl Into<String>,
|
||||||
|
owner: impl Into<String>,
|
||||||
|
repo: impl Into<String>,
|
||||||
|
filter: AssetFilter,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||||
|
owner: owner.into(),
|
||||||
|
repo: repo.into(),
|
||||||
|
filter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn releases_url(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
|
||||||
|
self.api_base, self.owner, self.repo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateSource for GiteaSource {
|
||||||
|
fn fetch_latest(&self) -> Result<Release> {
|
||||||
|
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
|
||||||
|
let release = newest_published(&releases)
|
||||||
|
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
||||||
|
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| self.filter.matches(&asset.name))
|
||||||
|
.ok_or(Error::NoMatchingAsset {
|
||||||
|
target_os: std::env::consts::OS,
|
||||||
|
target_arch: std::env::consts::ARCH,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Resolve the published checksum so the engine can reject a truncated or substituted download.
|
||||||
|
let sha256 = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
||||||
|
.map(|sums| http::get_string(&sums.browser_download_url))
|
||||||
|
.transpose()?
|
||||||
|
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||||
|
|
||||||
|
Ok(Release {
|
||||||
|
version: parse_tag(&release.tag_name)?,
|
||||||
|
notes: release
|
||||||
|
.body
|
||||||
|
.clone()
|
||||||
|
.filter(|body| !body.trim().is_empty())
|
||||||
|
.or_else(|| release.name.clone()),
|
||||||
|
asset: Asset {
|
||||||
|
name: asset.name.clone(),
|
||||||
|
url: asset.browser_download_url.clone(),
|
||||||
|
size: asset.size,
|
||||||
|
},
|
||||||
|
signature: None,
|
||||||
|
signature_url: None,
|
||||||
|
sha256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> {
|
||||||
|
releases
|
||||||
|
.iter()
|
||||||
|
.filter(|release| !release.draft && !release.prerelease)
|
||||||
|
.filter_map(|release| {
|
||||||
|
parse_tag(&release.tag_name)
|
||||||
|
.ok()
|
||||||
|
.map(|version| (version, release))
|
||||||
|
})
|
||||||
|
.max_by(|(left, _), (right, _)| left.cmp(right))
|
||||||
|
.map(|(_, release)| release)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file.
|
||||||
|
fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
|
||||||
|
sums.lines().find_map(|line| {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let (hash, path) = (parts.next()?, parts.next()?);
|
||||||
|
let path = path.strip_prefix('*').unwrap_or(path);
|
||||||
|
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||||
|
(base == asset_name).then(|| hash.to_ascii_lowercase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaRelease {
|
||||||
|
tag_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
prerelease: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
assets: Vec<GiteaAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release asset as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaAsset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking HTTP helpers for release metadata.
|
||||||
|
mod http {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui_updater_core::{Error, Result};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use ureq::Agent;
|
||||||
|
use ureq::tls::{RootCerts, TlsConfig};
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
fn agent() -> Agent {
|
||||||
|
Agent::config_builder()
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.tls_config(
|
||||||
|
TlsConfig::builder()
|
||||||
|
.root_certs(RootCerts::PlatformVerifier)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.timeout_resolve(Some(CONNECT_TIMEOUT))
|
||||||
|
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||||
|
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut response = agent().get(url).call().map_err(|error| match error {
|
||||||
|
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
|
||||||
|
other => Error::Http(other.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_vec()
|
||||||
|
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
|
||||||
|
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_string(url: &str) -> Result<String> {
|
||||||
|
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use gpui_updater_core::Version;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const PUBLISHED: &[&str] = &[
|
||||||
|
"Coop_1.0.1_aarch64.dmg",
|
||||||
|
"Coop_1.0.1_x64.dmg",
|
||||||
|
"coop-linux-aarch64.tar.gz",
|
||||||
|
"coop-linux-x86_64.tar.gz",
|
||||||
|
"coop_1.0.1_aarch64.snap",
|
||||||
|
"coop_1.0.1_arm64-setup.exe",
|
||||||
|
"coop_1.0.1_x64-setup.exe",
|
||||||
|
"coop_1.0.1_x86_64.snap",
|
||||||
|
"su.reya.coop_aarch64.flatpak",
|
||||||
|
"su.reya.coop_x86_64.flatpak",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn selected(os: &str, arch: &str) -> Option<&'static str> {
|
||||||
|
let filter = asset_filter_for(os, arch)?;
|
||||||
|
PUBLISHED.iter().copied().find(|name| filter.matches(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_the_artifact_matching_os_and_architecture() {
|
||||||
|
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
||||||
|
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
||||||
|
assert_eq!(
|
||||||
|
selected("linux", "aarch64"),
|
||||||
|
Some("coop-linux-aarch64.tar.gz")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selected("linux", "x86_64"),
|
||||||
|
Some("coop-linux-x86_64.tar.gz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||||
|
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() {
|
||||||
|
let macos = asset_filter_for("macos", "aarch64").unwrap();
|
||||||
|
assert!(!macos.matches("coop_1.0.1_aarch64.snap"));
|
||||||
|
assert!(!macos.matches("su.reya.coop_aarch64.flatpak"));
|
||||||
|
assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig"));
|
||||||
|
|
||||||
|
let linux = asset_filter_for("linux", "x86_64").unwrap();
|
||||||
|
assert!(!linux.matches("coop_1.0.1_x64-setup.exe"));
|
||||||
|
assert!(!linux.matches("coop_1.0.1_x86_64.snap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_checksums_by_basename_ignoring_directory_prefix() {
|
||||||
|
let sums = "\
|
||||||
|
abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
||||||
|
123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz
|
||||||
|
789abc SHA256SUMS
|
||||||
|
";
|
||||||
|
assert_eq!(
|
||||||
|
sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(),
|
||||||
|
Some("abcdef")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(),
|
||||||
|
Some("123456")
|
||||||
|
);
|
||||||
|
assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newest_published_skips_drafts_prereleases_and_bad_tags() {
|
||||||
|
let releases: Vec<GiteaRelease> = serde_json::from_str(
|
||||||
|
r#"[
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.2",
|
||||||
|
"draft": true,
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v2.0.0-rc.1",
|
||||||
|
"prerelease": true,
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "nightly",
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "coop-linux-x86_64.tar.gz",
|
||||||
|
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz",
|
||||||
|
"size": 26160329
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.1",
|
||||||
|
"name": "v1.0.1",
|
||||||
|
"body": "Fixed app panic on flatpak installations",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "coop-linux-x86_64.tar.gz",
|
||||||
|
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz",
|
||||||
|
"size": 26160329
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let newest = newest_published(&releases).unwrap();
|
||||||
|
assert_eq!(newest.tag_name, "v1.0.1");
|
||||||
|
assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1");
|
||||||
|
assert_eq!(newest.assets.len(), 1);
|
||||||
|
assert_eq!(newest.assets[0].size, 26160329);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires network access to the release host"]
|
||||||
|
fn live_release_source_resolves_the_running_platform() {
|
||||||
|
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
||||||
|
.expect("this platform should be supported");
|
||||||
|
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
|
||||||
|
|
||||||
|
let release = source
|
||||||
|
.fetch_latest()
|
||||||
|
.expect("release lookup should succeed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
release.version >= Version::new(1, 0, 0),
|
||||||
|
"unexpected version {}",
|
||||||
|
release.version
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source.filter.matches(&release.asset.name),
|
||||||
|
"unexpected artifact {}",
|
||||||
|
release.asset.name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
release.asset.url.starts_with("https://"),
|
||||||
|
"{} ",
|
||||||
|
release.asset.url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-12
@@ -375,14 +375,8 @@ impl Workspace {
|
|||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
}
|
}
|
||||||
Command::Update => {
|
Command::Update => {
|
||||||
// No-op on managed distribution channels (Flatpak/Snap) where
|
|
||||||
// the in-app updater is never initialized.
|
|
||||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
auto_updater.update(cx, |this, cx| {
|
auto_updater.update(cx, |this, cx| this.check(cx));
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,6 +605,7 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let auto_updater = AutoUpdater::try_global(cx);
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -623,20 +618,35 @@ impl Workspace {
|
|||||||
let profile = persons.read(cx).get(&public_key, cx);
|
let profile = persons.read(cx).get(&public_key, cx);
|
||||||
let announcement = profile.announcement();
|
let announcement = profile.announcement();
|
||||||
|
|
||||||
// Update status is only shown when auto-update is available. On
|
let updater_status = auto_updater.as_ref().and_then(|updater| {
|
||||||
// managed distribution channels (Flatpak/Snap) no updater exists, so
|
|
||||||
// nothing is rendered.
|
|
||||||
let updater_status = AutoUpdater::try_global(cx).and_then(|updater| {
|
|
||||||
let updater = updater.read(cx);
|
let updater = updater.read(cx);
|
||||||
(!updater.idle(cx)).then(|| updater.status(cx))
|
(!updater.idle()).then(|| updater.status())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let staged_update = auto_updater
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|updater| updater.read(cx).staged());
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when_some(updater_status, |this, status| {
|
.when_some(updater_status, |this, status| {
|
||||||
this.child(div().text_xs().italic().child(status))
|
this.child(div().text_xs().italic().child(status))
|
||||||
})
|
})
|
||||||
|
.when(staged_update, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("restart-to-update")
|
||||||
|
.label("Restart to Update")
|
||||||
|
.tooltip("Quit and relaunch into the installed update")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click(cx.listener(|_this, _event, _window, cx| {
|
||||||
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
|
auto_updater.update(cx, |this, cx| this.restart(cx));
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(nip4e_enabled, |this| {
|
.when(nip4e_enabled, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("key")
|
Button::new("key")
|
||||||
|
|||||||
Reference in New Issue
Block a user