feat(commands): MS Graph calendar link/import/disconnect + egress allowlist (T8.9, M4.4, FR-CAL-6)
This commit is contained in:
+164
-1
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user