From 5c1530b4b9725f06d0b4da0efceb160b1e02792b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 7 Oct 2026 16:54:26 +0200 Subject: [PATCH] fix(core,gui): send the release version as the client identity Every release sent the fixed `external-drive-linux@0.1.0-alpha`. If Proton ever retires that version, every release fails sign-in at once and no update can fix it. - core: APP_VERSION and USER_AGENT follow CARGO_PKG_VERSION (`external-drive-linux@-stable`, `proton-drive-linux/`) - core: `app_version` and `user_agent` in config.json are optional overrides; the fixed values older releases stored there read as unset and leave the file on the next save, a hand-set value is kept - gui: the About dialog shows the release, its debug info the identity actually sent - docs: describe the new default and the migration --- crates/pdfs-core/src/auth.rs | 5 +- crates/pdfs-core/src/config.rs | 140 ++++++++++++++++++++++++-------- crates/pdfs-gui/src/app/main.rs | 6 +- docs/CONFIGURATION.md | 6 +- 4 files changed, 118 insertions(+), 39 deletions(-) diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 0cc0c8e..517246f 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -109,7 +109,10 @@ fn client_config() -> ProtonClientConfiguration { let (app_version, user_agent) = match AppDirs::new() { Ok(dirs) => { let config = dirs.load_config(); - (config.app_version, config.user_agent) + ( + config.resolved_app_version().to_string(), + config.resolved_user_agent().to_string(), + ) } Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()), }; diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 2a82fd9..74b7342 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use directories::ProjectDirs; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::error::{Error, Result}; use crate::menu::PromptConfig; @@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS; /// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈ /// stable/beta/alpha); a malformed value trips the 422 anti-abuse path. -pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha"; -pub const USER_AGENT: &str = "proton-drive-linux/0.1.0"; +/// Follows the release so Proton can tell releases apart and retire old ones +/// without retiring every one. +pub const APP_VERSION: &str = concat!( + "external-drive-linux@", + env!("CARGO_PKG_VERSION"), + "-stable" +); +pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION")); + +/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read +/// back as "unset" so those configs follow the release instead of pinning it. +const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha"; +const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0"; /// Keyring service name; one entry per credential kind keyed by username. pub const KEYRING_SERVICE: &str = "proton-drive-linux"; @@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option) -> SweepMode } /// Configuration structure allowing the user to customize client identification headers. -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct AppConfig { - pub app_version: String, - pub user_agent: String, + /// Override for [`APP_VERSION`]. `None` means "use the release's own"; + /// the old fixed default reads as `None`, so it leaves the file on the + /// next save. + #[serde( + default, + deserialize_with = "identity_override", + skip_serializing_if = "Option::is_none" + )] + pub app_version: Option, + /// Override for [`USER_AGENT`], handled like `app_version`. + #[serde( + default, + deserialize_with = "identity_override", + skip_serializing_if = "Option::is_none" + )] + pub user_agent: Option, /// Soft cap on the on-disk content cache, in bytes (`0` = unlimited). /// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page /// writes an explicit value here. Defaulted for configs predating the field. @@ -216,31 +241,28 @@ impl FileSort { } } -impl Default for AppConfig { - fn default() -> Self { - Self { - app_version: APP_VERSION.to_string(), - user_agent: USER_AGENT.to_string(), - cache_budget: None, - upload_limit: None, - download_limit: None, - mountpoint: None, - ignore_patterns: None, - device_uid: None, - conflict_sweep: None, - open_with: None, - prompt: None, - proton_theme: None, - files_view: FilesView::default(), - tray_hidden: false, - language: None, - online_map: false, - local_first: None, - } - } +/// Reads an `app_version` or `user_agent` override, dropping the fixed +/// identity older releases stored as the default. +fn identity_override<'de, D: Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + let value = Option::::deserialize(deserializer)?; + Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT)) } impl AppConfig { + /// The `x-pm-appversion` sent to Proton: the user's override, or + /// [`APP_VERSION`]. + pub fn resolved_app_version(&self) -> &str { + self.app_version.as_deref().unwrap_or(APP_VERSION) + } + + /// The `User-Agent` sent to Proton: the user's override, or + /// [`USER_AGENT`]. + pub fn resolved_user_agent(&self) -> &str { + self.user_agent.as_deref().unwrap_or(USER_AGENT) + } + /// The effective cache budget in bytes: the user's explicit choice, or /// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset. pub fn resolved_cache_budget(&self) -> u64 { @@ -571,8 +593,8 @@ mod tests { #[test] fn test_app_config_serialization() { let config = AppConfig { - app_version: "external-drive-test-client@1.0.0".to_string(), - user_agent: "test-agent/1.0".to_string(), + app_version: Some("external-drive-test-client@1.0.0".to_string()), + user_agent: Some("test-agent/1.0".to_string()), cache_budget: Some(1234), upload_limit: Some(500_000), download_limit: None, @@ -596,8 +618,11 @@ mod tests { }; let json = serde_json::to_string(&config).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0"); - assert_eq!(decoded.user_agent, "test-agent/1.0"); + assert_eq!( + decoded.resolved_app_version(), + "external-drive-test-client@1.0.0" + ); + assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0"); assert_eq!(decoded.proton_theme, Some(true)); assert!(decoded.tray_hidden); assert!(decoded.online_map); @@ -628,8 +653,55 @@ mod tests { #[test] fn test_default_app_config() { let default_config = AppConfig::default(); - assert_eq!(default_config.app_version, APP_VERSION); - assert_eq!(default_config.user_agent, USER_AGENT); + assert_eq!(default_config.resolved_app_version(), APP_VERSION); + assert_eq!(default_config.resolved_user_agent(), USER_AGENT); + } + + #[test] + fn the_client_identity_follows_the_release() { + let version = env!("CARGO_PKG_VERSION"); + assert_eq!( + APP_VERSION, + format!("external-drive-linux@{version}-stable") + ); + assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}")); + } + + #[test] + fn an_old_config_with_the_fixed_identity_follows_the_release() { + // Every config written up to 3.1.0 holds these two values, so they must + // not pin the identity once Proton retires 0.1.0. + let json = format!( + r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"# + ); + let decoded: AppConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.resolved_app_version(), APP_VERSION); + assert_eq!(decoded.resolved_user_agent(), USER_AGENT); + assert!(decoded.tray_hidden, "the rest of the config survives"); + + let saved = serde_json::to_string(&decoded).unwrap(); + assert!(!saved.contains("app_version"), "{saved}"); + assert!(!saved.contains("user_agent"), "{saved}"); + } + + #[test] + fn a_hand_set_client_identity_is_kept() { + let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#; + let decoded: AppConfig = serde_json::from_str(json).unwrap(); + assert_eq!( + decoded.resolved_app_version(), + "external-drive-linux@9.9.9-beta" + ); + assert_eq!(decoded.resolved_user_agent(), "custom/1"); + let saved = serde_json::to_string(&decoded).unwrap(); + assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}"); + } + + #[test] + fn a_config_without_a_client_identity_loads() { + let decoded: AppConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(decoded.resolved_app_version(), APP_VERSION); + assert_eq!(decoded.resolved_user_agent(), USER_AGENT); } #[test] diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 0058a1f..e2b8a16 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -1210,7 +1210,7 @@ fn install_window_actions(ui: &Rc, window: &adw::ApplicationWindow) { let dialog = adw::AboutDialog::builder() .application_name(gettext("Proton Drive for Linux")) .application_icon("io.narl.proton-drive-linux") - .version(pdfs_core::config::APP_VERSION) + .version(env!("CARGO_PKG_VERSION")) .developer_name("Nils Pukropp") .website("https://proton-drive.narl.io") .issue_url("https://github.com/narrrl/proton-drive-linux/issues") @@ -1350,8 +1350,8 @@ fn debug_info(ui: &Rc) -> String { format!( "App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\ Mountpoint: {}\nControl socket: {}\nMount service running: {}\n", - pdfs_core::config::APP_VERSION, - pdfs_core::config::USER_AGENT, + config.resolved_app_version(), + config.resolved_user_agent(), adw::major_version(), adw::minor_version(), adw::micro_version(), diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 20703d6..7b74336 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -89,7 +89,11 @@ fix it. | `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) | | `prompt` | object | built-in window | — | See [`prompt`](#prompt) | -`app_version` and `user_agent` identify the client to Proton. Leave them alone. +`app_version` and `user_agent` identify the client to Proton. When the keys are absent, the +client sends its own release (`external-drive-linux@-stable`, `proton-drive-linux/`). +Leave them out unless a support case asks for a different value. The fixed +`external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0` that releases up to 3.1.0 wrote +here are treated as absent and removed on the next save. ### `ignore_patterns`