diff --git a/package.json b/package.json index ee02251..7012e7b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "whispassist", "private": true, - "version": "0.1.5", + "version": "0.1.6", "type": "module", "description": "Privacy-first, fully local Windows meeting assistant.", "license": "MIT OR Apache-2.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5374439..7857445 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2209,7 +2209,9 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", "log", + "windows-sys 0.60.2", "zeroize", ] @@ -5973,7 +5975,7 @@ dependencies = [ [[package]] name = "whispassist" -version = "0.1.5" +version = "0.1.6" dependencies = [ "argon2", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d4bfca6..c8d6390 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whispassist" -version = "0.1.5" +version = "0.1.6" description = "Privacy-first, fully local Windows meeting assistant" authors = ["WhispAssist contributors"] license = "MIT OR Apache-2.0" @@ -43,7 +43,7 @@ argon2 = "0.5" chacha20poly1305 = "0.10" getrandom = "0.2" zeroize = "1" # wipe key material from memory on lock -keyring = { version = "3", optional = true } # OS credential store (sync + AI creds) +keyring = { version = "3", optional = true, features = ["windows-native"] } # OS credential store (sync + AI creds); windows-native = real Credential Manager (else keyring 3.x uses a no-op mock store) rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011) # audio / transcription / diarization / calendar are integrated per-phase and are diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 93243e8..74ba734 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2349,15 +2349,46 @@ pub async fn test_sync_target( let hints = provider_setup_hints(config.provider_hint.as_deref()); let (target, temp_ref): (Box, Option) = if let Some(id) = config.id.clone() { - // Existing target (any kind) — dispatch to its concrete impl. let row = state .store .get_sync_target(&id) .await .map_err(|e| WaError::new("sync", e.to_string()))?; - let target = crate::sync::build_sync_target(&row) - .map_err(|e| WaError::new("sync", e.to_string()))?; - (target, None) + if row.kind == "webdav" { + // Editing an existing webdav target: test the values on screen but + // reuse the STORED password (via its credential_ref) unless the user + // typed a new one — so "Test" works after a URL fix without having to + // re-enter the password. + let (credential_ref, temp_ref) = + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4()); + crate::sync::credentials::set(&temp_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + (temp_ref.clone(), Some(temp_ref)) + } else { + (row.credential_ref.clone(), None) + }; + let target = crate::sync::WebDavTarget { + base_url: config.base_url.clone().or(row.base_url.clone()).unwrap_or_default(), + provider_hint: config.provider_hint.clone().or(row.provider_hint.clone()), + remote_base_path: config + .remote_base_path + .clone() + .unwrap_or(row.remote_base_path.clone()), + username: config.username.clone().or(row.username.clone()).unwrap_or_default(), + credential_ref, + third_party: false, + allow_plaintext_lan: config + .allow_plaintext_lan + .unwrap_or(row.allow_plaintext_lan), + }; + (Box::new(target), temp_ref) + } else { + // Non-webdav (OAuth) target — dispatch to its concrete impl as-is. + let target = crate::sync::build_sync_target(&row) + .map_err(|e| WaError::new("sync", e.to_string()))?; + (target, None) + } } else { // Unsaved WebDAV target: stash the secret under a temp credential_ref so // the same resolve-at-use path works, then clean it up after the test. @@ -2372,6 +2403,7 @@ pub async fn test_sync_target( } let target = crate::sync::WebDavTarget { base_url, + provider_hint: config.provider_hint.clone(), remote_base_path: config .remote_base_path .clone() @@ -3028,6 +3060,13 @@ mod tests { enabled, third_party, host: Some(host.to_string()), + upload_transcript: true, + upload_notes: true, + upload_summary: true, + upload_recording: false, + trigger_on_finalize: true, + allow_plaintext_lan: false, + encrypt_before_upload: false, } } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 3126525..0decf5d 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -233,6 +233,15 @@ pub struct SyncTargetInfo { pub enabled: bool, pub third_party: bool, pub host: Option, + // Upload selection + options, so the UI can pre-fill an edit form (the secret + // is never included — FR-SYNC-6). + pub upload_transcript: bool, + pub upload_notes: bool, + pub upload_summary: bool, + pub upload_recording: bool, + pub trigger_on_finalize: bool, + pub allow_plaintext_lan: bool, + pub encrypt_before_upload: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 453be6d..78db143 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -75,7 +75,11 @@ pub trait SyncManager: Send + Sync { /// WebDAV provider — primary targets + Synology (feature `sync`). #[cfg(feature = "sync")] pub struct WebDavTarget { - pub base_url: String, // https://host/remote.php/dav/files// , /seafdav , /dav , … + /// For Nextcloud/ownCloud this is just the server URL (e.g. `https://host`); + /// the canonical `/remote.php/dav/files//` path is derived. For other + /// providers it's the full DAV URL entered by the user (`…/seafdav`, `…/dav`). + pub base_url: String, + pub provider_hint: Option, pub remote_base_path: String, pub username: String, pub credential_ref: String, // key into the OS credential store — resolved at use, not stored here @@ -90,6 +94,7 @@ impl WebDavTarget { pub fn from_row(row: &crate::storage::SyncTargetRow) -> Self { Self { base_url: row.base_url.clone().unwrap_or_default(), + provider_hint: row.provider_hint.clone(), remote_base_path: row.remote_base_path.clone(), username: row.username.clone().unwrap_or_default(), credential_ref: row.credential_ref.clone(), @@ -98,11 +103,38 @@ impl WebDavTarget { } } + /// The effective WebDAV root the paths hang off. For Nextcloud/ownCloud the + /// user supplies only their server URL and we build the canonical + /// `…/remote.php/dav/files//` path (deriving the origin, so a pasted + /// full DAV URL is normalized too). Other providers use the URL verbatim. + fn dav_root(&self) -> String { + match self.provider_hint.as_deref() { + Some("nextcloud") | Some("owncloud") => { + let origin = reqwest::Url::parse(&self.base_url) + .ok() + .map(|u| u.origin().ascii_serialization()) + .unwrap_or_else(|| self.base_url.trim_end_matches('/').to_string()); + // ponytail: raw username in the path — fine for typical Nextcloud + // usernames; add percent-encoding if one ever contains `/` or spaces. + format!( + "{origin}/remote.php/dav/files/{}", + self.username.trim_matches('/') + ) + } + _ => self.base_url.trim_end_matches('/').to_string(), + } + } + /// Full URL for a path under this target, resolving `//` at the join. fn url_for(&self, remote_path: &str) -> String { - let base = self.base_url.trim_end_matches('/'); + let base = self.dav_root(); + let base = base.trim_end_matches('/'); let path = remote_path.trim_start_matches('/'); - format!("{base}/{path}") + if path.is_empty() { + base.to_string() + } else { + format!("{base}/{path}") + } } /// Resolves the secret and returns an authenticated request builder, after @@ -281,6 +313,13 @@ pub fn row_to_info(row: &crate::storage::SyncTargetRow) -> SyncTargetInfo { enabled: row.enabled, third_party: row.kind != "webdav", host, + upload_transcript: row.upload_transcript, + upload_notes: row.upload_notes, + upload_summary: row.upload_summary, + upload_recording: row.upload_recording, + trigger_on_finalize: row.trigger_on_finalize, + allow_plaintext_lan: row.allow_plaintext_lan, + encrypt_before_upload: row.encrypt_before_upload, } } @@ -373,6 +412,7 @@ mod tests { fn url_join_normalizes_slashes() { let t = WebDavTarget { base_url: "https://host/dav/".to_string(), + provider_hint: None, remote_base_path: "/WhispAssist".to_string(), username: "u".to_string(), credential_ref: "r".to_string(), @@ -385,6 +425,30 @@ mod tests { ); } + #[test] + fn nextcloud_builds_dav_path_from_server_url() { + let mk = |base: &str| WebDavTarget { + base_url: base.to_string(), + provider_hint: Some("nextcloud".to_string()), + remote_base_path: "/WhispAssist".to_string(), + username: "dwdoubet".to_string(), + credential_ref: "r".to_string(), + third_party: false, + allow_plaintext_lan: false, + }; + // Bare server URL → canonical DAV path built from username. + assert_eq!( + mk("https://box.dou.bet").url_for("/WhispAssist"), + "https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist" + ); + // A pasted full DAV URL normalizes to the same thing (origin only). + assert_eq!( + mk("https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist") + .url_for("/WhispAssist"), + "https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist" + ); + } + /// Full WebDAV round-trip against a real server, opt-in (needs one running): /// WA_WEBDAV_URL, WA_WEBDAV_USER, WA_WEBDAV_PASS /// e.g. `wsgidav --host=127.0.0.1 --port=8899 --root= --auth=anonymous` @@ -407,6 +471,7 @@ mod tests { let target = WebDavTarget { base_url, + provider_hint: None, remote_base_path: "/WhispAssist-test".to_string(), username: user, credential_ref: cred_ref.to_string(), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c99af92..93becca 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WhispAssist", - "version": "0.1.5", + "version": "0.1.6", "identifier": "bet.dou.whispassist", "build": { "frontendDist": "../dist", diff --git a/src/lib/api.ts b/src/lib/api.ts index f530ea5..a021996 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -202,6 +202,13 @@ export interface SyncTargetInfo { enabled: boolean; third_party: boolean; host: string | null; + upload_transcript: boolean; + upload_notes: boolean; + upload_summary: boolean; + upload_recording: boolean; + trigger_on_finalize: boolean; + allow_plaintext_lan: boolean; + encrypt_before_upload: boolean; } // privacy_self_check() response (FR-SEC-2) — proves local-only handling. diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index cf6134b..d4bc3e1 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -248,6 +248,19 @@ class SettingsStore { await this.loadPrivacy(); } + async updateTarget(config: SyncTargetConfig & { id: string }) { + try { + const updated = await api.updateSyncTarget(config); + this.targets = this.targets.map((t) => (t.id === config.id ? updated : t)); + } catch { + this.backendStub = true; + this.targets = this.targets.map((t) => + t.id === config.id ? { ...t, ...stubTarget(config), id: config.id } : t, + ); + } + await this.loadPrivacy(); + } + async removeTarget(id: string) { this.targets = this.targets.filter((t) => t.id !== id); try { @@ -297,6 +310,13 @@ function stubTarget(c: SyncTargetConfig): SyncTargetInfo { enabled: c.enabled ?? false, third_party: c.kind !== "webdav", host, + upload_transcript: c.upload_transcript ?? true, + upload_notes: c.upload_notes ?? true, + upload_summary: c.upload_summary ?? true, + upload_recording: c.upload_recording ?? false, + trigger_on_finalize: c.trigger_on_finalize ?? true, + allow_plaintext_lan: c.allow_plaintext_lan ?? false, + encrypt_before_upload: c.encrypt_before_upload ?? false, }; } diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 73d29f4..3b71e3d 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -8,7 +8,7 @@ import ConsentNotice from "../components/ConsentNotice.svelte"; import { open } from "@tauri-apps/plugin-dialog"; import { api, errorMessage, events } from "../api"; - import type { BackendId, SyncKind, SyncTargetConfig } from "../api"; + import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api"; import { trapFocus } from "../actions/trapFocus"; import { X, @@ -343,6 +343,12 @@ // ---- Add-target form ---- const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"]; let form = $state(blankForm("webdav")); + // When set, the form is editing an existing target rather than adding one. + let editingId = $state(null); + // Nextcloud/ownCloud only need the server URL — the app builds the DAV path. + let davAutoPath = $derived( + form.provider_hint === "nextcloud" || form.provider_hint === "owncloud", + ); function blankForm(kind: SyncKind): SyncTargetConfig { return { @@ -368,14 +374,51 @@ testResult = null; } async function testConnection() { - const r = await settings.test(form); + // In edit mode, pass the id so the backend reuses the stored password + // (unless a new one was typed) while still testing the edited URL/fields. + const r = await settings.test(editingId ? { ...form, id: editingId } : form); testResult = { ok: r.ok, message: r.message }; } - async function addTarget() { - await settings.addTarget(form); - form = blankForm(form.kind); + // Load an existing target into the form for editing. The secret is never + // returned to the UI (FR-SYNC-6), so it stays blank — left blank on save it + // keeps the stored password; typed, it rotates. No `id` on the form itself, so + // "Test connection" checks the typed values (needs the password re-entered). + function startEdit(t: SyncTargetInfo) { + editingId = t.id; + form = { + name: t.name, + kind: t.kind, + provider_hint: t.provider_hint ?? undefined, + base_url: t.base_url ?? "", + remote_base_path: t.remote_base_path, + username: t.username ?? "", + secret: "", + enabled: t.enabled, + upload_transcript: t.upload_transcript, + upload_notes: t.upload_notes, + upload_summary: t.upload_summary, + upload_recording: t.upload_recording, + trigger_on_finalize: t.trigger_on_finalize, + allow_plaintext_lan: t.allow_plaintext_lan, + encrypt_before_upload: t.encrypt_before_upload, + }; testResult = null; } + function cancelEdit() { + editingId = null; + form = blankForm("webdav"); + testResult = null; + } + async function saveTarget() { + if (editingId) { + await settings.updateTarget({ ...form, id: editingId }); + cancelEdit(); + } else { + await settings.addTarget(form); + form = blankForm(form.kind); + testResult = null; + } + } function linkOAuth() { // begin_oauth_link → loopback PKCE flow; the target is created in the // background on success (sync://linked), which the store handles. @@ -711,6 +754,9 @@ >{t.third_party ? "third-party" : "your server"} {t.host ?? t.kind} + {#if !t.third_party} + + {/if} @@ -719,14 +765,16 @@ {/if} -

Add a target

-
- {#each ["webdav", "onedrive", "dropbox", "box"] as k (k)} - - {/each} -
+

{editingId ? "Edit target" : "Add a target"}

+ {#if !editingId} +
+ {#each ["webdav", "onedrive", "dropbox", "box"] as k (k)} + + {/each} +
+ {/if} {#if form.kind === "webdav"}

@@ -744,16 +792,26 @@ + placeholder={davAutoPath + ? "https://cloud.example.com" + : "https://host/remote.php/dav/… (or /seafdav, /dav)"} + /> + {#if davAutoPath} + Just your server URL — WhispAssist adds + /remote.php/dav/files/<username>/ automatically. + {/if} + @@ -782,9 +840,12 @@

- {editingId ? "Save changes" : "Add target"} + {#if editingId} + + {/if} {#if testResult} {#if testResult.ok}