Compare commits

...

15 Commits

Author SHA1 Message Date
holger krekel
d25fa95ffb refactor: treat accounts and transports the same in terms of gathering version information 2026-08-13 12:26:48 +02:00
B. Petersen
793ee26723 update app_version together with turn, on a regular base 2026-08-13 11:33:13 +02:00
B. Petersen
3cd051a91e fix test_get_app_versions() 2026-08-13 11:21:10 +02:00
B. Petersen
4f93d237ec repeat security note in jsonrpc 2026-08-13 10:57:12 +02:00
B. Petersen
340cb9c34c a relay serving broken json should not block discovery of others 2026-08-12 11:32:59 +02:00
biørn
3b8aca042d Update src/appversions.rs
Co-authored-by: holger krekel  <holger@merlinux.eu>
2026-08-12 06:25:53 +02:00
biørn
1a69e34cc8 Update src/appversions.rs
Co-authored-by: holger krekel  <holger@merlinux.eu>
2026-08-12 06:25:36 +02:00
biørn
c955d8b34a Update deltachat-jsonrpc/src/api.rs
Co-authored-by: holger krekel  <holger@merlinux.eu>
2026-08-12 06:24:26 +02:00
biørn
8ffab367ed Update deltachat-jsonrpc/src/api/types/appversions.rs
Co-authored-by: holger krekel  <holger@merlinux.eu>
2026-08-12 06:24:06 +02:00
B. Petersen
a075f17252 make clippy happy 2026-08-11 18:30:58 +02:00
B. Petersen
f9b58732b6 get_app_version() iterates over all profiles and relays 2026-08-11 18:20:03 +02:00
B. Petersen
6a4d08d2e3 unify AppSource and source_id 2026-08-11 15:39:27 +02:00
B. Petersen
4c37ac4565 let relay know we want 'appversions' data 2026-08-11 15:31:16 +02:00
B. Petersen
a636f3a36e UI only needs concrete client/source info 2026-08-11 15:27:16 +02:00
B. Petersen
ee7bfb7937 feat: client version information 2026-08-11 14:45:42 +02:00
6 changed files with 413 additions and 2 deletions

View File

@@ -64,6 +64,7 @@ use self::types::{
JsonrpcMessageListItem, MessageNotificationInfo, MessageSearchResult, MessageViewtype,
},
};
use crate::api::types::appversions::JsonrpcAppSource;
use crate::api::types::chat_list::{ChatListItemFetchResult, get_chat_list_item_by_id};
use crate::api::types::login_param::TransportListEntry;
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
@@ -2789,6 +2790,39 @@ impl CommandApi {
Err(anyhow!("chat with id {chat_id} doesn't have draft message"))
}
}
/// Get version information of a specific client and source
/// across all configured accounts and transports.
///
/// Returns the source with the highest `version_integer`.
/// If no matching version information is available at all, `None` is returned.
///
/// UIs shall call the function after a reasonable time after app start,
/// when most relays have reported the information they have, say 30 seconds.
/// After that, once a day.
/// (it is accepted if by the simple approach an update message is delayed.
/// an event was considered, but that seemed more complex for few benefit:
/// as we do not know if "late" relays will report "better" versions,
/// also there we would work with timeouts etc.)
///
/// If the reported `version_integer` is larger than the running app version,
/// the UI shall report to the user, that an update is available,
/// and, if possible, offer a direct update by the given URL.
///
/// Security note: consumers need to verify themselves
/// that downloaded app files are valid before installing them.
async fn get_app_version(
&self,
client_id: String,
source_id: String,
) -> Result<Option<JsonrpcAppSource>> {
let accounts = self.accounts.read().await;
Ok(
deltachat::appversions::get_app_version(&accounts, &client_id, &source_id)
.await?
.map(JsonrpcAppSource::from_core_type),
)
}
}
// Helper functions (to prevent code duplication)

View File

@@ -0,0 +1,29 @@
use deltachat::appversions::AppSource;
use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef;
/// Version information of a single source of a client, eg. "gplay" or "fdroid".
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "AppSource", rename_all = "camelCase")]
pub struct JsonrpcAppSource {
/// Always increasing version number.
pub version_integer: u32,
/// Any version string.
pub version_string: String,
/// Where to download that version.
/// Security note: consumers need to verify themselves
/// that downloaded app files are valid before installing them.
pub download_url: String,
}
impl JsonrpcAppSource {
pub fn from_core_type(source: AppSource) -> Self {
JsonrpcAppSource {
version_integer: source.version_integer,
version_string: source.version_string,
download_url: source.download_url,
}
}
}

