Merge pull request 'Feature chore bug 001' (#15) from feature_chore_bug_001 into main

Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
2026-07-06 13:18:01 -05:00
10 changed files with 234 additions and 31 deletions
+1 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+43 -4
View File
@@ -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<dyn SyncTarget>, Option<String>) =
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,
}
}
+9
View File
@@ -233,6 +233,15 @@ pub struct SyncTargetInfo {
pub enabled: bool,
pub third_party: bool,
pub host: Option<String>,
// 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)]
+68 -3
View File
@@ -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/<user>/ , /seafdav , /dav , …
/// For Nextcloud/ownCloud this is just the server URL (e.g. `https://host`);
/// the canonical `/remote.php/dav/files/<user>/` 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<String>,
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/<user>/` 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=<dir> --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(),
+1 -1
View File
@@ -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",
+7
View File
@@ -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.
+20
View File
@@ -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,
};
}
+80 -19
View File
@@ -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<SyncTargetConfig>(blankForm("webdav"));
// When set, the form is editing an existing target rather than adding one.
let editingId = $state<string | null>(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"}</span
>
<span class="host">{t.host ?? t.kind}</span>
{#if !t.third_party}
<button class="link" onclick={() => startEdit(t)}>Edit</button>
{/if}
<button class="link danger" onclick={() => settings.removeTarget(t.id)}
>Remove</button
>
@@ -719,14 +765,16 @@
</ul>
{/if}
<h4>Add a target</h4>
<div class="kinds">
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
>{k}</button
>
{/each}
</div>
<h4>{editingId ? "Edit target" : "Add a target"}</h4>
{#if !editingId}
<div class="kinds">
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
>{k}</button
>
{/each}
</div>
{/if}
{#if form.kind === "webdav"}
<p class="muted">
@@ -744,16 +792,26 @@
<label class="wide"
>Server URL<input
bind:value={form.base_url}
placeholder="https://cloud.example.com/remote.php/dav/files/me/"
/></label
>
placeholder={davAutoPath
? "https://cloud.example.com"
: "https://host/remote.php/dav/… (or /seafdav, /dav)"}
/>
{#if davAutoPath}
<small class="muted"
>Just your server URL — WhispAssist adds
<code>/remote.php/dav/files/&lt;username&gt;/</code> automatically.</small
>
{/if}
</label>
<label>Remote folder<input bind:value={form.remote_base_path} /></label>
<label>Username<input bind:value={form.username} /></label>
<label
>App password<input
type="password"
bind:value={form.secret}
placeholder="stored in OS credential store"
placeholder={editingId
? "leave blank to keep current password"
: "stored in OS credential store"}
/></label
>
</div>
@@ -782,9 +840,12 @@
</label>
<div class="actions">
<button onclick={testConnection}>Test connection</button>
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}
>Add target</button
<button class="primary" onclick={saveTarget} disabled={!form.name || !form.base_url}
>{editingId ? "Save changes" : "Add target"}</button
>
{#if editingId}
<button class="link" onclick={cancelEdit}>Cancel</button>
{/if}
{#if testResult}
<span class="test" class:ok={testResult.ok} class:fail={!testResult.ok}>
{#if testResult.ok}<Check size={14} aria-hidden="true" />{:else}<X