Merge pull request #44 from narrrl/automate-updates

feat: COPR repository and opt-in update check
This commit is contained in:
nvrl authored and GitHub committed 2026-10-17 13:13:49 +02:00
commit d1d4d23dd5
30 files changed
+2471 -1596

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
name: COPR
on:
# Called by release.yml once the GitHub release and its assets exist: COPR
# downloads the tag archive and the vendored crates from the release.
workflow_call:
inputs:
version:
required: true
type: string
secrets:
COPR_CONFIG:
required: true
workflow_dispatch:
inputs:
version:
description: 'Release to build, such as 3.5.0. It must already have its vendor tarball.'
required: true
type: string
permissions:
contents: read
concurrency:
group: copr
cancel-in-progress: false
jobs:
build:
name: Build in COPR
runs-on: ubuntu-24.04
container: fedora:latest
env:
VERSION: ${{ inputs.version }}
COPR_PROJECT: narl-rs/proton-drive-linux
steps:
- name: Install packaging tools
run: dnf -y install copr-cli rpm-build rpmdevtools
- name: Checkout repository
uses: actions/checkout@v7
- name: Configure COPR access
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
install -d -m700 ~/.config
printf '%s\n' "$COPR_CONFIG" > ~/.config/copr
chmod 600 ~/.config/copr
# COPR rebuilds the source package without our --define, so the version
# has to be written into the spec itself.
- name: Build the source package
run: |
version="${VERSION#v}"
spec=packaging/proton-drive-linux.spec
sed -i -E "s/^(%\{!\?version: %global version )[^}]*\}/\1${version}}/" "$spec"
grep -qF "%global version ${version}}" "$spec"
mkdir -p rpmbuild/SOURCES
spectool -g -C rpmbuild/SOURCES "$spec"
rpmbuild -bs --define "_topdir $PWD/rpmbuild" "$spec"
# Waits for the build, so a failed COPR build fails this job.
- name: Submit to COPR
run: copr-cli build "$COPR_PROJECT" rpmbuild/SRPMS/*.src.rpm
+20
View File
@@ -52,8 +52,10 @@ jobs:
tag_version="${GITHUB_REF_NAME#v}"
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
spec_version="$(sed -n 's/^%{!?version: %global version \([^}]*\)}/\1/p' packaging/proton-drive-linux.spec)"
test "$tag_version" = "$manifest_version"
test "$tag_version" = "$pkgbuild_version"
test "$tag_version" = "$spec_version"
- name: Release quality gates
run: |
@@ -129,6 +131,12 @@ jobs:
# Create tarball
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
# Every crate the build needs, so COPR can build the spec without network.
- name: Package vendored crates (.tar.xz)
run: |
cargo vendor --locked dist/vendor > /dev/null
tar -cJf proton-drive-linux-vendor-${{ env.VERSION }}.tar.xz -C dist vendor
- name: Package Debian package (.deb)
run: |
mkdir -p deb/DEBIAN
@@ -230,6 +238,7 @@ jobs:
path: |
*.deb
*.tar.gz
*.tar.xz
*.rpm
if-no-files-found: error
@@ -297,6 +306,7 @@ jobs:
files: |
*.deb
*.tar.gz
*.tar.xz
*.rpm
draft: false
prerelease: false
@@ -312,3 +322,13 @@ jobs:
version: ${{ github.ref_name }}
secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
copr:
name: Build COPR packages
needs: publish
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/copr.yml
with:
version: ${{ github.ref_name }}
secrets:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
+3 -2
View File
@@ -74,14 +74,15 @@ scriptable CLI.
| Distribution | Package |
|---|---|
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| Fedora 44 or newer | [`narl-rs/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/) in COPR, or the `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) |
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
```bash
sudo apt install ./proton-drive-linux_*.deb # Debian, Ubuntu
sudo dnf install ./proton-drive-linux-*.rpm # Fedora
sudo dnf copr enable narl-rs/proton-drive-linux # Fedora
sudo dnf install proton-drive-linux
paru -S proton-drive-for-linux # Arch
```
+24
View File
@@ -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);
+1
View File
@@ -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};
+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;
#[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: &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();
list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("navigation-sidebar");
@@ -927,6 +937,7 @@ fn build_sidebar(footer: &gtk4::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);
+41
View File
@@ -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();
+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))
})
}
+9
View File
@@ -11,6 +11,15 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
### Added
- **A COPR repository for Fedora**, so `dnf upgrade` brings each new release:
`sudo dnf copr enable narl-rs/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
Schema: **44**. Queued changes get an index by kind and name, and each synced file remembers the
+4
View File
@@ -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) |
+11 -8
View File
@@ -150,22 +150,25 @@ to the GitHub wiki, so keep their file names.
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
`packaging/PKGBUILD` agree;
1. checks that the tag, the workspace version in `Cargo.toml`, `pkgver` in
`packaging/PKGBUILD` and the default version in `packaging/proton-drive-linux.spec` agree;
2. runs the quality gates;
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`, and
builds the Nix flake at the tag, which is what a flake input pinned to `vX.Y.Z` gets, and
pushes it to the `proton-drive-linux` Cachix cache (secret `CACHIX_AUTH_TOKEN`);
3. builds the release binaries and packages a `.tar.gz`, a `.deb`, a GPG-signed `.rpm` and a
`.tar.xz` of the vendored crates, and builds the Nix flake at the tag, which is what a flake
input pinned to `vX.Y.Z` gets, and pushes it to the `proton-drive-linux` Cachix cache (secret
`CACHIX_AUTH_TOKEN`);
4. publishes them as a GitHub release, with the version's section of `docs/CHANGELOG.md` as the
notes;
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
back to `main`.
back to `main`;
6. calls `.github/workflows/copr.yml`, which builds the release in the
[COPR repository](../packaging/copr/README.md) (secret `COPR_CONFIG`).
To prepare a release:
```bash
# 1. Bump the version in Cargo.toml, packaging/PKGBUILD, po/pdfs.pot and the pinned flake
# URL in docs/INSTALL.md, then refresh the lock file.
# 1. Bump the version in Cargo.toml, packaging/PKGBUILD, packaging/proton-drive-linux.spec,
# po/pdfs.pot and the pinned flake URL in docs/INSTALL.md, then refresh the lock file.
cargo update --workspace
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
# 3. Commit, tag and push.
+18
View File
@@ -59,6 +59,17 @@ sudo apt install ./proton-drive-linux_*_amd64.deb
### Fedora (44 or newer)
The [COPR repository](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/) builds
every release, so `dnf upgrade` keeps the client up to date:
```bash
sudo dnf copr enable narl-rs/proton-drive-linux
sudo dnf install proton-drive-linux
```
The `.rpm` from the release page installs the same files, but you have to download each new
version yourself:
```bash
sudo dnf install ./proton-drive-linux-*.x86_64.rpm
```
@@ -277,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:
+8 -2
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.
- **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
@@ -75,7 +79,9 @@ operations, and the client only writes them to its local log.
## Packages
- The `.rpm` is signed with GPG.
- The `.rpm` on the release page is signed with GPG.
- The COPR packages are built by Fedora's COPR service from the tagged source and the vendored
crates of the release, and are signed with the COPR project's own key.
- The `.deb` and the `.tar.gz` are built by GitHub Actions from the tagged source, and are not
signed.
- The AUR packages build from the tagged source or from `main`, or install the release binaries
@@ -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"
}
+43
View File
@@ -0,0 +1,43 @@
# COPR repository
Fedora users install from the COPR project
[`narl-rs/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/),
so `dnf upgrade` brings every release. COPR builds `packaging/proton-drive-linux.spec` from two
release assets:
| Source | What it is |
| --- | --- |
| `Source0` | GitHub's archive of the `v<version>` tag |
| `Source1` | `proton-drive-linux-vendor-<version>.tar.xz`: every crate from `Cargo.lock`, made by `cargo vendor` in `release.yml` |
With the vendored crates the build needs no network, so the COPR project keeps network access
off.
## Releasing a new version
`.github/workflows/copr.yml` does this automatically:
- **On a release tag**, `release.yml` calls it after the GitHub release is published. It writes
the version into the spec, downloads both sources with `spectool`, builds a source package
and submits it with `copr-cli build`. The job waits for COPR, so a failed build fails the
release workflow.
- **By hand**, run the workflow from the Actions tab with a version. The release must already
have its vendor tarball.
It needs the `COPR_CONFIG` repository secret: the whole content of `~/.config/copr` from
<https://copr.fedorainfracloud.org/api/>. **The token expires after 180 days.** Renew it on that
page and replace the secret, or the next release's COPR job fails.
To do the same by hand:
```bash
spectool -g -C ~/rpmbuild/SOURCES packaging/proton-drive-linux.spec
rpmbuild -bs packaging/proton-drive-linux.spec
copr-cli build narl-rs/proton-drive-linux ~/rpmbuild/SRPMS/proton-drive-linux-*.src.rpm
```
## Project settings
- Chroots: `fedora-44-x86_64`, `fedora-45-x86_64` and `fedora-rawhide-x86_64`. The spec is
`ExclusiveArch: x86_64` and needs `rust >= 1.96`.
- Network access in builds: off.
+3
View File
@@ -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
+46 -16
View File
@@ -1,13 +1,19 @@
# Local in-tree RPM (mirrors packaging/PKGBUILD).
# From the repository root:
# rpmbuild -bb packaging/proton-drive-linux.spec --define "git_dir $PWD" ...
# %build needs network so cargo can fetch crates.io (same trade-off as the Arch PKGBUILD).
# Two ways to build this spec:
#
# - From a checkout (local builds and the release workflow). From the repository root:
# rpmbuild -bb packaging/proton-drive-linux.spec --define "git_dir $PWD" ...
# %build needs network so cargo can fetch crates.io (same trade-off as the Arch PKGBUILD).
# - From the release tarballs (COPR, .github/workflows/copr.yml), when git_dir is not
# defined. Source1 holds every crate, so the build runs offline.
%global debug_package %{nil}
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
%global _lto_cflags %{nil}
%{!?version: %global version 0.4.0}
%{!?version: %global version 3.5.0}
%global forge https://github.com/narrrl/proton-drive-linux
# Files come from the checkout, or from the unpacked tag archive.
%global src %{?git_dir}%{!?git_dir:.}
Name: proton-drive-linux
Version: %{version}
@@ -16,9 +22,13 @@ Summary: Unofficial Proton Drive client for Linux (FUSE, CLI, GTK4 app +
License: MIT
URL: https://proton-drive.narl.io
ExclusiveArch: x86_64
%if 0%{!?git_dir:1}
Source0: %{forge}/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.tar.gz
Source1: %{forge}/releases/download/v%{version}/%{name}-vendor-%{version}.tar.xz
%endif
BuildRequires: cargo
BuildRequires: rust
BuildRequires: rust >= 1.96
BuildRequires: pkgconf-pkg-config
BuildRequires: fuse3-devel
BuildRequires: gtk4-devel
@@ -57,18 +67,31 @@ block-level caching, a scriptable CLI, and a GTK4/Libadwaita desktop app with
system tray and search launcher. Unofficial and not affiliated with Proton AG.
%prep
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
test -n "%{?git_dir}" || (echo 'Pass --define "git_dir $PWD" from the repo root' >&2; exit 1)
%if 0%{?git_dir:1}
# In-tree build: no Source tarball.
test -f %{git_dir}/Cargo.toml
cp -a %{git_dir}/LICENSE .
%else
# -a 1 unpacks the vendored crates into vendor/ inside the source tree.
%autosetup -n %{name}-%{version} -a 1
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"
EOF
%endif
%build
# The release workflow has already built target/release and passes --define "prebuilt 1".
# rpmbuild exports its own compiler flags, which would make cargo rebuild everything.
%if 0%{?prebuilt}
test -x %{git_dir}/target/release/pdfs
%else
cd %{git_dir}
cargo build --release --locked \
cd %{src}
cargo build --release %{?git_dir:--locked}%{!?git_dir:--frozen} \
--bin pdfs \
--bin pdfs-tray \
--bin pdfs-app \
@@ -76,21 +99,25 @@ cargo build --release --locked \
%endif
%install
rel=%{git_dir}/target/release
rel=%{src}/target/release
install -D -m0755 "$rel/pdfs" %{buildroot}%{_bindir}/pdfs
install -D -m0755 "$rel/pdfs-tray" %{buildroot}%{_bindir}/pdfs-tray
install -D -m0755 "$rel/pdfs-app" %{buildroot}%{_bindir}/pdfs-app
install -D -m0755 "$rel/pdfs-prompt" %{buildroot}%{_bindir}/pdfs-prompt
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.desktop \
install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.desktop \
%{buildroot}%{_datadir}/applications/io.narl.proton-drive-linux.desktop
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux-tray.desktop \
install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux-tray.desktop \
%{buildroot}%{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.svg \
install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \
%{buildroot}%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
install -D -m0644 %{git_dir}/packaging/proton-drive.service \
install -D -m0644 %{src}/packaging/proton-drive.service \
%{buildroot}/usr/lib/systemd/user/proton-drive.service
%{git_dir}/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
# mount from the deleted binary, so files deleted under the mount afterwards,
@@ -122,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
+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/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
+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