Move command logic from Tauri handlers into radicle-types

Auth, inbox, CLI detection and cache rebuilds lived only in the Tauri
commands, so test-http-api couldn't share them and stubbed them instead.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-01 10:20:19 +02:00
parent a99631e611
commit aed733ade6
12 changed files with 412 additions and 335 deletions
+3 -77
View File
@@ -1,11 +1,5 @@
use std::str::FromStr;
use radicle::crypto::Seed;
use radicle::crypto::ssh::{self, Passphrase};
use radicle::node::Alias;
use radicle::profile::env;
use radicle::crypto::ssh::Passphrase;
use radicle_types::error::Error;
use ssh_key::rand_core::{OsRng, RngCore};
use crate::AppState;
@@ -14,78 +8,10 @@ pub fn authenticate(
ctx: tauri::State<AppState>,
passphrase: Option<Passphrase>,
) -> Result<(), Error> {
let profile = &ctx.profile;
if !profile.keystore.is_encrypted()? {
return Ok(());
}
match ssh::agent::Agent::connect() {
Ok(mut agent) => {
if agent.request_identities()?.contains(&profile.public_key) {
return Ok(());
}
match passphrase {
Some(passphrase) => {
profile.keystore.secret_key(Some(passphrase.clone()))?;
register(&mut agent, profile, passphrase)
}
None => Err(Error::Crypto(
radicle::crypto::ssh::keystore::Error::PassphraseMissing,
)),
}
}
Err(e) if e.is_not_running() => Err(Error::AgentNotRunning)?,
Err(e) => Err(e)?,
}
radicle_types::auth::authenticate(&ctx.profile, passphrase)
}
#[tauri::command]
pub(crate) fn init(alias: String, passphrase: Passphrase) -> Result<(), Error> {
let home = radicle::profile::home()?;
let alias = Alias::from_str(&alias)?;
if passphrase.is_empty() {
return Err(Error::Crypto(
radicle::crypto::ssh::keystore::Error::PassphraseMissing,
));
}
let seed = env::seed().unwrap_or_else(|| {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
Seed::new(bytes)
});
let profile = radicle::Profile::init(home, alias, Some(passphrase.clone()), seed)?;
match ssh::agent::Agent::connect() {
Ok(mut agent) => register(&mut agent, &profile, passphrase.clone())?,
Err(e) if e.is_not_running() => return Err(Error::AgentNotRunning),
Err(e) => Err(e)?,
}
Ok(())
}
pub fn register(
agent: &mut ssh::agent::Agent,
profile: &radicle::Profile,
passphrase: ssh::Passphrase,
) -> Result<(), Error> {
let secret = profile
.keystore
.secret_key(Some(passphrase))
.map_err(|e| {
if e.is_crypto_err() {
Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
ssh_key::Error::Crypto,
))
} else {
e.into()
}
})?
.ok_or(Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
ssh_key::Error::Crypto,
)))?;
agent.register(&secret)?;
Ok(())
radicle_types::auth::init(alias, passphrase)
}
+1 -29
View File
@@ -1,8 +1,5 @@
use std::ops::ControlFlow;
use radicle::git;
use radicle::identity;
use radicle::storage::ReadStorage;
use radicle::issue::TYPENAME;
use radicle_types as types;
@@ -97,30 +94,5 @@ pub async fn rebuild_issue_cache(
rid: identity::RepoId,
on_event: tauri::ipc::Channel<types::cobs::CacheEvent>,
) -> Result<(), Error> {
let repo = ctx.profile.storage.repository(rid)?;
let signer = ctx.profile.signer()?;
let mut issues = ctx.profile.issues_mut(&repo, &signer)?;
on_event.send(types::cobs::CacheEvent::Started { rid })?;
issues.write_all(|result, progress| {
match result {
Ok((id, _)) => {
if on_event
.send(types::cobs::CacheEvent::Progress {
rid,
oid: **id,
current: progress.current(),
total: progress.total(),
})
.is_err()
{
log::error!("Failed to send progress");
}
}
Err(err) => log::warn!("Failed to retrieve issue: {err}"),
};
ControlFlow::Continue(())
})?;
on_event.send(types::cobs::CacheEvent::Finished { rid })?;
Ok(())
ctx.rebuild_issue_cache(rid, |event| on_event.send(event).map_err(Error::from))
}
+1 -28
View File
@@ -1,5 +1,3 @@
use std::ops::ControlFlow;
use radicle::patch::{ReviewId, TYPENAME};
use radicle::storage::{ReadRepository as _, ReadStorage};
use radicle::{git, identity};
@@ -97,30 +95,5 @@ pub async fn rebuild_patch_cache(
rid: identity::RepoId,
on_event: tauri::ipc::Channel<cobs::CacheEvent>,
) -> Result<(), Error> {
let repo = ctx.profile.storage.repository(rid)?;
let signer = ctx.profile.signer()?;
let mut patches = ctx.profile.patches_mut(&repo, &signer)?;
on_event.send(types::cobs::CacheEvent::Started { rid })?;
patches.write_all(|result, progress| {
match result {
Ok((id, _)) => {
if on_event
.send(cobs::CacheEvent::Progress {
rid,
oid: **id,
current: progress.current(),
total: progress.total(),
})
.is_err()
{
log::error!("Failed to send progress");
}
}
Err(err) => log::warn!("Failed to retrieve patch: {err}"),
};
ControlFlow::Continue(())
})?;
on_event.send(types::cobs::CacheEvent::Finished { rid })?;
Ok(())
ctx.rebuild_patch_cache(rid, |event| on_event.send(event).map_err(Error::from))
}
+9 -185
View File
@@ -1,15 +1,9 @@
use radicle::identity;
use radicle::issue::cache::Issues;
use radicle::node;
use radicle::patch::cache::Patches;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle_types::AppState;
use radicle_types::domain::inbox::models::notification::{self, RepoGroupByItem};
use radicle_types::domain::inbox::models::notification;
use radicle_types::domain::inbox::service::Service;
use radicle_types::domain::inbox::traits::InboxService;
use radicle_types::error::Error;
use radicle_types::outbound::sqlite::Sqlite;
use radicle_types::traits::inbox::Inbox;
#[tauri::command]
pub fn list_notifications(
@@ -17,177 +11,15 @@ pub fn list_notifications(
sqlite_service: tauri::State<Service<Sqlite>>,
params: notification::RepoGroupParams,
) -> Result<notification::NotificationsByRepoList, Error> {
let profile = &ctx.profile;
let aliases = profile.aliases();
let repos_with_groups = sqlite_service.repo_group(params.clone())?;
let mut repo_counts = std::collections::HashMap::new();
for (repo_id, count) in (sqlite_service.counts_by_repo()?).flatten() {
repo_counts.insert(repo_id, count);
}
let take = if params.all.unwrap_or(false) {
usize::MAX
} else {
params.take.unwrap_or(20)
};
let mut grouped_repos = std::collections::HashMap::new();
for (repo_id, group) in repos_with_groups {
grouped_repos
.entry(repo_id)
.or_insert_with(Vec::new)
.extend(group);
}
let mut result = Vec::new();
for (repo_id, all) in grouped_repos {
let repo = match profile.storage.repository(repo_id) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to open repository {}: {}", repo_id, e);
continue;
}
};
let name = match repo.identity_doc() {
Ok(identity::DocAt { doc, .. }) => match doc.project() {
Ok(project) => project.name().to_string(),
Err(_) => format!("Unknown project in {}", repo_id),
},
Err(_) => format!("Unknown project in {}", repo_id),
};
let patches = match profile.patches(&repo) {
Ok(p) => p,
Err(e) => {
log::error!("Failed to get patches for {}: {}", repo_id, e);
continue;
}
};
let issues = match profile.issues(&repo) {
Ok(i) => i,
Err(e) => {
log::error!("Failed to get issues for {}: {}", repo_id, e);
continue;
}
};
let content = all
.into_iter()
.take(take)
.map(|(qualified, n)| {
n.into_iter()
.filter_map(|s| {
let update: notification::RefUpdate =
(qualified.clone().into_refstring(), s.new, s.old).into();
let update: radicle::storage::RefUpdate = update.into();
let kind =
node::notifications::NotificationKind::try_from(qualified.clone())
.ok()?;
match kind {
node::notifications::NotificationKind::Cob { ref typed_id } => {
if typed_id.is_patch() {
let actions = notification::actions(
typed_id.type_name.clone(),
typed_id.id,
update.old(),
update.new(),
&repo,
&aliases,
)
.unwrap_or_default();
match patches.get(&typed_id.id) {
Ok(Some(p)) => Some(notification::NotificationItem::Patch(
notification::Patch {
row_id: s.row_id,
id: typed_id.id,
update: update.into(),
timestamp: s.timestamp,
title: p.title().to_string(),
status: (p.state().clone()).into(),
actions,
repo_id: Some(repo_id),
},
)),
Ok(None) => {
log::error!("No patch found");
None
}
Err(e) => {
log::error!("{}", e);
None
}
}
} else if typed_id.is_issue() {
let actions = notification::actions(
typed_id.type_name.clone(),
typed_id.id,
update.old(),
update.new(),
&repo,
&aliases,
)
.unwrap_or_default();
match issues.get(&typed_id.id) {
Ok(Some(i)) => Some(notification::NotificationItem::Issue(
notification::Issue {
row_id: s.row_id,
id: typed_id.id,
update: update.into(),
timestamp: s.timestamp,
title: i.title().to_string(),
status: (*i.state()).into(),
actions,
repo_id: Some(repo_id),
},
)),
Ok(None) => {
log::error!("No issue found");
None
}
Err(e) => {
log::error!("{}", e);
None
}
}
} else {
None
}
}
_ => None,
}
})
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
.collect::<RepoGroupByItem>();
if !content.is_empty() {
let count = repo_counts
.get(&repo_id)
.copied()
.unwrap_or_else(|| content.iter().map(|items| items.len()).sum());
result.push(notification::NotificationsByRepo {
rid: repo_id,
name,
notifications: content,
count,
});
}
}
Ok(result)
ctx.list_notifications(&sqlite_service, params)
}
#[tauri::command]
pub fn notification_count(inbox: tauri::State<Service<Sqlite>>) -> Result<usize, Error> {
inbox.notification_count().map_err(Error::from)
pub fn notification_count(
ctx: tauri::State<AppState>,
inbox: tauri::State<Service<Sqlite>>,
) -> Result<usize, Error> {
ctx.notification_count(&inbox)
}
#[tauri::command]
@@ -195,13 +27,5 @@ pub fn clear_notifications(
ctx: tauri::State<AppState>,
params: notification::SetStatusNotifications,
) -> Result<(), Error> {
let profile = &ctx.profile;
let mut notifications = profile.notifications_mut()?;
match params {
notification::SetStatusNotifications::Ids(ids) => notifications.clear(&ids)?,
notification::SetStatusNotifications::Repo(repo) => notifications.clear_by_repo(&repo)?,
notification::SetStatusNotifications::All => notifications.clear_all()?,
};
Ok(())
ctx.clear_notifications(params)
}
+4 -16
View File
@@ -19,17 +19,7 @@ pub(crate) fn version(app: AppHandle) -> Result<Version, Error> {
#[tauri::command]
pub(crate) fn check_radicle_cli(ctx: tauri::State<AppState>) -> Result<(), Error> {
// Where the official installer puts it, even if it never reached `PATH`.
let rad = ctx.profile().home().path().join("bin").join("rad");
if rad.exists() {
return Ok(());
}
if radicle_types::binaries::rad().is_some() {
return Ok(());
}
Err(Error::RadicleNotInstalled)
radicle_types::binaries::check_radicle_cli(&ctx.profile())
}
#[tauri::command]
@@ -40,10 +30,9 @@ pub(crate) fn git_info() -> GitInfo {
#[tauri::command]
pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {
let profile = radicle::Profile::load()?;
let home = profile.home();
let cobs_cache = radicle::cob::cache::Store::open(home.cobs().join(COBS_DB_FILE))?;
cobs_cache.check_version()?;
let state = AppState { profile };
state.check_cobs_cache()?;
let profile = &state.profile;
let inbox_db = radicle_types::outbound::sqlite::Sqlite::reader(
profile.node().join(NOTIFICATIONS_DB_FILE),
@@ -70,7 +59,6 @@ pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {
}
});
let state = AppState { profile };
app.manage(state.clone());
Ok(state.config())
+86
View File
@@ -0,0 +1,86 @@
use std::str::FromStr;
use crate::error::Error;
use radicle::crypto::Seed;
use radicle::crypto::ssh::{self, Passphrase};
use radicle::node::Alias;
use radicle::profile::env;
use ssh_key::rand_core::{OsRng, RngCore};
pub fn authenticate(
profile: &radicle::Profile,
passphrase: Option<Passphrase>,
) -> Result<(), Error> {
if !profile.keystore.is_encrypted()? {
return Ok(());
}
match ssh::agent::Agent::connect() {
Ok(mut agent) => {
if agent.request_identities()?.contains(&profile.public_key) {
return Ok(());
}
match passphrase {
Some(passphrase) => {
profile.keystore.secret_key(Some(passphrase.clone()))?;
register(&mut agent, profile, passphrase)
}
None => Err(Error::Crypto(
radicle::crypto::ssh::keystore::Error::PassphraseMissing,
)),
}
}
Err(e) if e.is_not_running() => Err(Error::AgentNotRunning)?,
Err(e) => Err(e)?,
}
}
pub fn init(alias: String, passphrase: Passphrase) -> Result<(), Error> {
let home = radicle::profile::home()?;
let alias = Alias::from_str(&alias)?;
if passphrase.is_empty() {
return Err(Error::Crypto(
radicle::crypto::ssh::keystore::Error::PassphraseMissing,
));
}
let seed = env::seed().unwrap_or_else(|| {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
Seed::new(bytes)
});
let profile = radicle::Profile::init(home, alias, Some(passphrase.clone()), seed)?;
match ssh::agent::Agent::connect() {
Ok(mut agent) => register(&mut agent, &profile, passphrase.clone())?,
Err(e) if e.is_not_running() => return Err(Error::AgentNotRunning),
Err(e) => Err(e)?,
}
Ok(())
}
pub fn register(
agent: &mut ssh::agent::Agent,
profile: &radicle::Profile,
passphrase: ssh::Passphrase,
) -> Result<(), Error> {
let secret = profile
.keystore
.secret_key(Some(passphrase))
.map_err(|e| {
if e.is_crypto_err() {
Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
ssh_key::Error::Crypto,
))
} else {
e.into()
}
})?
.ok_or(Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
ssh_key::Error::Crypto,
)))?;
agent.register(&secret)?;
Ok(())
}
+8
View File
@@ -104,6 +104,14 @@ pub fn git_command() -> Option<std::process::Command> {
Some(command)
}
pub fn check_radicle_cli(profile: &radicle::Profile) -> Result<(), crate::error::Error> {
if profile.home().path().join("bin").join("rad").exists() || rad().is_some() {
return Ok(());
}
Err(crate::error::Error::RadicleNotInstalled)
}
/// The `rad` CLI, if it is installed.
///
/// Uncached: the app polls for it so that installing the CLI while the app is
+3
View File
@@ -1,12 +1,14 @@
use traits::Profile;
use traits::cobs::Cobs;
use traits::identity::Identity;
use traits::inbox::Inbox;
use traits::issue::{Issues, IssuesMut};
use traits::job::Jobs;
use traits::patch::{Patches, PatchesMut};
use traits::repo::Repo;
use traits::thread::Thread;
pub mod auth;
pub mod binaries;
pub mod cobs;
pub mod config;
@@ -29,6 +31,7 @@ impl Repo for AppState {}
impl Thread for AppState {}
impl Cobs for AppState {}
impl Identity for AppState {}
impl Inbox for AppState {}
impl Issues for AppState {}
impl IssuesMut for AppState {}
impl Jobs for AppState {}
+18
View File
@@ -1,9 +1,11 @@
use radicle::node::{AliasStore, NodeId};
use crate::config::Config;
use crate::error::Error;
pub mod cobs;
pub mod identity;
pub mod inbox;
pub mod issue;
pub mod job;
pub mod patch;
@@ -25,6 +27,22 @@ pub trait Profile {
}
}
fn check_cobs_cache(&self) -> Result<(), Error> {
let p = self.profile();
let cache = radicle::cob::cache::Store::open(
p.home().cobs().join(radicle::cob::cache::COBS_DB_FILE),
)?;
cache.check_version()?;
Ok(())
}
fn node_running(&self) -> bool {
let p = self.profile();
radicle::node::Handle::is_running(&radicle::Node::new(p.home().socket_from_env()))
}
fn alias(&self, nid: NodeId) -> Option<radicle::node::Alias> {
let p = self.profile();
let aliases = p.aliases();
+213
View File
@@ -0,0 +1,213 @@
use radicle::identity;
use radicle::issue::cache::Issues;
use radicle::node;
use radicle::patch::cache::Patches;
use radicle::storage::{ReadRepository, ReadStorage};
use crate::domain::inbox::models::notification::{self, RepoGroupByItem};
use crate::domain::inbox::service::Service;
use crate::domain::inbox::traits::InboxService;
use crate::error::Error;
use crate::outbound::sqlite::Sqlite;
use crate::traits::Profile;
pub trait Inbox: Profile {
fn list_notifications(
&self,
sqlite_service: &Service<Sqlite>,
params: notification::RepoGroupParams,
) -> Result<notification::NotificationsByRepoList, Error> {
let profile = &self.profile();
let aliases = profile.aliases();
let repos_with_groups = sqlite_service.repo_group(params.clone())?;
let mut repo_counts = std::collections::HashMap::new();
for (repo_id, count) in (sqlite_service.counts_by_repo()?).flatten() {
repo_counts.insert(repo_id, count);
}
let take = if params.all.unwrap_or(false) {
usize::MAX
} else {
params.take.unwrap_or(20)
};
let mut grouped_repos = std::collections::HashMap::new();
for (repo_id, group) in repos_with_groups {
grouped_repos
.entry(repo_id)
.or_insert_with(Vec::new)
.extend(group);
}
let mut result = Vec::new();
for (repo_id, all) in grouped_repos {
let repo = match profile.storage.repository(repo_id) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to open repository {}: {}", repo_id, e);
continue;
}
};
let name = match repo.identity_doc() {
Ok(identity::DocAt { doc, .. }) => match doc.project() {
Ok(project) => project.name().to_string(),
Err(_) => format!("Unknown project in {}", repo_id),
},
Err(_) => format!("Unknown project in {}", repo_id),
};
let patches = match profile.patches(&repo) {
Ok(p) => p,
Err(e) => {
log::error!("Failed to get patches for {}: {}", repo_id, e);
continue;
}
};
let issues = match profile.issues(&repo) {
Ok(i) => i,
Err(e) => {
log::error!("Failed to get issues for {}: {}", repo_id, e);
continue;
}
};
let content = all
.into_iter()
.take(take)
.map(|(qualified, n)| {
n.into_iter()
.filter_map(|s| {
let update: notification::RefUpdate =
(qualified.clone().into_refstring(), s.new, s.old).into();
let update: radicle::storage::RefUpdate = update.into();
let kind =
node::notifications::NotificationKind::try_from(qualified.clone())
.ok()?;
match kind {
node::notifications::NotificationKind::Cob { ref typed_id } => {
if typed_id.is_patch() {
let actions = notification::actions(
typed_id.type_name.clone(),
typed_id.id,
update.old(),
update.new(),
&repo,
&aliases,
)
.unwrap_or_default();
match patches.get(&typed_id.id) {
Ok(Some(p)) => {
Some(notification::NotificationItem::Patch(
notification::Patch {
row_id: s.row_id,
id: typed_id.id,
update: update.into(),
timestamp: s.timestamp,
title: p.title().to_string(),
status: (p.state().clone()).into(),
actions,
repo_id: Some(repo_id),
},
))
}
Ok(None) => {
log::error!("No patch found");
None
}
Err(e) => {
log::error!("{}", e);
None
}
}
} else if typed_id.is_issue() {
let actions = notification::actions(
typed_id.type_name.clone(),
typed_id.id,
update.old(),
update.new(),
&repo,
&aliases,
)
.unwrap_or_default();
match issues.get(&typed_id.id) {
Ok(Some(i)) => {
Some(notification::NotificationItem::Issue(
notification::Issue {
row_id: s.row_id,
id: typed_id.id,
update: update.into(),
timestamp: s.timestamp,
title: i.title().to_string(),
status: (*i.state()).into(),
actions,
repo_id: Some(repo_id),
},
))
}
Ok(None) => {
log::error!("No issue found");
None
}
Err(e) => {
log::error!("{}", e);
None
}
}
} else {
None
}
}
_ => None,
}
})
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
.collect::<RepoGroupByItem>();
if !content.is_empty() {
let count = repo_counts
.get(&repo_id)
.copied()
.unwrap_or_else(|| content.iter().map(|items| items.len()).sum());
result.push(notification::NotificationsByRepo {
rid: repo_id,
name,
notifications: content,
count,
});
}
}
Ok(result)
}
fn notification_count(&self, inbox: &Service<Sqlite>) -> Result<usize, Error> {
inbox.notification_count().map_err(Error::from)
}
fn clear_notifications(
&self,
params: notification::SetStatusNotifications,
) -> Result<(), Error> {
let profile = &self.profile();
let mut notifications = profile.notifications_mut()?;
match params {
notification::SetStatusNotifications::Ids(ids) => notifications.clear(&ids)?,
notification::SetStatusNotifications::Repo(repo) => {
notifications.clear_by_repo(&repo)?
}
notification::SetStatusNotifications::All => notifications.clear_all()?,
};
Ok(())
}
}
+33
View File
@@ -68,6 +68,39 @@ pub trait Issues: Profile {
}
pub trait IssuesMut: Profile {
fn rebuild_issue_cache(
&self,
rid: identity::RepoId,
on_event: impl Fn(cobs::CacheEvent) -> Result<(), Error>,
) -> Result<(), Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let signer = profile.signer()?;
let mut issues = profile.issues_mut(&repo, &signer)?;
on_event(cobs::CacheEvent::Started { rid })?;
issues.write_all(|result, progress| {
match result {
Ok((id, _)) => {
if on_event(cobs::CacheEvent::Progress {
rid,
oid: **id,
current: progress.current(),
total: progress.total(),
})
.is_err()
{
log::error!("Failed to send progress");
}
}
Err(err) => log::warn!("Failed to retrieve issue: {err}"),
};
std::ops::ControlFlow::Continue(())
})?;
on_event(cobs::CacheEvent::Finished { rid })?;
Ok(())
}
fn create_issue(
&self,
rid: identity::RepoId,
+33
View File
@@ -51,6 +51,39 @@ pub trait Patches: Profile {
}
pub trait PatchesMut: Profile {
fn rebuild_patch_cache(
&self,
rid: identity::RepoId,
on_event: impl Fn(cobs::CacheEvent) -> Result<(), Error>,
) -> Result<(), Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let signer = profile.signer()?;
let mut patches = profile.patches_mut(&repo, &signer)?;
on_event(cobs::CacheEvent::Started { rid })?;
patches.write_all(|result, progress| {
match result {
Ok((id, _)) => {
if on_event(cobs::CacheEvent::Progress {
rid,
oid: **id,
current: progress.current(),
total: progress.total(),
})
.is_err()
{
log::error!("Failed to send progress");
}
}
Err(err) => log::warn!("Failed to retrieve patch: {err}"),
};
std::ops::ControlFlow::Continue(())
})?;
on_event(cobs::CacheEvent::Finished { rid })?;
Ok(())
}
/// Publish a review of a revision, together with its code comments.
fn create_patch_review(
&self,