feat(commands): MS Graph calendar link/import/disconnect + egress allowlist (T8.9, M4.4, FR-CAL-6)

This commit is contained in:
iamdoubz
2026-07-07 17:53:13 -05:00
parent ef6bb28f3e
commit d81b4726f9
+164 -1
View File
@@ -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<String>,
) -> WaResult<u32> {
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<serde_json::Value> {
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<i64>,
to: Option<i64>,
) -> WaResult<u32> {
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<i64>,
to: Option<i64>,
) -> WaResult<u32> {
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();