diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f373b74..bb079c9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -70,6 +70,8 @@ fn default_settings() -> Settings { retention_max_size_gb: None, pst_last_path: None, pst_auto_sync: false, + graph_calendar_enabled: false, + graph_calendar_credential_ref: None, audio_output_device: None, microphone_enabled: true, audio_input_device: None, @@ -2432,7 +2434,12 @@ pub(crate) async fn import_pst_core( password: Option, ) -> WaResult { let events = tauri::async_runtime::spawn_blocking(move || { - PstSource.import(CalImport { path, password }) + PstSource.import(CalImport { + path, + password, + from: None, + to: None, + }) }) .await .map_err(|e| WaError::new("calendar", e.to_string()))? @@ -2509,6 +2516,157 @@ pub async fn attach_meeting_to_event( .map_err(|e| WaError::new("storage", e.to_string())) } +// ---- Microsoft Graph calendar source (M4.4, T8.9, FR-CAL-6) ---- + +/// Begin OAuth 2.0 PKCE linking for the optional MS Graph calendar source. +/// Explicit consent per FR-CAL-6: the user both clicks "Connect" here and +/// approves Microsoft's own consent screen. On success, stores the token set +/// in the OS credential store and flips `graph_calendar_enabled`; emits +/// `calendar://linked`. Mirrors `begin_oauth_link`'s PKCE flow (`sync::oauth`) +/// but a calendar link isn't a `SyncTarget` — no row is created in +/// `sync_targets`, so it can never be picked up by the upload pump or listed +/// as a sync destination. +#[tauri::command] +pub async fn begin_graph_calendar_link(app: AppHandle) -> WaResult { + use crate::sync::oauth; + + let provider = oauth::provider_for("graph-calendar") + .ok_or_else(|| WaError::new("calendar", "unknown OAuth provider 'graph-calendar'"))?; + let client_id = provider + .client_id() + .map_err(|e| WaError::new("calendar", e.to_string()))?; + + let pkce = oauth::pkce_pair(); + let csrf_state = uuid::Uuid::new_v4().to_string(); + let loopback = + oauth::LoopbackRedirect::bind().map_err(|e| WaError::new("calendar", e.to_string()))?; + let redirect_uri = loopback.redirect_uri.clone(); + let auth_url = oauth::build_auth_url( + &provider, + &client_id, + &redirect_uri, + &pkce.challenge, + &csrf_state, + ) + .map_err(|e| WaError::new("calendar", e.to_string()))?; + + let verifier = pkce.verifier; + tauri::async_runtime::spawn(async move { + let fail = |msg: String| { + let _ = app.emit( + "calendar://linked", + serde_json::json!({ "ok": false, "error": msg }), + ); + }; + + let code = + match tauri::async_runtime::spawn_blocking(move || loopback.wait_for_code(&csrf_state)) + .await + { + Ok(Ok(code)) => code, + _ => return fail("authorization was cancelled or failed".into()), + }; + + let tokens = + match oauth::exchange_code(&provider, &client_id, &code, &verifier, &redirect_uri) + .await + { + Ok(t) => t, + Err(e) => return fail(e.to_string()), + }; + + let credential_ref = "wa-calendar-graph".to_string(); + let Ok(json) = serde_json::to_string(&tokens) else { + return fail("could not serialize tokens".into()); + }; + if let Err(e) = crate::sync::credentials::set(&credential_ref, &json) { + return fail(e.to_string()); + } + + let mut settings = load_settings(); + settings.graph_calendar_enabled = true; + settings.graph_calendar_credential_ref = Some(credential_ref); + if let Err(e) = save_settings(&settings) { + return fail(e.message); + } + let _ = app.emit("calendar://linked", serde_json::json!({ "ok": true })); + }); + + Ok(serde_json::json!({ "authUrl": auth_url })) +} + +/// Import events from the linked MS Graph calendar for `[from, to]` (unix +/// seconds; defaults to roughly the last month through the next quarter — +/// see `GraphSource::import`). Split from the `#[tauri::command]` wrapper for +/// the same reason as `import_pst_core`. Persisted the same way as PST +/// events — dedup'd by `(source, raw_uid)` — so re-running this just catches +/// up on new/changed events. +pub(crate) async fn import_graph_calendar_core( + app: &AppHandle, + store: &dyn crate::storage::Store, + credential_ref: String, + from: Option, + to: Option, +) -> WaResult { + let events = tauri::async_runtime::spawn_blocking(move || { + crate::calendar::GraphSource { credential_ref }.import(CalImport { + path: String::new(), // unused by GraphSource + password: None, + from, + to, + }) + }) + .await + .map_err(|e| WaError::new("calendar", e.to_string()))? + .map_err(|e| WaError::new("calendar", e.to_string()))?; + + let total = events.len() as u32; + let imported = store + .import_calendar_events(events) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + let _ = app.emit( + "calendar://progress", + serde_json::json!({ "processed": imported, "total": total }), + ); + Ok(imported) +} + +#[tauri::command] +pub async fn import_graph_calendar( + app: AppHandle, + state: State<'_, AppState>, + from: Option, + to: Option, +) -> WaResult { + let settings = load_settings(); + let credential_ref = settings + .graph_calendar_enabled + .then_some(settings.graph_calendar_credential_ref) + .flatten() + .ok_or_else(|| { + WaError::new( + "calendar", + "Microsoft calendar isn't connected — link it in Settings first", + ) + })?; + import_graph_calendar_core(&app, state.store.as_ref(), credential_ref, from, to).await +} + +/// Unlink the MS Graph calendar source: best-effort credential cleanup, then +/// clears the settings flags. Imported `calendar_events` rows (source "graph") +/// are left as historical data, same as PST events are never deleted on +/// disconnect. +#[tauri::command] +pub async fn disconnect_graph_calendar() -> WaResult<()> { + let mut settings = load_settings(); + if let Some(cred) = settings.graph_calendar_credential_ref.take() { + let _ = crate::sync::credentials::delete(&cred); + } + settings.graph_calendar_enabled = false; + save_settings(&settings) +} + /// Manually rename a meeting — recordings default to "Untitled meeting" with /// no prior way to change that from the UI. #[tauri::command] @@ -3641,6 +3799,11 @@ fn privacy_self_check_json( allowlisted_hosts.push(host.clone()); } } + // MS Graph calendar source (M4.4, FR-CAL-6) — the only egress it ever + // needs, and only when the user has explicitly linked it. + if settings.graph_calendar_enabled { + allowlisted_hosts.push("graph.microsoft.com".to_string()); + } allowlisted_hosts.sort(); allowlisted_hosts.dedup();