From e66308498b9dfa9a741bde32bc7f5cbdd7505da0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 17 Oct 2026 11:44:33 +0200 Subject: [PATCH] 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 --- crates/pdfs-core/src/config.rs | 24 ++ crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-core/src/update.rs | 178 +++++++++ crates/pdfs-gui/src/app/main.rs | 19 +- crates/pdfs-gui/src/app/pages/status.rs | 41 ++ crates/pdfs-gui/src/app/update.rs | 168 ++++++++ docs/CHANGELOG.md | 4 + docs/CONFIGURATION.md | 4 + docs/INSTALL.md | 7 + docs/SECURITY_MODEL.md | 6 +- .../aur/proton-drive-for-linux-bin/PKGBUILD | 3 + .../aur/proton-drive-for-linux-git/PKGBUILD | 3 + packaging/aur/proton-drive-for-linux/PKGBUILD | 3 + packaging/nix/package.nix | 3 + packaging/proton-drive-linux.spec | 7 + po/POTFILES.in | 1 + po/de.po | 375 ++++++++++-------- po/en_GB.po | 372 +++++++++-------- po/es.po | 375 ++++++++++-------- po/fr.po | 375 ++++++++++-------- po/it.po | 375 ++++++++++-------- po/nl.po | 375 ++++++++++-------- po/pdfs.pot | 370 +++++++++-------- po/pl.po | 375 ++++++++++-------- po/pt_BR.po | 375 ++++++++++-------- 25 files changed, 2270 insertions(+), 1569 deletions(-) create mode 100644 crates/pdfs-core/src/update.rs create mode 100644 crates/pdfs-gui/src/app/update.rs diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 4206655..5de4454 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -190,6 +190,24 @@ pub struct AppConfig { /// the field. #[serde(default)] pub local_first: Option, + /// 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, + /// 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, } /// 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); diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 73a84a7..f7b9131 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -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}; diff --git a/crates/pdfs-core/src/update.rs b/crates/pdfs-core/src/update.rs new file mode 100644 index 0000000..5c0cd7a --- /dev/null +++ b/crates/pdfs-core/src/update.rs @@ -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 { + 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::().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, 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 { + 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)); + } +} diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 164410a..6df2ce5 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -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); diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 17961ac..a3a3879 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -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(); diff --git a/crates/pdfs-gui/src/app/update.rs b/crates/pdfs-gui/src/app/update.rs new file mode 100644 index 0000000..b364757 --- /dev/null +++ b/crates/pdfs-gui/src/app/update.rs @@ -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, + /// The release page the banner's button opens. + url: RefCell, +} + +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) { + 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) { + 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, 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, 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)) + }) +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b212283..9d6707c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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-.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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f121275..558ccf0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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) | diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d6da210..a84b6a6 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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: diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index af4896c..a7095b2 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -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 diff --git a/packaging/aur/proton-drive-for-linux-bin/PKGBUILD b/packaging/aur/proton-drive-for-linux-bin/PKGBUILD index 47cdbb4..e31c103 100644 --- a/packaging/aur/proton-drive-for-linux-bin/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux-bin/PKGBUILD @@ -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" } diff --git a/packaging/aur/proton-drive-for-linux-git/PKGBUILD b/packaging/aur/proton-drive-for-linux-git/PKGBUILD index 4ac4776..2329e3b 100644 --- a/packaging/aur/proton-drive-for-linux-git/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux-git/PKGBUILD @@ -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" } diff --git a/packaging/aur/proton-drive-for-linux/PKGBUILD b/packaging/aur/proton-drive-for-linux/PKGBUILD index 6974209..cfba5ff 100644 --- a/packaging/aur/proton-drive-for-linux/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux/PKGBUILD @@ -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" } diff --git a/packaging/nix/package.nix b/packaging/nix/package.nix index 2f4e6ed..3c052b2 100644 --- a/packaging/nix/package.nix +++ b/packaging/nix/package.nix @@ -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 diff --git a/packaging/proton-drive-linux.spec b/packaging/proton-drive-linux.spec index 3734f04..dbb0d29 100644 --- a/packaging/proton-drive-linux.spec +++ b/packaging/proton-drive-linux.spec @@ -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 diff --git a/po/POTFILES.in b/po/POTFILES.in index 879f4b9..fd55328 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -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 diff --git a/po/de.po b/po/de.po index 778e824..5555d25 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-16 17:09+0200\n" +"POT-Creation-Date: 2026-10-17 01:23+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,7 +57,7 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:849 +#: crates/pdfs-gui/src/app/main.rs:856 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:833 @@ -70,74 +70,74 @@ msgstr "%A, %-d. %B %Y, %H:%M" msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:852 crates/pdfs-gui/src/app/main.rs:1517 +#: crates/pdfs-gui/src/app/main.rs:859 crates/pdfs-gui/src/app/main.rs:1528 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:857 +#: crates/pdfs-gui/src/app/main.rs:864 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:862 +#: crates/pdfs-gui/src/app/main.rs:869 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:865 +#: crates/pdfs-gui/src/app/main.rs:872 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:493 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:866 +#: crates/pdfs-gui/src/app/main.rs:873 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:868 +#: crates/pdfs-gui/src/app/main.rs:875 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1214 +#: crates/pdfs-gui/src/app/main.rs:1225 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1237 +#: crates/pdfs-gui/src/app/main.rs:1248 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1238 +#: crates/pdfs-gui/src/app/main.rs:1249 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1239 +#: crates/pdfs-gui/src/app/main.rs:1250 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1240 +#: crates/pdfs-gui/src/app/main.rs:1251 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1328 +#: crates/pdfs-gui/src/app/main.rs:1339 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1336 +#: crates/pdfs-gui/src/app/main.rs:1347 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -149,23 +149,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1341 +#: crates/pdfs-gui/src/app/main.rs:1352 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1345 +#: crates/pdfs-gui/src/app/main.rs:1356 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1349 +#: crates/pdfs-gui/src/app/main.rs:1360 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1354 +#: crates/pdfs-gui/src/app/main.rs:1365 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1358 +#: crates/pdfs-gui/src/app/main.rs:1369 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -190,7 +190,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1366 +#: crates/pdfs-gui/src/app/main.rs:1377 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,7 +60,7 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:849 +#: crates/pdfs-gui/src/app/main.rs:856 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:833 @@ -73,74 +73,74 @@ msgstr "" msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:852 crates/pdfs-gui/src/app/main.rs:1517 +#: crates/pdfs-gui/src/app/main.rs:859 crates/pdfs-gui/src/app/main.rs:1528 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:857 +#: crates/pdfs-gui/src/app/main.rs:864 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:862 +#: crates/pdfs-gui/src/app/main.rs:869 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:865 +#: crates/pdfs-gui/src/app/main.rs:872 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:493 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:866 +#: crates/pdfs-gui/src/app/main.rs:873 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:868 +#: crates/pdfs-gui/src/app/main.rs:875 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1214 +#: crates/pdfs-gui/src/app/main.rs:1225 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1237 +#: crates/pdfs-gui/src/app/main.rs:1248 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1238 +#: crates/pdfs-gui/src/app/main.rs:1249 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1239 +#: crates/pdfs-gui/src/app/main.rs:1250 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1240 +#: crates/pdfs-gui/src/app/main.rs:1251 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1328 +#: crates/pdfs-gui/src/app/main.rs:1339 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1336 +#: crates/pdfs-gui/src/app/main.rs:1347 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +148,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1341 +#: crates/pdfs-gui/src/app/main.rs:1352 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1345 +#: crates/pdfs-gui/src/app/main.rs:1356 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1349 +#: crates/pdfs-gui/src/app/main.rs:1360 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1354 +#: crates/pdfs-gui/src/app/main.rs:1365 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1358 +#: crates/pdfs-gui/src/app/main.rs:1369 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -177,21 +177,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1366 +#: crates/pdfs-gui/src/app/main.rs:1377 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "The street map, when turned on, comes from OpenFreeMap in the