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:
nvrl committed 2026-10-17 11:45:09 +02:00
1 parent 29b87ece72
commit e66308498b
25 files changed
+2270 -1569

No files matched your search

+24
View File
@@ -190,6 +190,24 @@ pub struct AppConfig {
/// the field. /// the field.
#[serde(default)] #[serde(default)]
pub local_first: Option<bool>, 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. /// The My Files page's view choices, remembered between runs.
@@ -753,6 +771,9 @@ mod tests {
language: Some("de".to_string()), language: Some("de".to_string()),
online_map: true, online_map: true,
local_first: Some(false), 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 json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap();
@@ -764,6 +785,9 @@ mod tests {
assert_eq!(decoded.proton_theme, Some(true)); assert_eq!(decoded.proton_theme, Some(true));
assert!(decoded.tray_hidden); assert!(decoded.tray_hidden);
assert!(decoded.online_map); 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.language.as_deref(), Some("de"));
assert_eq!(decoded.upload_limit, Some(500_000)); assert_eq!(decoded.upload_limit, Some(500_000));
assert_eq!(decoded.download_limit, None); assert_eq!(decoded.download_limit, None);
+1
View File
@@ -27,6 +27,7 @@ pub mod syncignore;
pub mod syncroot; pub mod syncroot;
pub mod takeout; pub mod takeout;
pub mod tray; pub mod tray;
pub mod update;
pub use access::{Access, access_for, perm_bits}; pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result}; pub use error::{CoreError, CoreResult, Error, Result};
+178
View File
@@ -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));
}
}
+15 -4
View File
@@ -10,6 +10,7 @@ pub(crate) mod pages;
pub(crate) mod reload; pub(crate) mod reload;
#[path = "../theme.rs"] #[path = "../theme.rs"]
pub(crate) mod theme; pub(crate) mod theme;
pub(crate) mod update;
pub(crate) mod widgets; pub(crate) mod widgets;
use compat::*; use compat::*;
@@ -33,6 +34,7 @@ use pages::trash::*;
use pages::verify::*; use pages::verify::*;
use reload::{LoadTicket, Loader, replace_items}; use reload::{LoadTicket, Loader, replace_items};
use theme::{load_resources, proton_theme_active, set_proton_theme}; use theme::{load_resources, proton_theme_active, set_proton_theme};
use update::*;
use widgets::compat::*; use widgets::compat::*;
use widgets::crumb::*; use widgets::crumb::*;
use widgets::details::*; use widgets::details::*;
@@ -155,6 +157,7 @@ struct Ui {
pub(crate) locations: LocationsState, pub(crate) locations: LocationsState,
pub(crate) activity: ActivityState, pub(crate) activity: ActivityState,
pub(crate) takeout: TakeoutState, pub(crate) takeout: TakeoutState,
pub(crate) update: UpdateState,
} }
impl Ui { impl Ui {
@@ -365,7 +368,8 @@ fn build_window(app: &adw::Application) {
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack; // Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g. // `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
// login lands on Files). // 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 // Every page brings its own header bar (see `page_frame`), so the content
// side is the bare stack. // side is the bare stack.
@@ -432,6 +436,7 @@ fn build_window(app: &adw::Application) {
theme_row: main_widgets.theme_row.clone(), theme_row: main_widgets.theme_row.clone(),
online_map_row: main_widgets.online_map_row.clone(), online_map_row: main_widgets.online_map_row.clone(),
tray_row: main_widgets.tray_row.clone(), tray_row: main_widgets.tray_row.clone(),
update_row: main_widgets.update_row.clone(),
language_row: main_widgets.language_row.clone(), language_row: main_widgets.language_row.clone(),
settings_suppress: Cell::new(false), settings_suppress: Cell::new(false),
budget_source: RefCell::new(None), budget_source: RefCell::new(None),
@@ -654,6 +659,7 @@ fn build_window(app: &adw::Application) {
running: Cell::new(false), running: Cell::new(false),
dry_run: Cell::new(false), dry_run: Cell::new(false),
}, },
update,
}); });
wire_login(&ui); wire_login(&ui);
if let Some(e) = keyring_error { if let Some(e) = keyring_error {
@@ -700,6 +706,7 @@ fn build_window(app: &adw::Application) {
wire_activity(&ui, &activity_widgets); wire_activity(&ui, &activity_widgets);
wire_takeout(&ui, &takeout_widgets); wire_takeout(&ui, &takeout_widgets);
wire_retry(&ui); wire_retry(&ui);
wire_update(&ui);
// Lazily load the Files / Photos pages the first time they're shown, so the // 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. // 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. /// Index of the first row in the sidebar's sync section.
const SIDEBAR_SYNC_SECTION: i32 = 6; const SIDEBAR_SYNC_SECTION: i32 = 6;
/// The navigation sidebar: the destinations over a footer carrying the sync /// The navigation sidebar: the destinations under the update banner and over a
/// status, the account quota and the account menu. Returns the page (for the /// 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). /// split view) and the list (to drive + reflect the current page).
fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) { fn build_sidebar(
footer: &gtk4::Box,
update_banner: &adw::Banner,
) -> (adw::NavigationPage, gtk4::ListBox) {
let list = gtk4::ListBox::new(); let list = gtk4::ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single); list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("navigation-sidebar"); list.add_css_class("navigation-sidebar");
@@ -927,6 +937,7 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
.build(); .build();
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
toolbar.add_top_bar(update_banner);
toolbar.set_content(Some(&scroll)); toolbar.set_content(Some(&scroll));
toolbar.add_bottom_bar(footer); toolbar.add_bottom_bar(footer);
+41
View File
@@ -52,6 +52,9 @@ pub(crate) struct StatusState {
pub(crate) online_map_row: adw::SwitchRow, pub(crate) online_map_row: adw::SwitchRow,
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`]. /// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
pub(crate) tray_row: adw::SwitchRow, 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 /// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
/// [`Self::settings_suppress`]. /// [`Self::settings_suppress`].
pub(crate) language_row: adw::ComboRow, pub(crate) language_row: adw::ComboRow,
@@ -164,6 +167,7 @@ pub(crate) struct MainWidgets {
pub(crate) theme_row: adw::SwitchRow, pub(crate) theme_row: adw::SwitchRow,
pub(crate) online_map_row: adw::SwitchRow, pub(crate) online_map_row: adw::SwitchRow,
pub(crate) tray_row: adw::SwitchRow, pub(crate) tray_row: adw::SwitchRow,
pub(crate) update_row: adw::SwitchRow,
pub(crate) language_row: adw::ComboRow, pub(crate) language_row: adw::ComboRow,
} }
@@ -250,6 +254,17 @@ pub(crate) fn build_main_page() -> MainWidgets {
let language_row = build_language_row(); let language_row = build_language_row();
appearance_group.add(&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() let general = adw::PreferencesPage::builder()
.title(gettext("General")) .title(gettext("General"))
.icon_name("preferences-system-symbolic") .icon_name("preferences-system-symbolic")
@@ -258,6 +273,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
general.add(&location_group); general.add(&location_group);
general.add(&network_group); general.add(&network_group);
general.add(&appearance_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 // ---- Preferences: Storage
let storage_group = adw::PreferencesGroup::builder() let storage_group = adw::PreferencesGroup::builder()
@@ -442,6 +461,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
theme_row, theme_row,
online_map_row, online_map_row,
tray_row, tray_row,
update_row,
language_row, language_row,
} }
} }
@@ -541,6 +561,7 @@ pub(crate) fn wire_settings(
.set_active(config.proton_theme.unwrap_or(false)); .set_active(config.proton_theme.unwrap_or(false));
ui.status.online_map_row.set_active(config.online_map); ui.status.online_map_row.set_active(config.online_map);
ui.status.tray_row.set_active(!config.tray_hidden); ui.status.tray_row.set_active(!config.tray_hidden);
ui.status.update_row.set_active(config.update_check);
ui.status ui.status
.language_row .language_row
.set_selected(language_index(config.language.as_deref())); .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 // Tray icon: the tray reads the same flag at start, so hiding it here also
// keeps the login autostart from bringing it back. // keeps the login autostart from bringing it back.
let ui_tray = ui.clone(); let ui_tray = ui.clone();
+168
View File
@@ -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))
})
}
+4
View File
@@ -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: - **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 `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). 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 ## [3.5.0] — 2026-10-16
+4
View File
@@ -61,6 +61,7 @@ fix it.
"proton_theme": true, "proton_theme": true,
"online_map": false, "online_map": false,
"tray_hidden": false, "tray_hidden": false,
"update_check": false,
"files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true }, "files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true },
"window": { "width": 980, "height": 680, "maximized": false }, "window": { "width": 980, "height": 680, "maximized": false },
"open_with": { "open_with": {
@@ -87,6 +88,9 @@ fix it.
| `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme | | `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 | | `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
| `tray_hidden` | bool | `false` | General → Appearance | Hide the tray icon | | `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` | | `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` | | `window` | object | 980 × 680 | Resizing the app window | `width`, `height` (the unmaximized size), `maximized` |
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) | | `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
+7
View File
@@ -288,6 +288,13 @@ starts; to switch at once:
systemctl --user restart proton-drive.service 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 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 `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: `ksecretd` at every login. Signing in again or toggling "Start on login" does this as well:
+5 -1
View File
@@ -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. - **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 - **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. 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. operations, and the client only writes them to its local log.
## What this client does not protect against ## What this client does not protect against
@@ -65,4 +65,7 @@ package() {
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale" cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 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 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" 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 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" 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"
} }
+3
View File
@@ -106,6 +106,9 @@ rustPlatform.buildRustPackage {
patchShebangs po/build.sh patchShebangs po/build.sh
po/build.sh $out/share/locale 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 # Thumbnails shell out to exiftool, and to ffmpeg for video. Suffixed, so a
+7
View File
@@ -114,6 +114,10 @@ install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \
install -D -m0644 %{src}/packaging/proton-drive.service \ install -D -m0644 %{src}/packaging/proton-drive.service \
%{buildroot}/usr/lib/systemd/user/proton-drive.service %{buildroot}/usr/lib/systemd/user/proton-drive.service
%{src}/po/build.sh %{buildroot}%{_datadir}/locale %{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 # 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, # 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 %{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
/usr/lib/systemd/user/proton-drive.service /usr/lib/systemd/user/proton-drive.service
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo %{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
%if 0%{!?git_dir:1}
%{_datadir}/proton-drive-linux/managed-updates
%endif
%changelog %changelog
* Sun Jul 19 2026 Local Packager - 0.4.0-1 * Sun Jul 19 2026 Local Packager - 0.4.0-1
+1
View File
@@ -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/takeout.rs
crates/pdfs-gui/src/app/pages/trash.rs crates/pdfs-gui/src/app/pages/trash.rs
crates/pdfs-gui/src/app/pages/verify.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/compat.rs
crates/pdfs-gui/src/app/widgets/details.rs crates/pdfs-gui/src/app/widgets/details.rs
crates/pdfs-gui/src/app/widgets/file_list.rs crates/pdfs-gui/src/app/widgets/file_list.rs
+201 -174
View File
File diff suppressed because it is too large. Load diff
+199 -173
View File
File diff suppressed because it is too large. Load diff
+201 -174
View File
File diff suppressed because it is too large. Load diff
+201 -174
View File
File diff suppressed because it is too large. Load diff
+201 -174
View File
File diff suppressed because it is too large. Load diff
+201 -174
View File
File diff suppressed because it is too large. Load diff
+197 -173
View File
File diff suppressed because it is too large. Load diff
+201 -174
View File
File diff suppressed because it is too large. Load diff
+201 -174
View File
File diff suppressed because it is too large. Load diff