mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(gui,core): opt-in check for new releases
Preferences gets a switch that asks GitHub once a day for the latest release and shows a banner over the sidebar when it is newer. Off by default. Packages whose package manager brings updates install the marker share/proton-drive-linux/managed-updates, which hides the switch. Refs #42
This commit is contained in:
1 parent
29b87ece72
commit
e66308498b
25 files changed
+2270
-1569
No files matched your search
@@ -190,6 +190,24 @@ pub struct AppConfig {
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub local_first: Option<bool>,
|
||||
/// Ask GitHub once a day whether a newer release is out, and say so in the
|
||||
/// desktop app. Off by default: GitHub then sees the address and version
|
||||
/// asking. Never used where a package manager installs updates (see
|
||||
/// [`crate::update::updates_managed`]). Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub update_check: bool,
|
||||
/// When the update check last asked GitHub, in Unix seconds, so a restart
|
||||
/// does not ask again within [`crate::update::CHECK_INTERVAL`]. Defaulted
|
||||
/// for configs predating the field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub update_last_checked: Option<i64>,
|
||||
/// The latest release that check found, such as `3.6.0`, so the app can
|
||||
/// say so again after a restart without asking. Older than or equal to the
|
||||
/// running version once the update is installed. Defaulted for configs
|
||||
/// predating the field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub update_latest: Option<String>,
|
||||
}
|
||||
|
||||
/// The My Files page's view choices, remembered between runs.
|
||||
@@ -753,6 +771,9 @@ mod tests {
|
||||
language: Some("de".to_string()),
|
||||
online_map: true,
|
||||
local_first: Some(false),
|
||||
update_check: true,
|
||||
update_last_checked: Some(1_760_000_000),
|
||||
update_latest: Some("3.6.0".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
@@ -764,6 +785,9 @@ mod tests {
|
||||
assert_eq!(decoded.proton_theme, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert!(decoded.online_map);
|
||||
assert!(decoded.update_check);
|
||||
assert_eq!(decoded.update_last_checked, Some(1_760_000_000));
|
||||
assert_eq!(decoded.update_latest.as_deref(), Some("3.6.0"));
|
||||
assert_eq!(decoded.language.as_deref(), Some("de"));
|
||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||
assert_eq!(decoded.download_limit, None);
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod syncignore;
|
||||
pub mod syncroot;
|
||||
pub mod takeout;
|
||||
pub mod tray;
|
||||
pub mod update;
|
||||
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The opt-in check for a newer release ([`crate::config::AppConfig::update_check`]).
|
||||
//!
|
||||
//! Only the decisions live here: what GitHub's answer says, which version is
|
||||
//! newer, when to ask again, and whether a package manager already owns
|
||||
//! updates. The desktop app does the request itself.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// GitHub's latest published release. Drafts and pre-releases never show up
|
||||
/// here, so a tagged release candidate does not reach anyone.
|
||||
pub const RELEASES_LATEST_API: &str =
|
||||
"https://api.github.com/repos/narrrl/proton-drive-linux/releases/latest";
|
||||
|
||||
/// How often the app asks, across restarts.
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Installed next to the binaries' `share/` by packages whose package manager
|
||||
/// brings new releases (COPR, the AUR, Nix). The release `.deb`, `.rpm` and
|
||||
/// `.tar.gz` do not ship it, since nothing updates them.
|
||||
pub const MANAGED_MARKER: &str = "share/proton-drive-linux/managed-updates";
|
||||
|
||||
/// The version of the release in a `/releases/latest` answer, without the
|
||||
/// tag's `v`. `None` for an answer that does not parse or a tag that is not
|
||||
/// `X.Y.Z`, so a changed API can never claim an update.
|
||||
pub fn latest_version(body: &[u8]) -> Option<String> {
|
||||
let json: serde_json::Value = serde_json::from_slice(body).ok()?;
|
||||
let tag = json.get("tag_name")?.as_str()?;
|
||||
let version = tag.strip_prefix('v').unwrap_or(tag);
|
||||
parse(version).map(|_| version.to_owned())
|
||||
}
|
||||
|
||||
/// Whether `version` is a later release than `current`. Anything that is not
|
||||
/// `X.Y.Z` on either side is not.
|
||||
pub fn is_newer(version: &str, current: &str) -> bool {
|
||||
matches!((parse(version), parse(current)), (Some(v), Some(c)) if v > c)
|
||||
}
|
||||
|
||||
/// The release page of `version` on GitHub.
|
||||
pub fn release_url(version: &str) -> String {
|
||||
format!("https://github.com/narrrl/proton-drive-linux/releases/tag/v{version}")
|
||||
}
|
||||
|
||||
/// `X.Y.Z` as numbers, so `3.10.0` sorts after `3.9.0`.
|
||||
fn parse(version: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = version.split('.').map(|p| p.parse::<u64>().ok());
|
||||
let triple = (parts.next()??, parts.next()??, parts.next()??);
|
||||
parts.next().is_none().then_some(triple)
|
||||
}
|
||||
|
||||
/// How long until the next check is due, given the last one in Unix seconds.
|
||||
/// Zero when it is due now. A clock set back past the last check counts as
|
||||
/// due, so a wrong clock cannot stop checks.
|
||||
pub fn next_check_in(last_checked: Option<i64>, now: i64) -> Duration {
|
||||
let interval = CHECK_INTERVAL.as_secs() as i64;
|
||||
match last_checked {
|
||||
Some(last) if (0..interval).contains(&(now - last)) => {
|
||||
Duration::from_secs((interval - (now - last)) as u64)
|
||||
}
|
||||
_ => Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the binary at `exe` was installed by a package manager that updates
|
||||
/// it, which [`MANAGED_MARKER`] next to its `bin/` says.
|
||||
pub fn updates_managed(exe: &Path) -> bool {
|
||||
exe.parent()
|
||||
.and_then(Path::parent)
|
||||
.is_some_and(|prefix| prefix.join(MANAGED_MARKER).exists())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn latest(tag: &str) -> Vec<u8> {
|
||||
format!(
|
||||
r#"{{"tag_name":"{tag}","html_url":"https://github.com/narrrl/proton-drive-linux/releases/tag/{tag}","draft":false}}"#
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_latest_release_is_read_without_its_v() {
|
||||
assert_eq!(latest_version(&latest("v3.6.0")).as_deref(), Some("3.6.0"));
|
||||
assert_eq!(latest_version(&latest("3.6.0")).as_deref(), Some("3.6.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_that_does_not_parse_has_no_version() {
|
||||
assert_eq!(latest_version(b"not json"), None);
|
||||
assert_eq!(latest_version(b"{}"), None);
|
||||
assert_eq!(latest_version(br#"{"tag_name":7}"#), None);
|
||||
assert_eq!(latest_version(&latest("nightly")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0.0-rc1")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0.0.1")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_later_release_is_newer() {
|
||||
assert!(is_newer("3.6.0", "3.5.0"));
|
||||
assert!(!is_newer("3.5.0", "3.5.0"));
|
||||
assert!(!is_newer("3.4.1", "3.5.0"));
|
||||
assert!(!is_newer("2.99.99", "3.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_compare_as_numbers() {
|
||||
assert!(is_newer("3.10.0", "3.9.0"));
|
||||
assert!(is_newer("3.5.10", "3.5.9"));
|
||||
assert!(is_newer("4.0.0", "3.99.99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_that_is_not_x_y_z_is_never_newer() {
|
||||
assert!(!is_newer("4.0.0", "3.5.0-dev"));
|
||||
assert!(!is_newer("garbage", "3.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_release_page_uses_the_tag() {
|
||||
assert_eq!(
|
||||
release_url("3.6.0"),
|
||||
"https://github.com/narrrl/proton-drive-linux/releases/tag/v3.6.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_is_due_once_a_day() {
|
||||
let day = CHECK_INTERVAL.as_secs() as i64;
|
||||
assert_eq!(next_check_in(None, 1_000), Duration::ZERO);
|
||||
assert_eq!(next_check_in(Some(1_000), 1_000), CHECK_INTERVAL);
|
||||
assert_eq!(
|
||||
next_check_in(Some(1_000), 1_000 + day - 1),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
assert_eq!(next_check_in(Some(1_000), 1_000 + day), Duration::ZERO);
|
||||
assert_eq!(
|
||||
next_check_in(Some(1_000), 999),
|
||||
Duration::ZERO,
|
||||
"a clock set back must not stop checks"
|
||||
);
|
||||
}
|
||||
|
||||
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
|
||||
/// [`crate::syncignore`]'s tests.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pdfs-update-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marker_next_to_bin_means_a_package_manager_updates() {
|
||||
let prefix = TempDir::new();
|
||||
let exe = prefix.0.join("bin/pdfs-app");
|
||||
assert!(!updates_managed(&exe));
|
||||
let marker = prefix.0.join(MANAGED_MARKER);
|
||||
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
|
||||
std::fs::write(&marker, "").unwrap();
|
||||
assert!(updates_managed(&exe));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub(crate) mod pages;
|
||||
pub(crate) mod reload;
|
||||
#[path = "../theme.rs"]
|
||||
pub(crate) mod theme;
|
||||
pub(crate) mod update;
|
||||
pub(crate) mod widgets;
|
||||
|
||||
use compat::*;
|
||||
@@ -33,6 +34,7 @@ use pages::trash::*;
|
||||
use pages::verify::*;
|
||||
use reload::{LoadTicket, Loader, replace_items};
|
||||
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
||||
use update::*;
|
||||
use widgets::compat::*;
|
||||
use widgets::crumb::*;
|
||||
use widgets::details::*;
|
||||
@@ -155,6 +157,7 @@ struct Ui {
|
||||
pub(crate) locations: LocationsState,
|
||||
pub(crate) activity: ActivityState,
|
||||
pub(crate) takeout: TakeoutState,
|
||||
pub(crate) update: UpdateState,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
@@ -365,7 +368,8 @@ fn build_window(app: &adw::Application) {
|
||||
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
||||
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
||||
// login lands on Files).
|
||||
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer);
|
||||
let update = UpdateState::new();
|
||||
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer, &update.banner);
|
||||
|
||||
// Every page brings its own header bar (see `page_frame`), so the content
|
||||
// side is the bare stack.
|
||||
@@ -432,6 +436,7 @@ fn build_window(app: &adw::Application) {
|
||||
theme_row: main_widgets.theme_row.clone(),
|
||||
online_map_row: main_widgets.online_map_row.clone(),
|
||||
tray_row: main_widgets.tray_row.clone(),
|
||||
update_row: main_widgets.update_row.clone(),
|
||||
language_row: main_widgets.language_row.clone(),
|
||||
settings_suppress: Cell::new(false),
|
||||
budget_source: RefCell::new(None),
|
||||
@@ -654,6 +659,7 @@ fn build_window(app: &adw::Application) {
|
||||
running: Cell::new(false),
|
||||
dry_run: Cell::new(false),
|
||||
},
|
||||
update,
|
||||
});
|
||||
wire_login(&ui);
|
||||
if let Some(e) = keyring_error {
|
||||
@@ -700,6 +706,7 @@ fn build_window(app: &adw::Application) {
|
||||
wire_activity(&ui, &activity_widgets);
|
||||
wire_takeout(&ui, &takeout_widgets);
|
||||
wire_retry(&ui);
|
||||
wire_update(&ui);
|
||||
|
||||
// Lazily load the Files / Photos pages the first time they're shown, so the
|
||||
// network round-trip only happens on demand rather than on every refresh.
|
||||
@@ -871,10 +878,13 @@ const DESTINATIONS: [(&str, &str, &str); 7] = [
|
||||
/// Index of the first row in the sidebar's sync section.
|
||||
const SIDEBAR_SYNC_SECTION: i32 = 6;
|
||||
|
||||
/// The navigation sidebar: the destinations over a footer carrying the sync
|
||||
/// status, the account quota and the account menu. Returns the page (for the
|
||||
/// The navigation sidebar: the destinations under the update banner and over a
|
||||
/// footer carrying the sync status, the account quota and the account menu. Returns the page (for the
|
||||
/// split view) and the list (to drive + reflect the current page).
|
||||
fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
fn build_sidebar(
|
||||
footer: >k4::Box,
|
||||
update_banner: &adw::Banner,
|
||||
) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
let list = gtk4::ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
||||
list.add_css_class("navigation-sidebar");
|
||||
@@ -927,6 +937,7 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
.build();
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.add_top_bar(update_banner);
|
||||
toolbar.set_content(Some(&scroll));
|
||||
toolbar.add_bottom_bar(footer);
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ pub(crate) struct StatusState {
|
||||
pub(crate) online_map_row: adw::SwitchRow,
|
||||
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) tray_row: adw::SwitchRow,
|
||||
/// "Check for updates" toggle, not shown where a package manager brings
|
||||
/// updates. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) update_row: adw::SwitchRow,
|
||||
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
|
||||
/// [`Self::settings_suppress`].
|
||||
pub(crate) language_row: adw::ComboRow,
|
||||
@@ -164,6 +167,7 @@ pub(crate) struct MainWidgets {
|
||||
pub(crate) theme_row: adw::SwitchRow,
|
||||
pub(crate) online_map_row: adw::SwitchRow,
|
||||
pub(crate) tray_row: adw::SwitchRow,
|
||||
pub(crate) update_row: adw::SwitchRow,
|
||||
pub(crate) language_row: adw::ComboRow,
|
||||
}
|
||||
|
||||
@@ -250,6 +254,17 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
let language_row = build_language_row();
|
||||
appearance_group.add(&language_row);
|
||||
|
||||
let updates_group = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Updates"))
|
||||
.build();
|
||||
let update_row = adw::SwitchRow::builder()
|
||||
.title(gettext("Check for updates"))
|
||||
.subtitle(gettext(
|
||||
"Ask GitHub once a day whether a new version is out. GitHub learns your IP address and which version you use.",
|
||||
))
|
||||
.build();
|
||||
updates_group.add(&update_row);
|
||||
|
||||
let general = adw::PreferencesPage::builder()
|
||||
.title(gettext("General"))
|
||||
.icon_name("preferences-system-symbolic")
|
||||
@@ -258,6 +273,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
general.add(&location_group);
|
||||
general.add(&network_group);
|
||||
general.add(&appearance_group);
|
||||
// A package manager brings new versions, so there is nothing to tell.
|
||||
if !updates_managed_here() {
|
||||
general.add(&updates_group);
|
||||
}
|
||||
|
||||
// ---- Preferences: Storage
|
||||
let storage_group = adw::PreferencesGroup::builder()
|
||||
@@ -442,6 +461,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
theme_row,
|
||||
online_map_row,
|
||||
tray_row,
|
||||
update_row,
|
||||
language_row,
|
||||
}
|
||||
}
|
||||
@@ -541,6 +561,7 @@ pub(crate) fn wire_settings(
|
||||
.set_active(config.proton_theme.unwrap_or(false));
|
||||
ui.status.online_map_row.set_active(config.online_map);
|
||||
ui.status.tray_row.set_active(!config.tray_hidden);
|
||||
ui.status.update_row.set_active(config.update_check);
|
||||
ui.status
|
||||
.language_row
|
||||
.set_selected(language_index(config.language.as_deref()));
|
||||
@@ -690,6 +711,26 @@ pub(crate) fn wire_settings(
|
||||
}
|
||||
});
|
||||
|
||||
// Update check: saved, and the first check runs right away if one is due.
|
||||
let ui_update = ui.clone();
|
||||
ui.status.update_row.connect_active_notify(move |row| {
|
||||
if ui_update.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let mut config = ui_update.dirs.load_config();
|
||||
config.update_check = row.is_active();
|
||||
if let Err(e) = ui_update.dirs.save_config(&config) {
|
||||
toast_error(
|
||||
&ui_update,
|
||||
&gettext("Couldn't save the update setting"),
|
||||
&e.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
show_latest(&ui_update);
|
||||
start_update_checks(&ui_update);
|
||||
});
|
||||
|
||||
// Tray icon: the tray reads the same flag at start, so hiding it here also
|
||||
// keeps the login autostart from bringing it back.
|
||||
let ui_tray = ui.clone();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Tells the user when a newer release is out, if they turned on Preferences →
|
||||
//! Updates. The decisions are [`pdfs_core::update`]'s; this asks GitHub and
|
||||
//! shows the banner.
|
||||
|
||||
use crate::*;
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use pdfs_core::update::{
|
||||
RELEASES_LATEST_API, is_newer, latest_version, next_check_in, release_url, updates_managed,
|
||||
};
|
||||
|
||||
/// How long one request to GitHub may take.
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long to wait after a failed check, such as one made offline, before
|
||||
/// trying again. Not saved, so a restart tries again straight away.
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
pub(crate) struct UpdateState {
|
||||
/// Over the sidebar, so it shows whichever page is open.
|
||||
pub(crate) banner: adw::Banner,
|
||||
/// The check loop is running. Turning the setting off and on again while it
|
||||
/// sleeps must not start a second one.
|
||||
running: Cell<bool>,
|
||||
/// The release page the banner's button opens.
|
||||
url: RefCell<String>,
|
||||
}
|
||||
|
||||
impl UpdateState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
banner: adw::Banner::builder()
|
||||
.button_label(gettext("Download"))
|
||||
.build(),
|
||||
running: Cell::new(false),
|
||||
url: RefCell::new(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a package manager brings new releases to this install. The app then
|
||||
/// never asks, and Preferences does not offer to.
|
||||
pub(crate) fn updates_managed_here() -> bool {
|
||||
std::env::current_exe().is_ok_and(|exe| updates_managed(&exe))
|
||||
}
|
||||
|
||||
/// Wire the banner, show a release an earlier check found, and start checking
|
||||
/// if the user asked for it.
|
||||
pub(crate) fn wire_update(ui: &Rc<Ui>) {
|
||||
let ui_open = ui.clone();
|
||||
ui.update
|
||||
.banner
|
||||
.connect_button_clicked(move |_| open_uri(&ui_open.update.url.borrow()));
|
||||
show_latest(ui);
|
||||
start_update_checks(ui);
|
||||
}
|
||||
|
||||
/// Start the daily check, unless it is off, managed elsewhere, or running.
|
||||
pub(crate) fn start_update_checks(ui: &Rc<Ui>) {
|
||||
if updates_managed_here()
|
||||
|| !ui.dirs.load_config().update_check
|
||||
|| ui.update.running.replace(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
// Also kept here, so a config that cannot be saved does not make every
|
||||
// pass ask GitHub again.
|
||||
let mut checked = None;
|
||||
loop {
|
||||
let config = ui.dirs.load_config();
|
||||
if !config.update_check {
|
||||
break;
|
||||
}
|
||||
let wait = next_check_in(config.update_last_checked.max(checked), unix_now());
|
||||
if !wait.is_zero() {
|
||||
glib::timeout_future(wait).await;
|
||||
continue;
|
||||
}
|
||||
match fetch_latest().await {
|
||||
Ok(latest) => {
|
||||
checked = Some(unix_now());
|
||||
let mut config = ui.dirs.load_config();
|
||||
config.update_last_checked = checked;
|
||||
config.update_latest = latest;
|
||||
if let Err(e) = ui.dirs.save_config(&config) {
|
||||
tracing::warn!("update check: cannot save the result: {e}");
|
||||
}
|
||||
show_latest(&ui);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("update check: {e}");
|
||||
glib::timeout_future(RETRY_AFTER).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.update.running.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reveal the banner if the last check found a release newer than this one,
|
||||
/// and hide it otherwise.
|
||||
pub(crate) fn show_latest(ui: &Ui) {
|
||||
let config = ui.dirs.load_config();
|
||||
let newer = config
|
||||
.update_latest
|
||||
.filter(|latest| config.update_check && is_newer(latest, env!("CARGO_PKG_VERSION")));
|
||||
match newer {
|
||||
Some(version) => {
|
||||
ui.update.banner.set_title(&gettext_f(
|
||||
"Proton Drive {version} is available",
|
||||
&[("version", &version)],
|
||||
));
|
||||
*ui.update.url.borrow_mut() = release_url(&version);
|
||||
ui.update.banner.set_revealed(!updates_managed_here());
|
||||
}
|
||||
None => ui.update.banner.set_revealed(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// The latest release's version, asked on a worker thread so the window never
|
||||
/// waits on GitHub. `Ok(None)` when the answer names no `X.Y.Z` release.
|
||||
async fn fetch_latest() -> Result<Option<String>, String> {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(fetch_latest_blocking());
|
||||
});
|
||||
rx.recv().await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn fetch_latest_blocking() -> Result<Option<String>, String> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("no runtime: {e}"))?;
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!(
|
||||
"proton-drive-linux/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (",
|
||||
env!("CARGO_PKG_REPOSITORY"),
|
||||
")"
|
||||
))
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("no HTTP client: {e}"))?;
|
||||
runtime.block_on(async {
|
||||
let reply = client
|
||||
.get(RELEASES_LATEST_API)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !reply.status().is_success() {
|
||||
return Err(format!("GitHub answered {}", reply.status()));
|
||||
}
|
||||
let body = reply.bytes().await.map_err(|e| e.to_string())?;
|
||||
Ok(latest_version(&body))
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,10 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
- **A COPR repository for Fedora**, so `dnf upgrade` brings each new release:
|
||||
`sudo dnf copr enable narrrl/proton-drive-linux`. Every release also ships the vendored crates
|
||||
as `proton-drive-linux-vendor-<version>.tar.xz`, which the COPR build uses offline (#42).
|
||||
- **The app can tell you about a new release.** Turn on **Check for updates** in Preferences, and
|
||||
the app asks GitHub once a day and shows a banner with a **Download** button when a newer
|
||||
version is out. It is off by default, and not offered for packages that a package manager
|
||||
updates (COPR, the AUR, Nix) (#42).
|
||||
|
||||
## [3.5.0] — 2026-10-16
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ fix it.
|
||||
"proton_theme": true,
|
||||
"online_map": false,
|
||||
"tray_hidden": false,
|
||||
"update_check": false,
|
||||
"files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true },
|
||||
"window": { "width": 980, "height": 680, "maximized": false },
|
||||
"open_with": {
|
||||
@@ -87,6 +88,9 @@ fix it.
|
||||
| `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme |
|
||||
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
|
||||
| `tray_hidden` | bool | `false` | General → Appearance | Hide the tray icon |
|
||||
| `update_check` | bool | `false` | General → Updates | Ask GitHub once a day for a newer release and show it over the sidebar. Not offered where a package manager installs updates |
|
||||
| `update_last_checked` | Unix seconds | none | — | Written by the update check, so a restart does not ask again within a day |
|
||||
| `update_latest` | version | none | — | The latest release the update check found |
|
||||
| `files_view` | object | grid, by name | My files view menu | `list`, `sort` (`name`, `size`, `modified`), `descending`, `folders_first` |
|
||||
| `window` | object | 980 × 680 | Resizing the app window | `width`, `height` (the unmaximized size), `maximized` |
|
||||
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
||||
|
||||
@@ -288,6 +288,13 @@ starts; to switch at once:
|
||||
systemctl --user restart proton-drive.service
|
||||
```
|
||||
|
||||
With the `.deb`, the release `.rpm` or the `.tar.gz`, nothing tells you about a new release.
|
||||
Turn on **Preferences → General → Check for updates** and the app asks GitHub once a day and
|
||||
shows a banner over the sidebar when a newer version is out. Packages that a package manager
|
||||
updates do not offer the setting. A package shows this by installing the empty file
|
||||
`share/proton-drive-linux/managed-updates` under the prefix of its `bin/` directory, for example
|
||||
`/usr/share/proton-drive-linux/managed-updates`.
|
||||
|
||||
Coming from 2.8.0 or older, re-enable the service once. Those releases hooked it into
|
||||
`default.target`, which starts it before the desktop session is ready; on KDE that crashes
|
||||
`ksecretd` at every login. Signing in again or toggling "Start on login" does this as well:
|
||||
|
||||
@@ -60,8 +60,12 @@ The client connects only to:
|
||||
- **Proton**, for the Drive and account API, and for the CAPTCHA page when Proton asks for one.
|
||||
- **OpenFreeMap**, for the street map of Places in Photos, only if you turn it on in Preferences. It is off by default, because the tile server learns which parts of the
|
||||
world you look at. The default map is drawn from data shipped with the app.
|
||||
- **GitHub**, once a day, to ask for the latest release, only if you turn on **Check for updates**
|
||||
in Preferences. It is off by default, because GitHub learns your IP address and, from the user
|
||||
agent, which version you run. Packages updated by a package manager (COPR, the AUR, Nix) do
|
||||
not offer the setting and never ask.
|
||||
|
||||
There is no telemetry, no analytics and no update check. The SDK can report timings of its own
|
||||
There is no telemetry and no analytics. The SDK can report timings of its own
|
||||
operations, and the client only writes them to its local log.
|
||||
|
||||
## What this client does not protect against
|
||||
|
||||
@@ -65,4 +65,7 @@ package() {
|
||||
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
|
||||
|
||||
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# pacman brings new releases, so the app never checks for them itself.
|
||||
install -Dm644 /dev/null "$pkgdir/usr/share/proton-drive-linux/managed-updates"
|
||||
}
|
||||
@@ -83,4 +83,7 @@ package() {
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# pacman brings new releases, so the app never checks for them itself.
|
||||
install -Dm644 /dev/null "$pkgdir/usr/share/proton-drive-linux/managed-updates"
|
||||
}
|
||||
@@ -78,4 +78,7 @@ package() {
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# pacman brings new releases, so the app never checks for them itself.
|
||||
install -Dm644 /dev/null "$pkgdir/usr/share/proton-drive-linux/managed-updates"
|
||||
}
|
||||
@@ -106,6 +106,9 @@ rustPlatform.buildRustPackage {
|
||||
|
||||
patchShebangs po/build.sh
|
||||
po/build.sh $out/share/locale
|
||||
|
||||
# Nix brings new releases, so the app never checks for them itself.
|
||||
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates
|
||||
'';
|
||||
|
||||
# Thumbnails shell out to exiftool, and to ffmpeg for video. Suffixed, so a
|
||||
|
||||
@@ -114,6 +114,10 @@ install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \
|
||||
install -D -m0644 %{src}/packaging/proton-drive.service \
|
||||
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
||||
%{src}/po/build.sh %{buildroot}%{_datadir}/locale
|
||||
%if 0%{!?git_dir:1}
|
||||
# dnf brings new releases from COPR, so the app does not offer its own check.
|
||||
install -D -m0644 /dev/null %{buildroot}%{_datadir}/proton-drive-linux/managed-updates
|
||||
%endif
|
||||
|
||||
# Removing the package does not stop a running daemon: it keeps serving the
|
||||
# mount from the deleted binary, so files deleted under the mount afterwards,
|
||||
@@ -145,6 +149,9 @@ fi
|
||||
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||
/usr/lib/systemd/user/proton-drive.service
|
||||
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
|
||||
%if 0%{!?git_dir:1}
|
||||
%{_datadir}/proton-drive-linux/managed-updates
|
||||
%endif
|
||||
|
||||
%changelog
|
||||
* Sun Jul 19 2026 Local Packager - 0.4.0-1
|
||||
|
||||
@@ -19,6 +19,7 @@ crates/pdfs-gui/src/app/pages/status.rs
|
||||
crates/pdfs-gui/src/app/pages/takeout.rs
|
||||
crates/pdfs-gui/src/app/pages/trash.rs
|
||||
crates/pdfs-gui/src/app/pages/verify.rs
|
||||
crates/pdfs-gui/src/app/update.rs
|
||||
crates/pdfs-gui/src/app/widgets/compat.rs
|
||||
crates/pdfs-gui/src/app/widgets/details.rs
|
||||
crates/pdfs-gui/src/app/widgets/file_list.rs
|
||||
|
||||
+199
-173
File diff suppressed because it is too large.
Load diff
+197
-173
File diff suppressed because it is too large.
Load diff
+201
-174
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user