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///
230/// Windows only: `tauri::test::mock_app()` crashes the whole test binary there with
231/// `STATUS_ENTRYPOINT_NOT_FOUND`, a known unresolved upstream issue
232/// (tauri-apps/tauri#11028, #13419, #13948, #13954) — this module is not compiled on
233/// that target, and the `tauri` dev-dependency's `test` feature is not enabled there
234/// either (see Cargo.toml).
235#[cfg(all(test, not(windows)))]
236mod tests {
237    use super::*;
238    use crate::HostConfig;
239    use crate::state::OriginState;
240    use origin_app::ApplicationBuilder;
241    use tauri::Manager;
242    use tokio_util::sync::CancellationToken;
243
244    fn mock_app_with_state() -> tauri::App<tauri::test::MockRuntime> {
245        let application = ApplicationBuilder::in_memory()
246            .build()
247            .expect("an in-memory application always builds");
248
249        let app = tauri::test::mock_app();
250        app.manage(OriginState::new(
251            application,
252            HostConfig::new("dev.origin.tests"),
253            CancellationToken::new(),
254        ));
255        app
256    }
257
258    #[tokio::test]
259    async fn health_reads_through_the_managed_state_to_the_sync_engine() {
260        let app = mock_app_with_state();
261
262        let health = origin_health(app.state()).await.unwrap();
263
264        assert_eq!(health, Health::Unknown, "no sync targets are registered");
265    }
266
267    #[tokio::test]
268    async fn a_setting_written_through_one_command_is_read_back_through_another() {
269        let app = mock_app_with_state();
270
271        origin_setting_set(app.state(), "theme".to_owned(), serde_json::json!("dark"))
272            .await
273            .unwrap();
274
275        let value = origin_setting_get(app.state(), "theme".to_owned())
276            .await
277            .unwrap();
278
279        assert_eq!(value, Some(serde_json::json!("dark")));
280    }
281
282    #[tokio::test]
283    async fn a_freshly_built_application_has_no_accounts_or_jobs() {
284        let app = mock_app_with_state();
285
286        assert!(origin_accounts(app.state()).await.unwrap().is_empty());
287        assert!(origin_jobs(app.state()).await.unwrap().is_empty());
288    }
289}