1use 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#[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#[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#[tauri::command]
110pub async fn origin_accounts(state: State<'_, OriginState>) -> CommandResult<Vec<Account>> {
111 Ok(state.application().platform().accounts.list().await?)
112}
113
114#[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#[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#[tauri::command]
151pub async fn origin_jobs(state: State<'_, OriginState>) -> CommandResult<Vec<Job>> {
152 Ok(state.application().platform().jobs.list().await)
153}
154
155#[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#[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#[tauri::command]
216pub async fn origin_health(state: State<'_, OriginState>) -> CommandResult<Health> {
217 Ok(state.application().platform().sync.health().await)
218}
219
220#[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}