View File

@@ -1,4 +1,5 @@
pub mod account;
pub mod appversions;
pub mod calls;
pub mod chat;
pub mod chat_list;

327
src/appversions.rs Normal file
View File

@@ -0,0 +1,327 @@
//! Get version information of clients.
//!
//! Used by clients to inform about updates.
//! The version information comes in via IMAP METADATA,
//! (as JSON) and is parsed to `AppVersionInfo`.
use crate::accounts::Accounts;
use crate::context::Context;
use crate::log::warn;
use anyhow::Result;
use serde::{Deserialize, Serialize};
/// Version information of clients as used on the wire.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct AppVersionInfo {
/// Array of clients with version information.
clients: Vec<AppClient>,
}
/// Version information of a single client, eg. "deltachat" or "ubuntutouch".
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct AppClient {
/// ID how the client identifies itself, eg. "deltachat" or "ubuntutouch"
client_id: String,
/// Array of sources for that client.
sources: Vec<AppSource>,
}
/// Version information of a single source of a client, eg. "gplay" or "fdroid".
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AppSource {
/// ID how the client identifies a source, eg. "gplay" or "fdroid"
source_id: String,
/// Always increasing version number.
pub version_integer: u32,
/// Any version string.
pub version_string: String,
/// Where to download that version.
pub download_url: String,
}
/// Returns version information from every relay of every profile.
///
/// Another transport of the same profile and another profile
/// are the same thing for version information,
/// so they are flattened into a single list.
async fn all_app_versions(accounts: &Accounts) -> Vec<(Context, String)> {
let mut result = Vec::new();
for account_id in accounts.get_all() {
let Some(context) = accounts.get_account(account_id) else {
continue;
};
for metadata in context.metadata.read().await.values() {
if let Some(json) = &metadata.app_versions {
result.push((context.clone(), json.clone()));
}
}
}
result
}
/// Get version information of a specific client and source.
///
/// Iterates over all accounts and all transports,
/// checking version information set by IMAP METADATA,
/// and returns the source with the highest `version_integer`.
///
/// If no matching version information is available at all, `None` is returned.
pub async fn get_app_version(
accounts: &Accounts,
client_id: &str,
source_id: &str,
) -> Result<Option<AppSource>> {
let mut best: Option<AppSource> = None;
for (context, json) in all_app_versions(accounts).await {
let app_versions: AppVersionInfo = match serde_json::from_str(&json) {
Ok(app_versions) => app_versions,
Err(err) => {
warn!(context, "Failed to parse appversions: {err:#}.");
continue;
}
};
let candidate = app_versions
.clients
.into_iter()
.find(|c| c.client_id == client_id)
.and_then(|c| c.sources.into_iter().find(|s| s.source_id == source_id));
if let Some(candidate) = candidate
&& best
.as_ref()
.is_none_or(|b| candidate.version_integer > b.version_integer)
{
best = Some(candidate);
}
}
Ok(best)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_app_version_info_deserialize() -> Result<()> {
let json = r##"{
"clients": [
{
"clientId": "deltachat",
"sources": [
{
"sourceId": "gplay",
"versionInteger": 754,
"versionString": "2.57.0",
"downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.57.0/deltachat-gplay-release-2.57.0.apk"
}
]
}
]
}"##;
let versions: AppVersionInfo = serde_json::from_str(json)?;
assert_eq!(versions.clients.len(), 1);
assert_eq!(versions.clients[0].client_id, "deltachat");
assert_eq!(versions.clients[0].sources.len(), 1);
assert_eq!(versions.clients[0].sources[0].source_id, "gplay");
assert_eq!(versions.clients[0].sources[0].version_integer, 754);
assert_eq!(versions.clients[0].sources[0].version_string, "2.57.0");
assert_eq!(
versions.clients[0].sources[0].download_url,
"https://github.com/deltachat/deltachat-android/releases/download/v2.57.0/deltachat-gplay-release-2.57.0.apk"
);
// missing fields are set to defaults, additional fields are ignored, errors are errors
let json = r##"{
"clients": [
{
"clientId": "deltachat",
"xsource": "bang"
}
],
"foo": "bar"
}"##;
let versions: AppVersionInfo = serde_json::from_str(json)?;
assert_eq!(versions.clients.len(), 1);
assert_eq!(versions.clients[0].client_id, "deltachat");
assert_eq!(versions.clients[0].sources.len(), 0);
let json = "{}";
let versions: AppVersionInfo = serde_json::from_str(json)?;
assert_eq!(versions.clients.len(), 0);
let json = "";
assert!(serde_json::from_str::<AppVersionInfo>(json).is_err());
let json = "bad json";
assert!(serde_json::from_str::<AppVersionInfo>(json).is_err());
Ok(())
}
async fn mockup_app_versions(accounts: &Accounts, account_id: u32, json: &str) {
let context = accounts.get_account(account_id).expect("account exists");
let mut metadata = context.metadata.write().await;
let transport_id = 1;
metadata.entry(transport_id).or_default().app_versions = Some(json.to_string());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_app_versions() -> Result<()> {
let dir = tempfile::tempdir().unwrap();
let p: PathBuf = dir.path().join("accounts");
let writable = true;
let mut accounts = Accounts::new(p.clone(), writable).await.unwrap();
// no accounts configured, means no versions are reported
let version = get_app_version(&accounts, "non-", "existant").await?;
assert!(version.is_none());
// first account reports two clients, with one and two sources
let account_id1 = accounts.add_account().await?;
let json = r##"{
"clients": [
{
"clientId": "basta",
"sources": [
{
"sourceId": "web",
"versionInteger": 754,
"versionString": "2.57.0",
"downloadUrl": "https://example.org/basta-2.57.0.apk"
}
]
},
{
"clientId": "foo",
"sources": [
{
"sourceId": "bar",
"versionInteger": 42,
"versionString": "42.0",
"downloadUrl": "https://foo.bar/42.0.prg"
},
{
"sourceId": "baz",
"versionInteger": 1337,
"versionString": "13.37",
"downloadUrl": "https://dl.org/1337.acc"
}
]
}
]
}"##;
mockup_app_versions(&accounts, account_id1, json).await;
let version = get_app_version(&accounts, "basta", "web").await?.unwrap();
assert_eq!(version.version_integer, 754);
assert_eq!(version.version_string, "2.57.0");
assert_eq!(version.download_url, "https://example.org/basta-2.57.0.apk");
let version = get_app_version(&accounts, "foo", "bar").await?.unwrap();
assert_eq!(version.version_integer, 42);
assert_eq!(version.version_string, "42.0");
assert_eq!(version.download_url, "https://foo.bar/42.0.prg");
let version = get_app_version(&accounts, "foo", "baz").await?.unwrap();
assert_eq!(version.version_integer, 1337);
assert_eq!(version.version_string, "13.37");
assert_eq!(version.download_url, "https://dl.org/1337.acc");
let version = get_app_version(&accounts, "non-", "existant").await?;
assert!(version.is_none());
// a second account reports a newer version for "bar"
let account_id2 = accounts.add_account().await?;
let json = r##"{
"clients": [
{
"clientId": "foo",
"sources": [
{
"sourceId": "bar",
"versionInteger": 43,
"versionString": "43.0",
"downloadUrl": "https://foo.bar/43.0-is-newer.prg"
}
]
}
]
}"##;
mockup_app_versions(&accounts, account_id2, json).await;
let version = get_app_version(&accounts, "basta", "web").await?.unwrap();
assert_eq!(version.version_integer, 754);
assert_eq!(version.version_string, "2.57.0");
assert_eq!(version.download_url, "https://example.org/basta-2.57.0.apk");
let version = get_app_version(&accounts, "foo", "bar").await?.unwrap();
assert_eq!(version.version_integer, 43);
assert_eq!(version.version_string, "43.0");
assert_eq!(version.download_url, "https://foo.bar/43.0-is-newer.prg");
let version = get_app_version(&accounts, "foo", "baz").await?.unwrap();
assert_eq!(version.version_integer, 1337);
assert_eq!(version.version_string, "13.37");
assert_eq!(version.download_url, "https://dl.org/1337.acc");
let version = get_app_version(&accounts, "non-", "existant").await?;
assert!(version.is_none());
// a third account reports a older version for "bar", that is ignored
let account_id3 = accounts.add_account().await?;
let json = r##"{
"clients": [
{
"clientId": "foo",
"sources": [
{
"sourceId": "bar",
"versionInteger": 39,
"versionString": "39.0",
"downloadUrl": "https://foo.bar/39.0-is-too-old.prg"
}
]
}
]
}"##;
mockup_app_versions(&accounts, account_id3, json).await;
let version = get_app_version(&accounts, "basta", "web").await?.unwrap();
assert_eq!(version.version_integer, 754);
assert_eq!(version.version_string, "2.57.0");
assert_eq!(version.download_url, "https://example.org/basta-2.57.0.apk");
let version = get_app_version(&accounts, "foo", "bar").await?.unwrap();
assert_eq!(version.version_integer, 43);
assert_eq!(version.version_string, "43.0");
assert_eq!(version.download_url, "https://foo.bar/43.0-is-newer.prg");
let version = get_app_version(&accounts, "foo", "baz").await?.unwrap();
assert_eq!(version.version_integer, 1337);
assert_eq!(version.version_string, "13.37");
assert_eq!(version.download_url, "https://dl.org/1337.acc");
let version = get_app_version(&accounts, "non-", "existant").await?;
assert!(version.is_none());
// second account returns an invalid json, that account is skipped, but app versionss are still gathered from the other
let json = r"bad json!";
mockup_app_versions(&accounts, account_id2, json).await;
let version = get_app_version(&accounts, "foo", "bar").await?.unwrap();
assert_eq!(version.version_integer, 42);
assert_eq!(version.version_string, "42.0");
assert_eq!(version.download_url, "https://foo.bar/42.0.prg");
Ok(())
}
}

