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:
+1
-1
@@ -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",
|
||||
|
||||
Generated
+3
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<username>/</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
|
||||
|
||||
Reference in New Issue
Block a user