Skip to main content

origin_tauri/
commands.rs

1//! The platform IPC surface.
2//!
3//! Commands are the only way the frontend causes anything to happen. They are thin:
4//! they resolve state, call the domain, and translate errors — no logic lives here.
5
6use crate::state::OriginState;
7use origin_app::AppInfo;
8use origin_connector::ConnectorDescriptor;
9use origin_domain::{Account, AccountId, AppError, ErrorContract, Health, Job, JobId};
10use origin_sync::{SyncStatus, SyncTarget};
11use serde::Serialize;
12use tauri::{AppHandle, State};
13use time::format_description::well_known::Rfc3339;
14
15/// Error payload for IPC.
16///
17/// Wraps [`ErrorContract`] so the frontend always receives the same shape and never a
18/// raw `rusqlite`, `reqwest` or `tauri` error (ADR-0002).
19#[derive(Debug, Serialize)]
20#[serde(transparent)]
21pub struct CommandError(ErrorContract);
22
23impl From<AppError> for CommandError {
24    fn from(error: AppError) -> Self {
25        tracing::warn!(kind = ?error.kind(), %error, "command failed");
26        Self(error.to_contract())
27    }
28}
29
30type CommandResult<T> = Result<T, CommandError>;
31
32#[tauri::command]
33pub async fn origin_app_info(
34    app: AppHandle,
35    state: State<'_, OriginState>,
36) -> CommandResult<AppInfo> {
37    let package = app.package_info();
38    Ok(AppInfo {
39        id: state.config().app_id.clone(),
40        name: package.name.clone(),
41        version: package.version.to_string(),
42        modules: state
43            .application()
44            .modules()
45            .iter()
46            .map(|module| (*module).to_owned())
47            .collect(),
48    })
49}
50
51#[tauri::command]
52pub async fn origin_setting_get(
53    state: State<'_, OriginState>,
54    key: String,
55) -> CommandResult<Option<serde_json::Value>> {
56    Ok(state
57        .application()
58        .platform()
59        .settings
60        .get_json(&key)
61        .await?)
62}
63
64#[tauri::command]
65pub async fn origin_setting_set(
66    state: State<'_, OriginState>,
67    key: String,
68    value: serde_json::Value,
69) -> CommandResult<()> {
70    state
71        .application()
72        .platform()
73        .settings
74        .set_json(&key, &value)
75        .await?;
76    Ok(())
77}
78
79#[tauri::command]
80pub async fn origin_settings_customised(
81    state: State<'_, OriginState>,
82) -> CommandResult<Vec<String>> {
83    Ok(state
84        .application()
85        .platform()
86        .settings
87        .customised_keys()
88        .await?)
89}
90
91/// Open an external URL.
92///
93/// Fails with a permission error when the product did not wire an [`Opener`] into its
94/// composition root — capabilities are granted at build time, not requested at runtime.
95///
96/// [`Opener`]: origin_platform::Opener
97#[tauri::command]
98pub async fn origin_open_url(state: State<'_, OriginState>, url: String) -> CommandResult<()> {
99    let application = state.application();
100    let opener = application.platform().opener.as_ref().ok_or_else(|| {
101        AppError::Permission("this application cannot open external urls".to_owned())
102    })?;
103
104    opener.open_url(&url).await?;
105    Ok(())
106}
107
108/// Every connected account, across all connectors.
109#[tauri::command]
110pub async fn origin_accounts(state: State<'_, OriginState>) -> CommandResult<Vec<Account>> {
111    Ok(state.application().platform().accounts.list().await?)
112}
113
114/// Remove an account and its credentials.
115///
116/// Data a module cached for the account is not removed here — only the module knows
117/// its namespaces (see `AccountService::disconnect`).
118#[tauri::command]
119pub async fn origin_account_disconnect(
120    state: State<'_, OriginState>,
121    account: String,
122) -> CommandResult<()> {
123    state
124        .application()
125        .platform()
126        .accounts
127        .disconnect(&AccountId::new(account))
128        .await?;
129    Ok(())
130}
131
132/// What this build can connect to.
133///
134/// Compiled in, so the list is fixed: a running application cannot gain a connector
135/// (ADR-0006).
136#[tauri::command]
137pub async fn origin_connectors(
138    state: State<'_, OriginState>,
139) -> CommandResult<Vec<ConnectorDescriptor>> {
140    Ok(state
141        .application()
142        .platform()
143        .connectors
144        .iter()
145        .map(|connector| connector.descriptor())
146        .collect())
147}
148
149/// Jobs the application knows about, newest first.
150#[tauri::command]
151pub async fn origin_jobs(state: State<'_, OriginState>) -> CommandResult<Vec<Job>> {
152    Ok(state.application().platform().jobs.list().await)
153}
154
155/// Ask a job to stop.
156///
157/// Returns as soon as the request is recorded — a job decides itself when it can stop
158/// safely, so the UI must keep watching its status rather than assuming it ended.
159#[tauri::command]
160pub async fn origin_job_cancel(state: State<'_, OriginState>, job: String) -> CommandResult<()> {
161    state
162        .application()
163        .platform()
164        .jobs
165        .cancel(&JobId::new(job))
166        .await?;
167    Ok(())
168}
169
170#[tauri::command]
171pub async fn origin_sync_status(state: State<'_, OriginState>) -> CommandResult<Vec<SyncStatus>> {
172    let application = state.application();
173    let engine = &application.platform().sync;
174    let now = application.platform().clock.now();
175
176    let mut statuses = Vec::new();
177    for target in engine.targets() {
178        let sync_state = engine.state(&target).await?;
179        let due_at = engine.due_at(&target).await.ok();
180
181        statuses.push(SyncStatus {
182            health: origin_sync::health_of(
183                &sync_state,
184                &engine.policy(&target).unwrap_or_default(),
185                now,
186            ),
187            state: sync_state,
188            due_at: due_at.and_then(|at| at.format(&Rfc3339).ok()),
189            target,
190        });
191    }
192
193    Ok(statuses)
194}
195
196/// Refresh one target now.
197///
198/// Explicit user intent, so this bypasses the throttle that protects against
199/// automatic triggers.
200#[tauri::command]
201pub async fn origin_sync_now(
202    state: State<'_, OriginState>,
203    target: SyncTarget,
204) -> CommandResult<()> {
205    state
206        .application()
207        .platform()
208        .sync
209        .sync_now(&target)
210        .await?;
211    Ok(())
212}
213
214/// Overall health across every registered sync target.
215#[tauri::command]
216pub async fn origin_health(state: State<'_, OriginState>) -> CommandResult<Health> {
217    Ok(state.application().platform().sync.health().await)
218}
219
220/// Exercises commands through a real, Tauri-managed `State` rather than by calling
221/// their bodies with a hand-built value — the part `cargo check` alone cannot prove,
222/// namely that `OriginState` is actually reachable as Tauri state the way the real host
223/// wires it in `lib.rs`. No window is started; [`tauri::test::mock_app`] provides the
224/// managed-state machinery without one.
225///
226/// Commands taking an `AppHandle` are not covered here: that parameter is the default,
227/// `Wry`-backed [`tauri::AppHandle`], which a [`tauri::test::MockRuntime`]-backed app
228/// cannot produce.
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::HostConfig;
233    use crate::state::OriginState;
234    use origin_app::ApplicationBuilder;
235    use tauri::Manager;
236    use tokio_util::sync::CancellationToken;
237
238    fn mock_app_with_state() -> tauri::App<tauri::test::MockRuntime> {
239        let application = ApplicationBuilder::in_memory()
240            .build()
241            .expect("an in-memory application always builds");
242
243        let app = tauri::test::mock_app();
244        app.manage(OriginState::new(
245            application,
246            HostConfig::new("dev.origin.tests"),
247            CancellationToken::new(),
248        ));
249        app
250    }
251
252    #[tokio::test]
253    async fn health_reads_through_the_managed_state_to_the_sync_engine() {
254        let app = mock_app_with_state();
255
256        let health = origin_health(app.state()).await.unwrap();
257
258        assert_eq!(health, Health::Unknown, "no sync targets are registered");
259    }
260
261    #[tokio::test]
262    async fn a_setting_written_through_one_command_is_read_back_through_another() {
263        let app = mock_app_with_state();
264
265        origin_setting_set(app.state(), "theme".to_owned(), serde_json::json!("dark"))
266            .await
267            .unwrap();
268
269        let value = origin_setting_get(app.state(), "theme".to_owned())
270            .await
271            .unwrap();
272
273        assert_eq!(value, Some(serde_json::json!("dark")));
274    }
275
276    #[tokio::test]
277    async fn a_freshly_built_application_has_no_accounts_or_jobs() {
278        let app = mock_app_with_state();
279
280        assert!(origin_accounts(app.state()).await.unwrap().is_empty());
281        assert!(origin_jobs(app.state()).await.unwrap().is_empty());
282    }
283}