View File

@@ -140,6 +140,10 @@ pub(crate) struct ServerMetadata {
/// should be fetched from the server
/// to be ready for WebRTC calls.
pub ice_servers_expiration_timestamp: i64,
/// App versions, as raw JSON string.
/// Consumed by get_app_versions().
pub app_versions: Option<String>,
}
struct UidGrouper<T: Iterator<Item = (i64, u32, String)>> {
@@ -1293,6 +1297,10 @@ impl Session {
let now = time();
// Refresh TURN server credentials if they expire in 12 hours.
//
// Moreover, Take the chance to update `app_versions` as well.
// As best effort, even checking every some days is good enough -
// and saves one additional time get get_metadata() call.
if now + 3600 * 12 < old_metadata.ice_servers_expiration_timestamp {
return Ok(());
}
@@ -1303,7 +1311,11 @@ impl Session {
let mailbox = "";
let options = "";
let metadata = self
.get_metadata(mailbox, options, "(/shared/vendor/deltachat/turn)")
.get_metadata(
mailbox,
options,
"(/shared/vendor/deltachat/turn /shared/vendor/deltachat/appversions)",
)
.await?;
for m in metadata {
if m.entry == "/shared/vendor/deltachat/turn"
@@ -1319,6 +1331,8 @@ impl Session {
warn!(context, "Failed to parse TURN server metadata: {err:#}.");
}
}
} else if m.entry == "/shared/vendor/deltachat/appversions" {
old_metadata.app_versions = m.value;
}
}
}
@@ -1342,6 +1356,7 @@ impl Session {
let mut max_smtp_rcpt_to = None;
let mut ice_servers = None;
let mut ice_servers_expiration_timestamp = 0;
let mut app_versions = None;
let mailbox = "";
let options = "";
@@ -1349,7 +1364,7 @@ impl Session {
.get_metadata(
mailbox,
options,
"(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay /shared/vendor/deltachat/turn /shared/vendor/deltachat/maxsmtprecipients)",
"(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay /shared/vendor/deltachat/turn /shared/vendor/deltachat/maxsmtprecipients /shared/vendor/deltachat/appversions)",
)
.await?;
for m in metadata {
@@ -1397,6 +1412,9 @@ impl Session {
}
}
}
"/shared/vendor/deltachat/appversions" => {
app_versions = m.value;
}
_ => {}
}
}
@@ -1418,6 +1436,7 @@ impl Session {
supports_push: max_smtp_rcpt_to.is_some() || self.capabilities.has_xdeltapush,
ice_servers,
ice_servers_expiration_timestamp,
app_versions,
},
);
Ok(())

View File

@@ -54,6 +54,7 @@ pub(crate) mod events;
pub use events::*;
mod aheader;
pub mod appversions;
mod automatic_relay_management;
pub mod blob;
pub mod calls;