From 29b87ece7208e49d0d07b2cf311ef96f4f97dde1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 17 Oct 2026 11:44:33 +0200 Subject: [PATCH 1/3] feat(packaging): COPR repository for Fedora The spec builds from the tag archive and a vendored crates tarball when git_dir is not defined, so COPR can build it without network access. release.yml publishes the vendor tarball and submits the build to COPR after the release is published. Refs #42 --- .github/workflows/copr.yml | 66 +++++++++++++++++++++++++++++++ .github/workflows/release.yml | 20 ++++++++++ README.md | 5 ++- docs/CHANGELOG.md | 5 +++ docs/DEVELOPMENT.md | 19 +++++---- docs/INSTALL.md | 11 ++++++ docs/SECURITY_MODEL.md | 4 +- packaging/copr/README.md | 43 ++++++++++++++++++++ packaging/proton-drive-linux.spec | 55 ++++++++++++++++++-------- 9 files changed, 201 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/copr.yml create mode 100644 packaging/copr/README.md diff --git a/.github/workflows/copr.yml b/.github/workflows/copr.yml new file mode 100644 index 0000000..997903e --- /dev/null +++ b/.github/workflows/copr.yml @@ -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: narrrl/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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b6d542..cb6e9b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/README.md b/README.md index 04a592c..af63543 100644 --- a/README.md +++ b/README.md @@ -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 | [`narrrl/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narrrl/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 narrrl/proton-drive-linux # Fedora +sudo dnf install proton-drive-linux paru -S proton-drive-for-linux # Arch ``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5ddcb5c..b212283 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,11 @@ 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 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). + ## [3.5.0] — 2026-10-16 Schema: **44**. Queued changes get an index by kind and name, and each synced file remembers the diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 61a1244..9c4396f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 8ec4eb7..d6da210 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -59,6 +59,17 @@ sudo apt install ./proton-drive-linux_*_amd64.deb ### Fedora (44 or newer) +The [COPR repository](https://copr.fedorainfracloud.org/coprs/narrrl/proton-drive-linux/) builds +every release, so `dnf upgrade` keeps the client up to date: + +```bash +sudo dnf copr enable narrrl/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 ``` diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index 89be444..af4896c 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -75,7 +75,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 diff --git a/packaging/copr/README.md b/packaging/copr/README.md new file mode 100644 index 0000000..fa01b60 --- /dev/null +++ b/packaging/copr/README.md @@ -0,0 +1,43 @@ +# COPR repository + +Fedora users install from the COPR project +[`narrrl/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narrrl/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` tag | +| `Source1` | `proton-drive-linux-vendor-.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 +. **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 narrrl/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. diff --git a/packaging/proton-drive-linux.spec b/packaging/proton-drive-linux.spec index 84e52c5..3734f04 100644 --- a/packaging/proton-drive-linux.spec +++ b/packaging/proton-drive-linux.spec @@ -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,21 @@ 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 # 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, From e66308498b9dfa9a741bde32bc7f5cbdd7505da0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 17 Oct 2026 11:44:33 +0200 Subject: [PATCH 2/3] 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 Date: Sat, 17 Oct 2026 12:38:09 +0200 Subject: [PATCH 3/3] fix(packaging): COPR project belongs to narl-rs The COPR project lives under the FAS account narl-rs, not narrrl. Refs #42 --- .github/workflows/copr.yml | 2 +- README.md | 4 ++-- docs/CHANGELOG.md | 2 +- docs/INSTALL.md | 4 ++-- packaging/copr/README.md | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/copr.yml b/.github/workflows/copr.yml index 997903e..be7648e 100644 --- a/.github/workflows/copr.yml +++ b/.github/workflows/copr.yml @@ -32,7 +32,7 @@ jobs: container: fedora:latest env: VERSION: ${{ inputs.version }} - COPR_PROJECT: narrrl/proton-drive-linux + COPR_PROJECT: narl-rs/proton-drive-linux steps: - name: Install packaging tools diff --git a/README.md b/README.md index af63543..0308d0b 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,14 @@ 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 | [`narrrl/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narrrl/proton-drive-linux/) in COPR, or the `.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 copr enable narrrl/proton-drive-linux # Fedora +sudo dnf copr enable narl-rs/proton-drive-linux # Fedora sudo dnf install proton-drive-linux paru -S proton-drive-for-linux # Arch ``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9d6707c..8aa852d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,7 +13,7 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ### Added - **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 narl-rs/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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index a84b6a6..8033650 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -59,11 +59,11 @@ sudo apt install ./proton-drive-linux_*_amd64.deb ### Fedora (44 or newer) -The [COPR repository](https://copr.fedorainfracloud.org/coprs/narrrl/proton-drive-linux/) builds +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 narrrl/proton-drive-linux +sudo dnf copr enable narl-rs/proton-drive-linux sudo dnf install proton-drive-linux ``` diff --git a/packaging/copr/README.md b/packaging/copr/README.md index fa01b60..67292d8 100644 --- a/packaging/copr/README.md +++ b/packaging/copr/README.md @@ -1,7 +1,7 @@ # COPR repository Fedora users install from the COPR project -[`narrrl/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narrrl/proton-drive-linux/), +[`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: @@ -33,7 +33,7 @@ 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 narrrl/proton-drive-linux ~/rpmbuild/SRPMS/proton-drive-linux-*.src.rpm +copr-cli build narl-rs/proton-drive-linux ~/rpmbuild/SRPMS/proton-drive-linux-*.src.rpm ``` ## Project settings