diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4ae68d0..8f77450 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -18,7 +18,7 @@ use crate::paths::{ diarization_embedding_model_file, diarization_segmentation_model_file, meeting_dir, settings_path, wa_root, whisper_model_file, }; -use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile}; +use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile, SyncTargetRow}; use crate::transcription::{ models as model_catalog, run_streaming_worker, Transcriber, WhisperTranscriber, }; @@ -1750,27 +1750,156 @@ pub async fn attach_meeting_to_event( // ---- Sync / upload (Phase 9, ADR-0010) ---- -#[tauri::command] -pub async fn list_sync_targets() -> WaResult> { - // Never returns secrets (FR-SYNC-6). - Err(not_implemented("list_sync_targets")) +/// Config payload for add/update. Snake→camel so the TS side stays idiomatic; +/// the `secret` (app password) is write-only — it goes to the OS store, never +/// back to the UI (FR-SYNC-6). +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SyncTargetConfigInput { + pub id: Option, + pub name: Option, + pub kind: Option, // webdav (default) | onedrive | dropbox | box + pub provider_hint: Option, + pub base_url: Option, + pub remote_base_path: Option, + pub username: Option, + pub secret: Option, + pub enabled: Option, + pub upload_transcript: Option, + pub upload_notes: Option, + pub upload_summary: Option, + pub upload_recording: Option, + pub trigger_on_finalize: Option, + pub allow_plaintext_lan: Option, } #[tauri::command] -pub async fn add_sync_target(_config: serde_json::Value) -> WaResult { - // `config` includes a `secret` stored to the OS credential store, not the DB. - Err(not_implemented("add_sync_target")) +pub async fn list_sync_targets(state: State<'_, AppState>) -> WaResult> { + // Never returns secrets (FR-SYNC-6) — row_to_info drops credential_ref. + let rows = state + .store + .list_sync_targets() + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + Ok(rows.iter().map(crate::sync::row_to_info).collect()) } #[tauri::command] -pub async fn update_sync_target(_config: serde_json::Value) -> WaResult { - // `config` carries an `id` and optional fields; an optional `secret` updates the credential store. - Err(not_implemented("update_sync_target")) +pub async fn add_sync_target( + state: State<'_, AppState>, + config: SyncTargetConfigInput, +) -> WaResult { + let id = uuid::Uuid::new_v4().to_string(); + let credential_ref = format!("wa-sync-{id}"); + // Secret goes to the OS credential store keyed by credential_ref, never the DB. + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + crate::sync::credentials::set(&credential_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + } + let row = SyncTargetRow { + id, + name: config.name.unwrap_or_else(|| "WebDAV target".to_string()), + kind: config.kind.unwrap_or_else(|| "webdav".to_string()), + provider_hint: config.provider_hint, + base_url: config.base_url, + remote_base_path: config + .remote_base_path + .unwrap_or_else(|| "/WhispAssist".to_string()), + username: config.username, + credential_ref, + enabled: config.enabled.unwrap_or(false), + upload_transcript: config.upload_transcript.unwrap_or(true), + upload_notes: config.upload_notes.unwrap_or(true), + upload_summary: config.upload_summary.unwrap_or(true), + upload_recording: config.upload_recording.unwrap_or(false), + trigger_on_finalize: config.trigger_on_finalize.unwrap_or(true), + allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false), + encrypt_before_upload: false, // 9c (vault) is on hold + created_at: now_unix(), + }; + state + .store + .add_sync_target(row.clone()) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + Ok(crate::sync::row_to_info(&row)) } #[tauri::command] -pub async fn remove_sync_target(_id: String) -> WaResult<()> { - Err(not_implemented("remove_sync_target")) +pub async fn update_sync_target( + state: State<'_, AppState>, + config: SyncTargetConfigInput, +) -> WaResult { + let id = config + .id + .clone() + .ok_or_else(|| WaError::new("sync", "update requires a target id"))?; + let mut row = state + .store + .get_sync_target(&id) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + + // A provided secret rotates the credential; credential_ref is preserved. + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + crate::sync::credentials::set(&row.credential_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + } + if let Some(v) = config.name { + row.name = v; + } + if let Some(v) = config.provider_hint { + row.provider_hint = Some(v); + } + if let Some(v) = config.base_url { + row.base_url = Some(v); + } + if let Some(v) = config.remote_base_path { + row.remote_base_path = v; + } + if let Some(v) = config.username { + row.username = Some(v); + } + if let Some(v) = config.enabled { + row.enabled = v; + } + if let Some(v) = config.upload_transcript { + row.upload_transcript = v; + } + if let Some(v) = config.upload_notes { + row.upload_notes = v; + } + if let Some(v) = config.upload_summary { + row.upload_summary = v; + } + if let Some(v) = config.upload_recording { + row.upload_recording = v; + } + if let Some(v) = config.trigger_on_finalize { + row.trigger_on_finalize = v; + } + if let Some(v) = config.allow_plaintext_lan { + row.allow_plaintext_lan = v; + } + state + .store + .update_sync_target(row.clone()) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + Ok(crate::sync::row_to_info(&row)) +} + +#[tauri::command] +pub async fn remove_sync_target(state: State<'_, AppState>, id: String) -> WaResult<()> { + // Best-effort secret cleanup first, then the row (cascades its jobs). + if let Ok(row) = state.store.get_sync_target(&id).await { + let _ = crate::sync::credentials::delete(&row.credential_ref); + } + state + .store + .remove_sync_target(&id) + .await + .map_err(|e| WaError::new("sync", e.to_string())) } /// Begin OAuth 2.0 PKCE linking for a secondary target (loopback redirect). FR-SYNC-9. @@ -1784,14 +1913,81 @@ pub async fn retry_sync_job(_job_id: String) -> WaResult<()> { Err(not_implemented("retry_sync_job")) } +/// Provider-specific setup tips surfaced on a failed/attempted test (T9.4). +fn provider_setup_hints(hint: Option<&str>) -> Vec<&'static str> { + match hint { + Some("seafile") => { + vec!["Enable SeafDAV on the server; it's off by default and may need LOCK disabled."] + } + Some("synology") => { + vec!["Install & enable the WebDAV Server package; prefer the HTTPS port."] + } + Some("nextcloud") | Some("owncloud") => { + vec!["Use an app password (Settings → Security), not your account password."] + } + _ => vec![], + } +} + +/// Reachability + auth test for a target — either an existing one (by `id`) or an +/// unsaved config carrying an inline `secret` (T9.4, FR-SYNC-4). #[tauri::command] -pub async fn test_sync_target(_config_or_id: serde_json::Value) -> WaResult { - Err(not_implemented("test_sync_target")) +pub async fn test_sync_target( + state: State<'_, AppState>, + config: SyncTargetConfigInput, +) -> WaResult { + use crate::sync::SyncTarget; + + let hints = provider_setup_hints(config.provider_hint.as_deref()); + let (target, temp_ref) = if let Some(id) = config.id.clone() { + let row = state + .store + .get_sync_target(&id) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + (crate::sync::WebDavTarget::from_row(&row), None) + } else { + // Unsaved target: stash the secret under a temp credential_ref so the same + // resolve-at-use path works, then clean it up after the test. + let base_url = config + .base_url + .clone() + .ok_or_else(|| WaError::new("sync", "test requires a base_url"))?; + let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4()); + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + crate::sync::credentials::set(&temp_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + } + let target = crate::sync::WebDavTarget { + base_url, + remote_base_path: config + .remote_base_path + .clone() + .unwrap_or_else(|| "/WhispAssist".to_string()), + username: config.username.clone().unwrap_or_default(), + credential_ref: temp_ref.clone(), + third_party: config.kind.as_deref().unwrap_or("webdav") != "webdav", + allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false), + }; + (target, Some(temp_ref)) + }; + + let result = target.test().await; + if let Some(temp_ref) = temp_ref { + let _ = crate::sync::credentials::delete(&temp_ref); + } + + match result { + Ok(()) => Ok(serde_json::json!({ "ok": true, "message": "Connected", "hints": hints })), + Err(e) => Ok(serde_json::json!({ "ok": false, "message": e.to_string(), "hints": hints })), + } } #[tauri::command] -pub async fn set_sync_enabled(_enabled: bool) -> WaResult<()> { - Err(not_implemented("set_sync_enabled")) +pub async fn set_sync_enabled(enabled: bool) -> WaResult<()> { + let mut settings = load_settings(); + settings.sync_enabled = enabled; + save_settings(&settings) } #[tauri::command] @@ -1954,13 +2150,18 @@ fn privacy_self_check_json( } /// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2). -/// Sync targets/hosts are empty until Phase 9 lands `SyncManager`; `list_sync_targets` is still -/// `not_implemented` so its error is swallowed here rather than failing the whole self-check. +/// Enabled sync targets' hosts are folded into the allowlist (T9.8); a DB error is +/// swallowed to an empty list rather than failing the whole self-check. #[tauri::command] -pub async fn privacy_self_check() -> WaResult { +pub async fn privacy_self_check(state: State<'_, AppState>) -> WaResult { let settings = load_settings(); let sync_targets: Vec = if settings.sync_enabled { - list_sync_targets().await.unwrap_or_default() + state + .store + .list_sync_targets() + .await + .map(|rows| rows.iter().map(crate::sync::row_to_info).collect()) + .unwrap_or_default() } else { Vec::new() };