feat(#2035): auto-discover dashboard-provisioned matrix accounts via token+homeserver sidecar

This commit is contained in:
damocles 2026-06-27 00:11:13 +02:00 committed by mara
commit 3b0a914487
6 changed files with 112 additions and 11 deletions

View file

@ -78,14 +78,13 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
state_dir: paths::matrix_state_dir(),
homeserver: None,
};
let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") else {
return Ok(vec![hive]);
};
let extras: Vec<AccountCfg> = serde_json::from_str(&raw.to_string_lossy())
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
let mut accounts = Vec::with_capacity(extras.len() + 1);
accounts.push(hive);
accounts.extend(extras);
let mut accounts = vec![hive];
if let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") {
let extras: Vec<AccountCfg> = serde_json::from_str(&raw.to_string_lossy())
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
accounts.extend(extras);
}
// Reject duplicate names among the *configured* set (hive + extras).
let mut seen = std::collections::HashSet::new();
for a in &accounts {
if !seen.insert(a.name.as_str()) {
@ -95,9 +94,84 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
);
}
}
// Append dashboard-provisioned accounts (a `matrix-token-<name>` file +
// its `matrix-account-<name>.json` homeserver sidecar) that aren't
// already declared in config, so an account logged in via the dashboard
// form works without a `matrixAccounts` edit + rebuild. Explicit config
// wins on name collision.
let configured: std::collections::HashSet<String> =
accounts.iter().map(|a| a.name.clone()).collect();
for disc in discover_token_accounts() {
if !configured.contains(&disc.name) {
accounts.push(disc);
}
}
Ok(accounts)
}
/// Scan the agent state dir for extra matrix accounts provisioned via the
/// dashboard login form: each is a `matrix-token-<name>` file plus a
/// `matrix-account-<name>.json` sidecar carrying the homeserver. Returns one
/// [`AccountCfg`] per discovered account that has BOTH files — a token
/// without a sidecar is skipped, because the homeserver is then unknown and
/// defaulting to the hive homeserver would be wrong for an external account.
/// Best-effort: an unreadable dir or malformed sidecar yields fewer
/// accounts, never an error — explicit `HIVE_MATRIX_ACCOUNTS` config stays
/// authoritative.
fn discover_token_accounts() -> Vec<AccountCfg> {
let token_path = paths::token_file();
let Some(state_dir) = token_path.parent() else {
return Vec::new();
};
let Ok(rd) = std::fs::read_dir(state_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let fname = entry.file_name();
let Some(fname) = fname.to_str() else {
continue;
};
// `matrix-token` (no suffix) is the hive account, handled separately;
// only `matrix-token-<name>` files are extra accounts.
let Some(name) = fname.strip_prefix("matrix-token-") else {
continue;
};
if name.is_empty() {
continue;
}
let sidecar = state_dir.join(format!("matrix-account-{name}.json"));
let Some(homeserver) = read_account_homeserver(&sidecar) else {
tracing::warn!(
account = name,
sidecar = %sidecar.display(),
"matrix: discovered token but no homeserver sidecar; skipping account \
(re-login via the dashboard to write it)"
);
continue;
};
out.push(AccountCfg {
name: name.to_owned(),
token_file: entry.path(),
state_dir: state_dir.join(format!("matrix-sdk-state-{name}")),
homeserver: Some(homeserver),
});
}
out
}
/// Read `{"homeserver": "<url>"}` from a sidecar file. `None` when the file
/// is missing, unreadable, not valid JSON, or the `homeserver` field is
/// absent / empty.
fn read_account_homeserver(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
json.get("homeserver")?
.as_str()
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
}
/// Live status of one matrix account, as reported by [`Registry::list`]
/// (the `list_accounts` daemon op). Only accounts that successfully
/// restored a session appear, so `live` is always `true` today; the