Feature another one #11

Merged
iamdoubz merged 32 commits from feature_another_one into main 2026-07-03 08:56:28 -05:00
17 changed files with 2964 additions and 94 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "whispassist",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"description": "Privacy-first, fully local Windows meeting assistant.",
"license": "MIT OR Apache-2.0",
+119 -1
View File
@@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -53,6 +63,18 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -199,6 +221,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -436,6 +467,30 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -450,6 +505,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -621,6 +687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -1975,6 +2042,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2765,6 +2841,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2856,6 +2938,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pastey"
version = "0.2.3"
@@ -3008,6 +3101,17 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "pom"
version = "3.4.0"
@@ -5412,6 +5516,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -5811,11 +5925,14 @@ dependencies = [
[[package]]
name = "whispassist"
version = "0.0.0"
version = "0.1.0"
dependencies = [
"argon2",
"async-trait",
"chacha20poly1305",
"docx-rs",
"futures-util",
"getrandom 0.2.17",
"hound",
"keyring",
"ort",
@@ -5840,6 +5957,7 @@ dependencies = [
"wasapi",
"whisper-rs",
"windows 0.58.0",
"zeroize",
]
[[package]]
+10 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "whispassist"
version = "0.0.0"
version = "0.1.0"
description = "Privacy-first, fully local Windows meeting assistant"
authors = ["WhispAssist contributors"]
license = "MIT OR Apache-2.0"
@@ -35,6 +35,14 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
futures-util = "0.3"
sha2 = "0.10" # skip-if-unchanged hashing (sync)
# At-rest encryption vault (Phase 8, T8.8, FR-SEC-3). Pure-Rust RustCrypto — no
# C build. Argon2id derives the vault key from the password; XChaCha20-Poly1305
# (24-byte nonce → safe random nonces) is the AEAD for artifacts + key wrapping.
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)
rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011)
@@ -73,7 +81,7 @@ windows = { version = "0.58", features = [
wasapi = { version = "0.15", optional = true } # Phase 1
[features]
default = ["audio", "cpu-transcription", "diarization", "pst"]
default = ["audio", "cpu-transcription", "diarization", "pst", "sync"]
# Phase 1
audio = ["dep:wasapi", "dep:hound"]
cpu-transcription = ["dep:whisper-rs"] # whisper-rs CPU build
+9 -6
View File
@@ -497,13 +497,16 @@ impl Resampler {
/// through hound's size-aware iterator; hound is only used to parse the
/// format/`fmt ` chunk.
pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
let reader =
hound::WavReader::open(path).map_err(|e| AudioError::Capture(format!("open wav: {e}")))?;
let spec = reader.spec();
let channels = spec.channels.max(1) as usize;
drop(reader);
let raw = std::fs::read(path).map_err(|e| AudioError::Capture(format!("read wav: {e}")))?;
// Decrypt if the recording was sealed at rest (T8.8); plaintext passes through.
let file =
crate::vault::open(&raw).map_err(|e| AudioError::Capture(format!("decrypt wav: {e}")))?;
let spec = hound::WavReader::new(std::io::Cursor::new(file.as_slice()))
.map_err(|e| AudioError::Capture(format!("open wav: {e}")))?
.spec();
let channels = spec.channels.max(1) as usize;
let file = std::fs::read(path).map_err(|e| AudioError::Capture(format!("read wav: {e}")))?;
let data_marker = file
.windows(4)
.position(|w| w == b"data")
+713 -40
View File
@@ -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,
};
@@ -545,6 +545,30 @@ pub async fn stop_recording(
// above, and only when retention is off.
if !session.retention {
let _ = std::fs::remove_file(&session.wav_path);
} else if crate::vault::is_unlocked() {
// Seal the retained recording at rest when the vault is unlocked (T8.8).
// Runs before the sync enqueue below, so any uploaded copy is ciphertext.
if let Ok(raw) = std::fs::read(&session.wav_path) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(&session.wav_path, sealed);
}
}
}
// Sync-on-finalize (T9.5, FR-SYNC-5): enqueue configured artifacts for
// finalize-trigger targets and pump in the background so stop returns
// promptly. Runs after the WAV-retention decision above, so a deleted
// working recording is simply skipped (its file is gone).
if load_settings().sync_enabled {
let store = state.store.clone();
let app_sync = app.clone();
let mid = meeting_id.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = enqueue_meeting_sync(store.as_ref(), &mid, None, true).await {
tracing::warn!("sync enqueue on finalize failed: {e:?}");
}
pump_sync(&app_sync, store.as_ref()).await;
});
}
crate::update_tray_tooltip(&app, "WhispAssist — idle");
@@ -1387,12 +1411,22 @@ async fn export_meeting_to(
std::fs::create_dir_all(dest_path)
.map_err(|e| WaError::new("export", e.to_string()))?;
let source_dir = meeting_dir(meeting_id);
for name in ["audio.wav", "transcript.json"] {
let src = source_dir.join(name);
if src.exists() {
std::fs::copy(&src, dest_path.join(name))
.map_err(|e| WaError::new("export", e.to_string()))?;
}
// Audio is stored plaintext — copy as-is.
let audio_src = source_dir.join("audio.wav");
if audio_src.exists() {
std::fs::copy(&audio_src, dest_path.join("audio.wav"))
.map_err(|e| WaError::new("export", e.to_string()))?;
}
// transcript.json may be vault-sealed; export the decrypted content
// so the bundle is usable (T8.8).
let transcript_src = source_dir.join("transcript.json");
if transcript_src.exists() {
let bytes = std::fs::read(&transcript_src)
.map_err(|e| WaError::new("export", e.to_string()))?;
let plain =
crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?;
std::fs::write(dest_path.join("transcript.json"), plain)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown)
.map_err(|e| WaError::new("export", e.to_string()))?;
@@ -1629,7 +1663,10 @@ pub async fn generate_summary(
};
let json = serde_json::to_string_pretty(&summary_file)
.map_err(|e| WaError::new("llm", e.to_string()))?;
let _ = std::fs::write(meeting_dir(&meeting_id).join("summary.json"), json);
// Seal at rest when the vault is unlocked (T8.8, FR-SEC-3); passthrough otherwise.
if let Ok(sealed) = crate::vault::seal(json.as_bytes()) {
let _ = std::fs::write(meeting_dir(&meeting_id).join("summary.json"), sealed);
}
let _ = app.emit(
"llm://done",
@@ -1750,59 +1787,676 @@ pub async fn attach_meeting_to_event(
// ---- Sync / upload (Phase 9, ADR-0010) ----
#[tauri::command]
pub async fn list_sync_targets() -> WaResult<Vec<SyncTargetInfo>> {
// Never returns secrets (FR-SYNC-6).
Err(not_implemented("list_sync_targets"))
/// Config payload for add/update (snake_case, matching the TS `SyncTargetConfig`
/// and the other serde structs). The `secret` (app password) is write-only — it
/// goes to the OS store, never back to the UI (FR-SYNC-6).
#[derive(Deserialize, Default)]
pub struct SyncTargetConfigInput {
pub id: Option<String>,
pub name: Option<String>,
pub kind: Option<String>, // webdav (default) | onedrive | dropbox | box
pub provider_hint: Option<String>,
pub base_url: Option<String>,
pub remote_base_path: Option<String>,
pub username: Option<String>,
pub secret: Option<String>,
pub enabled: Option<bool>,
pub upload_transcript: Option<bool>,
pub upload_notes: Option<bool>,
pub upload_summary: Option<bool>,
pub upload_recording: Option<bool>,
pub trigger_on_finalize: Option<bool>,
pub allow_plaintext_lan: Option<bool>,
pub encrypt_before_upload: Option<bool>,
}
#[tauri::command]
pub async fn add_sync_target(_config: serde_json::Value) -> WaResult<SyncTargetInfo> {
// `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<Vec<SyncTargetInfo>> {
// 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<SyncTargetInfo> {
// `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<SyncTargetInfo> {
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: config.encrypt_before_upload.unwrap_or(false),
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<SyncTargetInfo> {
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()))?;
/// Begin OAuth 2.0 PKCE linking for a secondary target (loopback redirect). FR-SYNC-9.
#[tauri::command]
pub async fn begin_oauth_link(_kind: String) -> WaResult<serde_json::Value> {
Err(not_implemented("begin_oauth_link"))
// 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;
}
if let Some(v) = config.encrypt_before_upload {
row.encrypt_before_upload = 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 retry_sync_job(_job_id: String) -> WaResult<()> {
Err(not_implemented("retry_sync_job"))
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()))
}
fn oauth_display_name(kind: &str) -> String {
match kind {
"onedrive" => "OneDrive",
"dropbox" => "Dropbox",
"box" => "Box",
other => other,
}
.to_string()
}
/// Begin OAuth 2.0 Authorization Code + PKCE linking for a secondary target
/// (loopback redirect). Returns the `authUrl` for the frontend to open; the flow
/// finishes in the background and emits `sync://linked` (T9.9, FR-SYNC-9).
#[tauri::command]
pub async fn begin_oauth_link(
app: AppHandle,
state: State<'_, AppState>,
kind: String,
) -> WaResult<serde_json::Value> {
use crate::sync::oauth;
let provider = oauth::provider_for(&kind)
.ok_or_else(|| WaError::new("sync", format!("unknown OAuth provider '{kind}'")))?;
// Requires an app registration; clear error until a client id is configured.
let client_id = provider
.client_id()
.map_err(|e| WaError::new("sync", 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("sync", 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("sync", e.to_string()))?;
// Finish the handshake off-thread: wait for the redirect, exchange the code,
// store tokens, and create the target. The frontend opens `authUrl` and
// listens for `sync://linked`.
let store = state.store.clone();
let app_bg = app.clone();
let verifier = pkce.verifier;
tauri::async_runtime::spawn(async move {
let fail = |msg: String| {
let _ = app_bg.emit(
"sync://linked",
serde_json::json!({ "ok": false, "kind": kind, "error": msg }),
);
};
// Blocking single-shot accept of the browser redirect.
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 id = uuid::Uuid::new_v4().to_string();
let credential_ref = format!("wa-sync-{id}");
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 row = SyncTargetRow {
id,
name: oauth_display_name(&kind),
kind: kind.clone(),
provider_hint: None,
base_url: None,
remote_base_path: "/WhispAssist".to_string(),
username: None,
credential_ref,
enabled: true,
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
created_at: now_unix(),
};
if let Err(e) = store.add_sync_target(row).await {
return fail(e.to_string());
}
let _ = app_bg.emit(
"sync://linked",
serde_json::json!({ "ok": true, "kind": kind.clone() }),
);
});
Ok(serde_json::json!({ "authUrl": auth_url }))
}
#[tauri::command]
pub async fn test_sync_target(_config_or_id: serde_json::Value) -> WaResult<serde_json::Value> {
Err(not_implemented("test_sync_target"))
pub async fn retry_sync_job(
app: AppHandle,
state: State<'_, AppState>,
job_id: String,
) -> WaResult<()> {
let mut job = state
.store
.get_sync_job(&job_id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
// Clear the backoff so the pump picks it up immediately.
job.status = "pending".to_string();
job.next_attempt_at = None;
job.updated_at = now_unix();
state
.store
.update_sync_job(job)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
pump_sync(&app, state.store.as_ref()).await;
Ok(())
}
/// 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(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<serde_json::Value> {
use crate::sync::SyncTarget;
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)
} 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.
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),
};
(Box::new(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)
}
// ---- At-rest encryption vault (Phase 8, T8.8, FR-SEC-3) ----
#[tauri::command]
pub async fn vault_status() -> WaResult<serde_json::Value> {
Ok(serde_json::json!({
"enabled": crate::vault::is_enabled(),
"unlocked": crate::vault::is_unlocked(),
}))
}
#[derive(Deserialize)]
pub struct VaultPasswordArgs {
pub password: String,
}
/// Turn on the vault: derive a key from the password and start sealing artifacts
/// at rest. Leaves the vault unlocked for the session.
#[tauri::command]
pub async fn enable_vault(args: VaultPasswordArgs) -> WaResult<()> {
crate::vault::enable(&args.password).map_err(|e| WaError::new("vault", e.to_string()))
}
#[tauri::command]
pub async fn sync_meeting(_meeting_id: MeetingId, _target_id: Option<String>) -> WaResult<()> {
// Manual "Upload now"; streams progress via "sync://job" events.
Err(not_implemented("sync_meeting"))
pub async fn unlock_vault(args: VaultPasswordArgs) -> WaResult<()> {
crate::vault::unlock(&args.password).map_err(|e| WaError::new("vault", e.to_string()))
}
#[tauri::command]
pub async fn sync_status(_meeting_id: Option<MeetingId>) -> WaResult<Vec<SyncJobInfo>> {
Err(not_implemented("sync_status"))
pub async fn lock_vault() -> WaResult<()> {
crate::vault::lock();
Ok(())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangeVaultPasswordArgs {
pub old_password: String,
pub new_password: String,
}
#[tauri::command]
pub async fn change_vault_password(args: ChangeVaultPasswordArgs) -> WaResult<()> {
crate::vault::change_password(&args.old_password, &args.new_password)
.map_err(|e| WaError::new("vault", e.to_string()))
}
// ---- Sync durable queue (T9.5) ----
/// Give up auto-retrying after this many failures; the job stays `failed` with
/// no `next_attempt_at` until the user hits "retry".
const SYNC_MAX_ATTEMPTS: i64 = 6;
/// Exponential backoff: 30s, 60s, 120s … capped at 1h.
fn sync_backoff_secs(attempts: i64) -> i64 {
(30_i64.saturating_mul(1_i64 << attempts.min(7))).min(3600)
}
fn file_sha256(path: &Path) -> Option<String> {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path).ok()?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Some(format!("{:x}", hasher.finalize()))
}
/// Which (artifact, filename) pairs this target is configured to upload.
fn artifact_plan(target: &SyncTargetRow) -> Vec<(&'static str, &'static str)> {
let mut plan = Vec::new();
if target.upload_transcript {
plan.push(("transcript", "transcript.json"));
}
if target.upload_notes {
plan.push(("notes", "notes.md"));
}
if target.upload_summary {
plan.push(("summary", "summary.json"));
}
if target.upload_recording {
plan.push(("recording", "audio.wav"));
}
plan
}
fn job_to_info(row: &crate::storage::SyncJobRow) -> SyncJobInfo {
SyncJobInfo {
id: row.id.clone(),
target_id: row.target_id.clone(),
meeting_id: row.meeting_id.clone(),
artifact: row.artifact.clone(),
status: row.status.clone(),
attempts: row.attempts as u32,
bytes_sent: row.bytes_sent as u64,
bytes_total: row.bytes_total.map(|b| b as u64),
last_error: row.last_error.clone(),
}
}
fn emit_sync_job(app: &AppHandle, row: &crate::storage::SyncJobRow) {
// camelCase event shape per docs/04-api-contracts.md (`onSyncJob`).
let _ = app.emit(
"sync://job",
serde_json::json!({
"jobId": row.id,
"meetingId": row.meeting_id,
"targetId": row.target_id,
"artifact": row.artifact,
"status": row.status,
"bytesSent": row.bytes_sent,
"bytesTotal": row.bytes_total,
"attempts": row.attempts,
"error": row.last_error,
}),
);
}
/// Enqueue a meeting's configured artifacts for one or all enabled targets
/// (T9.5). `finalize_only` limits to targets with `trigger_on_finalize`. Files
/// that don't exist are skipped; unchanged files are deduped by SHA-256 in the
/// storage upsert. Returns the number of jobs (re)queued.
pub(crate) async fn enqueue_meeting_sync(
store: &dyn crate::storage::Store,
meeting_id: &MeetingId,
target_id: Option<&str>,
finalize_only: bool,
) -> Result<u32, WaError> {
let targets = store
.list_sync_targets()
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
let mut queued = 0u32;
for target in targets.iter().filter(|t| t.enabled) {
if target_id.is_some_and(|id| id != target.id) {
continue;
}
if finalize_only && !target.trigger_on_finalize {
continue;
}
let base = target.remote_base_path.trim_end_matches('/');
for (artifact, filename) in artifact_plan(target) {
let local = meeting_dir(meeting_id).join(filename);
if !local.exists() {
continue;
}
let bytes_total = std::fs::metadata(&local).ok().map(|m| m.len() as i64);
let job = crate::storage::SyncJobRow {
id: uuid::Uuid::new_v4().to_string(),
target_id: target.id.clone(),
meeting_id: meeting_id.clone(),
artifact: artifact.to_string(),
local_path: local.to_string_lossy().into_owned(),
remote_path: format!("{base}/{meeting_id}/{filename}"),
sha256: file_sha256(&local),
status: "pending".to_string(),
attempts: 0,
last_error: None,
next_attempt_at: None,
bytes_total,
bytes_sent: 0,
updated_at: now_unix(),
};
if store
.upsert_sync_job(job)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?
{
queued += 1;
}
}
}
Ok(queued)
}
/// Upload one job's file: ensure the remote dir, PUT, report bytes sent.
async fn upload_job(
target: &dyn crate::sync::SyncTarget,
row: &crate::storage::SyncJobRow,
encrypt: bool,
) -> Result<u64, crate::sync::SyncError> {
use crate::sync::SyncError;
let remote_dir = row
.remote_path
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("");
target.ensure_dir(remote_dir).await?;
let (tx, rx) = std::sync::mpsc::channel();
if encrypt {
// Client-side encryption before upload (T9.12, FR-SYNC-10): seal to a
// temp file (idempotent if already sealed at rest) so the destination
// only ever holds ciphertext. Requires the vault to be unlocked.
let bytes = tokio::fs::read(&row.local_path)
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
let sealed =
crate::vault::ensure_sealed(&bytes).map_err(|e| SyncError::Upload(e.to_string()))?;
let tmp = std::env::temp_dir().join(format!("wa-enc-{}.bin", row.id));
tokio::fs::write(&tmp, &sealed)
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
let result = target.put(&tmp, &row.remote_path, tx).await;
let _ = tokio::fs::remove_file(&tmp).await;
result?;
} else {
target
.put(Path::new(&row.local_path), &row.remote_path, tx)
.await?;
}
let sent = rx
.try_iter()
.last()
.map(|(s, _)| s)
.unwrap_or(row.bytes_total.unwrap_or(0) as u64);
Ok(sent)
}
/// Drive all due jobs once: upload each, emit `sync://job` on every transition,
/// apply exponential backoff on failure (T9.5). Called on finalize, on startup,
/// and on manual upload/retry — never on an idle timer (NFR-RES-1).
pub(crate) async fn pump_sync(app: &AppHandle, store: &dyn crate::storage::Store) {
let due = match store.claim_due_sync_jobs(now_unix()).await {
Ok(jobs) => jobs,
Err(e) => {
tracing::warn!("sync pump: claim failed: {e}");
return;
}
};
for mut job in due {
let target = match store.get_sync_target(&job.target_id).await {
Ok(t) => t,
Err(_) => continue, // target removed under us — its jobs cascaded away
};
job.status = "uploading".to_string();
job.updated_at = now_unix();
let _ = store.update_sync_job(job.clone()).await;
emit_sync_job(app, &job);
let outcome = match crate::sync::build_sync_target(&target) {
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload).await,
Err(e) => Err(e),
};
match outcome {
Ok(sent) => {
job.status = "done".to_string();
job.bytes_sent = sent as i64;
job.last_error = None;
job.next_attempt_at = None;
}
Err(e) => {
job.attempts += 1;
job.last_error = Some(e.to_string());
job.status = "failed".to_string();
job.next_attempt_at = (job.attempts < SYNC_MAX_ATTEMPTS)
.then(|| now_unix() + sync_backoff_secs(job.attempts));
}
}
job.updated_at = now_unix();
let _ = store.update_sync_job(job.clone()).await;
emit_sync_job(app, &job);
}
}
/// Manual "Upload now": (re)enqueue the meeting's artifacts and pump immediately.
/// Streams progress via `sync://job` events.
#[tauri::command]
pub async fn sync_meeting(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
target_id: Option<String>,
) -> WaResult<()> {
enqueue_meeting_sync(
state.store.as_ref(),
&meeting_id,
target_id.as_deref(),
false,
)
.await?;
pump_sync(&app, state.store.as_ref()).await;
Ok(())
}
#[tauri::command]
pub async fn sync_status(
state: State<'_, AppState>,
meeting_id: Option<MeetingId>,
) -> WaResult<Vec<SyncJobInfo>> {
let rows = state
.store
.list_sync_jobs(meeting_id.as_ref())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(rows.iter().map(job_to_info).collect())
}
// ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ----
@@ -1954,13 +2608,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<serde_json::Value> {
pub async fn privacy_self_check(state: State<'_, AppState>) -> WaResult<serde_json::Value> {
let settings = load_settings();
let sync_targets: Vec<SyncTargetInfo> = 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()
};
@@ -1983,6 +2642,20 @@ mod tests {
}
}
#[test]
fn sync_backoff_grows_then_caps_at_one_hour() {
assert_eq!(sync_backoff_secs(0), 30);
assert_eq!(sync_backoff_secs(1), 60);
assert_eq!(sync_backoff_secs(2), 120);
// Monotonic non-decreasing and never above the 1h cap.
let mut prev = 0;
for a in 0..12 {
let b = sync_backoff_secs(a);
assert!(b >= prev && b <= 3600);
prev = b;
}
}
#[test]
fn speaker_infos_lists_distinct_speakers_in_first_appearance_order() {
let segments = vec![segment("S2"), segment("S1"), segment("S2")];
+13
View File
@@ -20,6 +20,7 @@ pub mod reminders;
pub mod storage;
pub mod sync;
pub mod transcription;
pub mod vault;
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
@@ -123,6 +124,7 @@ pub fn run() {
// FR-STORE-2, FR-CAL-5). Spawned so it never blocks the window from
// showing (NFR-PERF-4); nothing here repeats on a timer (NFR-RES-1).
let store = store_for_setup;
let startup_app = app.handle().clone();
tauri::async_runtime::spawn(async move {
match store.recover_scan().await {
Ok(recovered) if !recovered.is_empty() => {
@@ -153,6 +155,12 @@ pub fn run() {
}
Err(e) => tracing::error!("startup reminder reconcile failed: {e}"),
}
// Resume any sync jobs left pending/failed from a previous run
// (T9.5). One-shot on startup — no idle timer (NFR-RES-1).
if commands::load_settings().sync_enabled {
commands::pump_sync(&startup_app, store.as_ref()).await;
}
});
Ok(())
})
@@ -218,6 +226,11 @@ pub fn run() {
commands::get_settings,
commands::update_settings,
commands::privacy_self_check,
commands::vault_status,
commands::enable_vault,
commands::unlock_vault,
commands::lock_vault,
commands::change_vault_password,
])
.run(tauri::generate_context!())
.expect("error while running WhispAssist");
+394 -20
View File
@@ -128,6 +128,49 @@ pub struct Retention {
pub max_size_gb: Option<u32>,
}
/// A configured upload destination as stored in the DB (ADR-0010). Holds only a
/// `credential_ref` into the OS credential store — never the secret itself
/// (FR-SYNC-6). Maps 1:1 to the `sync_targets` table.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct SyncTargetRow {
pub id: String,
pub name: String,
pub kind: String, // webdav|onedrive|dropbox|box
pub provider_hint: Option<String>,
pub base_url: Option<String>,
pub remote_base_path: String,
pub username: Option<String>,
pub credential_ref: String,
pub enabled: bool,
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,
pub created_at: i64,
}
/// One durable upload job (per artifact × per target). Maps to `sync_jobs`.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct SyncJobRow {
pub id: String,
pub target_id: String,
pub meeting_id: MeetingId,
pub artifact: String, // transcript|notes|summary|recording
pub local_path: String,
pub remote_path: String,
pub sha256: Option<String>,
pub status: String, // pending|uploading|done|failed|skipped
pub attempts: i64,
pub last_error: Option<String>,
pub next_attempt_at: Option<i64>,
pub bytes_total: Option<i64>,
pub bytes_sent: i64,
pub updated_at: i64,
}
#[async_trait]
pub trait Store: Send + Sync {
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
@@ -226,6 +269,28 @@ pub trait Store: Send + Sync {
/// Enforce retention; returns count removed. Skips in-progress meetings
/// (anything whose `status` isn't a terminal `ready`/`error`).
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>;
// ---- Sync targets (Phase 9, ADR-0010) ----
async fn add_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError>;
/// Full-row replace (caller loads, edits, and preserves `credential_ref`).
async fn update_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError>;
async fn remove_sync_target(&self, id: &str) -> Result<(), StoreError>;
async fn list_sync_targets(&self) -> Result<Vec<SyncTargetRow>, StoreError>;
async fn get_sync_target(&self, id: &str) -> Result<SyncTargetRow, StoreError>;
// ---- Sync jobs / durable queue (Phase 9, T9.5) ----
/// Enqueue (or re-enqueue) a job. Idempotent per (target, meeting, artifact):
/// a job already `done` with the same `sha256` is left untouched (skip-if-
/// unchanged); anything else is reset to `pending`. Returns true if enqueued.
async fn upsert_sync_job(&self, job: SyncJobRow) -> Result<bool, StoreError>;
/// Jobs ready to run now: `pending`/`failed` with no future `next_attempt_at`.
async fn claim_due_sync_jobs(&self, now: i64) -> Result<Vec<SyncJobRow>, StoreError>;
async fn update_sync_job(&self, job: SyncJobRow) -> Result<(), StoreError>;
async fn get_sync_job(&self, id: &str) -> Result<SyncJobRow, StoreError>;
async fn list_sync_jobs(
&self,
meeting_id: Option<&MeetingId>,
) -> Result<Vec<SyncJobRow>, StoreError>;
}
/// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`).
@@ -254,6 +319,26 @@ impl SqliteStore {
Ok(store)
}
/// In-memory store for tests: one connection, migrations applied, foreign
/// keys off so sync-queue tests can insert jobs without full meeting rows.
#[cfg(test)]
pub async fn connect_in_memory() -> Result<Self, StoreError> {
let opts = SqliteConnectOptions::new().filename(":memory:");
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|e| StoreError::Db(e.to_string()))?;
sqlx::query("PRAGMA foreign_keys = OFF")
.execute(&pool)
.await
.ok();
Ok(Self { pool })
}
/// One-time-per-meeting catch-up for meetings finalized before full-text
/// search existed (T8.2, FR-SEARCH-1) — `finalize_meeting`/`update_notes`
/// keep `meeting_fts` current for everything from here on, but that adds
@@ -392,21 +477,19 @@ impl SqliteStore {
.fetch_one(&self.pool)
.await?;
let transcript_text =
std::fs::read_to_string(paths::meeting_dir(id).join("transcript.json"))
.ok()
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
.map(|t| {
t.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let transcript_text = read_artifact(&paths::meeting_dir(id).join("transcript.json"))
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
.map(|t| {
t.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let notes_text =
std::fs::read_to_string(paths::meeting_dir(id).join("notes.md")).unwrap_or_default();
read_artifact(&paths::meeting_dir(id).join("notes.md")).unwrap_or_default();
sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?")
.bind(id)
@@ -579,7 +662,10 @@ impl Store for SqliteStore {
};
let json =
serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?;
std::fs::write(paths::meeting_dir(id).join("transcript.json"), json)?;
write_artifact(
&paths::meeting_dir(id).join("transcript.json"),
json.as_bytes(),
)?;
self.reindex_fts(id).await?;
let template_id: Option<String> =
sqlx::query_scalar("SELECT template_id FROM meetings WHERE id = ?")
@@ -693,8 +779,7 @@ impl Store for SqliteStore {
.collect();
let folder = paths::meeting_dir(id);
let mut segments = std::fs::read_to_string(folder.join("transcript.json"))
.ok()
let mut segments = read_artifact(&folder.join("transcript.json"))
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
.map(|t| t.segments)
.unwrap_or_default();
@@ -706,9 +791,8 @@ impl Store for SqliteStore {
seg.speaker = resolve_canonical(&seg.speaker, &merge_map).to_string();
}
}
let notes_markdown = std::fs::read_to_string(folder.join("notes.md")).unwrap_or_default();
let summary = std::fs::read_to_string(folder.join("summary.json"))
.ok()
let notes_markdown = read_artifact(&folder.join("notes.md")).unwrap_or_default();
let summary = read_artifact(&folder.join("summary.json"))
.and_then(|s| serde_json::from_str::<SummaryFile>(&s).ok());
let tags = self.tags_for_meeting(id).await?;
@@ -752,7 +836,10 @@ impl Store for SqliteStore {
}
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> {
std::fs::write(paths::meeting_dir(id).join("notes.md"), markdown)?;
write_artifact(
&paths::meeting_dir(id).join("notes.md"),
markdown.as_bytes(),
)?;
sqlx::query("UPDATE meetings SET updated_at = ? WHERE id = ?")
.bind(now_unix())
.bind(id)
@@ -1189,6 +1276,198 @@ impl Store for SqliteStore {
Ok(removed)
}
async fn add_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError> {
sqlx::query(
"INSERT INTO sync_targets (id, name, kind, provider_hint, base_url, remote_base_path, \
username, credential_ref, enabled, upload_transcript, upload_notes, upload_summary, \
upload_recording, trigger_on_finalize, allow_plaintext_lan, encrypt_before_upload, created_at) \
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(&row.id)
.bind(&row.name)
.bind(&row.kind)
.bind(&row.provider_hint)
.bind(&row.base_url)
.bind(&row.remote_base_path)
.bind(&row.username)
.bind(&row.credential_ref)
.bind(row.enabled)
.bind(row.upload_transcript)
.bind(row.upload_notes)
.bind(row.upload_summary)
.bind(row.upload_recording)
.bind(row.trigger_on_finalize)
.bind(row.allow_plaintext_lan)
.bind(row.encrypt_before_upload)
.bind(row.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError> {
sqlx::query(
"UPDATE sync_targets SET name=?, kind=?, provider_hint=?, base_url=?, remote_base_path=?, \
username=?, credential_ref=?, enabled=?, upload_transcript=?, upload_notes=?, \
upload_summary=?, upload_recording=?, trigger_on_finalize=?, allow_plaintext_lan=?, \
encrypt_before_upload=? WHERE id=?",
)
.bind(&row.name)
.bind(&row.kind)
.bind(&row.provider_hint)
.bind(&row.base_url)
.bind(&row.remote_base_path)
.bind(&row.username)
.bind(&row.credential_ref)
.bind(row.enabled)
.bind(row.upload_transcript)
.bind(row.upload_notes)
.bind(row.upload_summary)
.bind(row.upload_recording)
.bind(row.trigger_on_finalize)
.bind(row.allow_plaintext_lan)
.bind(row.encrypt_before_upload)
.bind(&row.id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_sync_target(&self, id: &str) -> Result<(), StoreError> {
// ON DELETE CASCADE clears this target's sync_jobs too.
sqlx::query("DELETE FROM sync_targets WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_sync_targets(&self) -> Result<Vec<SyncTargetRow>, StoreError> {
let rows = sqlx::query_as::<_, SyncTargetRow>(
"SELECT * FROM sync_targets ORDER BY created_at ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn get_sync_target(&self, id: &str) -> Result<SyncTargetRow, StoreError> {
let row = sqlx::query_as::<_, SyncTargetRow>("SELECT * FROM sync_targets WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(format!("sync target {id}")))?;
Ok(row)
}
async fn upsert_sync_job(&self, job: SyncJobRow) -> Result<bool, StoreError> {
// ON CONFLICT: re-enqueue as pending, EXCEPT when the row is already
// `done` with the same content hash — that's the skip-if-unchanged case
// (`IS NOT` is SQLite's null-safe comparison).
let res = sqlx::query(
"INSERT INTO sync_jobs (id, target_id, meeting_id, artifact, local_path, remote_path, \
sha256, status, attempts, last_error, next_attempt_at, bytes_total, bytes_sent, updated_at) \
VALUES (?,?,?,?,?,?,?, 'pending', 0, NULL, NULL, ?, 0, ?) \
ON CONFLICT(target_id, meeting_id, artifact) DO UPDATE SET \
local_path=excluded.local_path, remote_path=excluded.remote_path, sha256=excluded.sha256, \
status='pending', attempts=0, last_error=NULL, next_attempt_at=NULL, \
bytes_total=excluded.bytes_total, bytes_sent=0, updated_at=excluded.updated_at \
WHERE sync_jobs.status != 'done' OR sync_jobs.sha256 IS NOT excluded.sha256",
)
.bind(&job.id)
.bind(&job.target_id)
.bind(&job.meeting_id)
.bind(&job.artifact)
.bind(&job.local_path)
.bind(&job.remote_path)
.bind(&job.sha256)
.bind(job.bytes_total)
.bind(job.updated_at)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn claim_due_sync_jobs(&self, now: i64) -> Result<Vec<SyncJobRow>, StoreError> {
let rows = sqlx::query_as::<_, SyncJobRow>(
"SELECT * FROM sync_jobs WHERE status IN ('pending','failed') \
AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY updated_at ASC",
)
.bind(now)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn update_sync_job(&self, job: SyncJobRow) -> Result<(), StoreError> {
sqlx::query(
"UPDATE sync_jobs SET status=?, attempts=?, last_error=?, next_attempt_at=?, \
bytes_total=?, bytes_sent=?, sha256=?, local_path=?, remote_path=?, updated_at=? WHERE id=?",
)
.bind(&job.status)
.bind(job.attempts)
.bind(&job.last_error)
.bind(job.next_attempt_at)
.bind(job.bytes_total)
.bind(job.bytes_sent)
.bind(&job.sha256)
.bind(&job.local_path)
.bind(&job.remote_path)
.bind(job.updated_at)
.bind(&job.id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_sync_job(&self, id: &str) -> Result<SyncJobRow, StoreError> {
sqlx::query_as::<_, SyncJobRow>("SELECT * FROM sync_jobs WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(format!("sync job {id}")))
}
async fn list_sync_jobs(
&self,
meeting_id: Option<&MeetingId>,
) -> Result<Vec<SyncJobRow>, StoreError> {
let rows = match meeting_id {
Some(m) => {
sqlx::query_as::<_, SyncJobRow>(
"SELECT * FROM sync_jobs WHERE meeting_id = ? ORDER BY updated_at DESC",
)
.bind(m)
.fetch_all(&self.pool)
.await?
}
None => {
sqlx::query_as::<_, SyncJobRow>("SELECT * FROM sync_jobs ORDER BY updated_at DESC")
.fetch_all(&self.pool)
.await?
}
};
Ok(rows)
}
}
/// Read a derived artifact, transparently decrypting it if the vault sealed it
/// (T8.8, FR-SEC-3). Returns `None` if the file is missing or can't be
/// decrypted (vault locked / wrong key) — callers already treat a missing
/// artifact as empty, so a locked vault degrades to "no content" rather than a
/// crash.
fn read_artifact(path: &std::path::Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
let plain = crate::vault::open(&bytes).ok()?;
Some(String::from_utf8_lossy(&plain).into_owned())
}
/// Write a derived artifact, sealing it when the vault is unlocked (a no-op
/// passthrough otherwise). Audio is not sealed here — it's handled separately.
fn write_artifact(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
let sealed = crate::vault::seal(contents).map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::write(path, sealed)
}
fn dir_size(path: &str) -> u64 {
@@ -1205,6 +1484,101 @@ fn dir_size(path: &str) -> u64 {
mod tests {
use super::*;
fn sample_target(id: &str) -> SyncTargetRow {
SyncTargetRow {
id: id.to_string(),
name: "t".to_string(),
kind: "webdav".to_string(),
provider_hint: None,
base_url: Some("https://host/dav".to_string()),
remote_base_path: "/WhispAssist".to_string(),
username: Some("u".to_string()),
credential_ref: format!("wa-sync-{id}"),
enabled: true,
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
created_at: 0,
}
}
fn sample_job(id: &str, sha: &str) -> SyncJobRow {
SyncJobRow {
id: id.to_string(),
target_id: "t1".to_string(),
meeting_id: "m1".to_string(),
artifact: "transcript".to_string(),
local_path: "/x/transcript.json".to_string(),
remote_path: "/WhispAssist/m1/transcript.json".to_string(),
sha256: Some(sha.to_string()),
status: "pending".to_string(),
attempts: 0,
last_error: None,
next_attempt_at: None,
bytes_total: Some(10),
bytes_sent: 0,
updated_at: 0,
}
}
#[tokio::test]
async fn sync_target_crud_round_trips() {
let store = SqliteStore::connect_in_memory().await.unwrap();
store.add_sync_target(sample_target("t1")).await.unwrap();
let listed = store.list_sync_targets().await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "t1");
store.remove_sync_target("t1").await.unwrap();
assert!(store.list_sync_targets().await.unwrap().is_empty());
}
#[tokio::test]
async fn upsert_skips_unchanged_done_job_and_reenqueues_changed() {
let store = SqliteStore::connect_in_memory().await.unwrap();
store.add_sync_target(sample_target("t1")).await.unwrap();
// New job → enqueued.
assert!(store.upsert_sync_job(sample_job("j1", "A")).await.unwrap());
// Mark it done, then re-upsert the same content (same sha) → skipped.
let mut done = store.list_sync_jobs(None).await.unwrap().remove(0);
done.status = "done".to_string();
store.update_sync_job(done).await.unwrap();
assert!(
!store.upsert_sync_job(sample_job("j2", "A")).await.unwrap(),
"an unchanged, already-done artifact must not re-enqueue"
);
// Changed content (new sha) → re-enqueued as pending, still one row.
assert!(store.upsert_sync_job(sample_job("j3", "B")).await.unwrap());
let jobs = store.list_sync_jobs(None).await.unwrap();
assert_eq!(
jobs.len(),
1,
"UNIQUE(target,meeting,artifact) keeps one row"
);
assert_eq!(jobs[0].status, "pending");
assert_eq!(jobs[0].sha256.as_deref(), Some("B"));
}
#[tokio::test]
async fn claim_due_skips_jobs_with_a_future_backoff() {
let store = SqliteStore::connect_in_memory().await.unwrap();
store.add_sync_target(sample_target("t1")).await.unwrap();
store.upsert_sync_job(sample_job("j1", "A")).await.unwrap();
// Push it into the future with a failed+backoff state.
let mut job = store.list_sync_jobs(None).await.unwrap().remove(0);
job.status = "failed".to_string();
job.next_attempt_at = Some(10_000);
store.update_sync_job(job).await.unwrap();
assert!(store.claim_due_sync_jobs(5_000).await.unwrap().is_empty());
assert_eq!(store.claim_due_sync_jobs(20_000).await.unwrap().len(), 1);
}
#[test]
fn fts_query_from_input_quotes_each_token_as_a_literal_phrase() {
assert_eq!(
+516 -16
View File
@@ -9,10 +9,15 @@
//! - TLS required; plaintext http only via explicit per-target LAN opt-in.
//! - `SyncManager` derives the egress allowlist from enabled targets.
#[cfg(feature = "sync")]
use crate::models::SyncTargetInfo;
use crate::models::{MeetingId, SyncJobInfo, SyncKind};
use async_trait::async_trait;
use std::path::Path;
#[cfg(feature = "sync")]
pub mod oauth;
#[derive(Debug, thiserror::Error)]
pub enum SyncError {
#[error("target unreachable: {0}")]
@@ -78,6 +83,46 @@ pub struct WebDavTarget {
pub allow_plaintext_lan: bool,
}
#[cfg(feature = "sync")]
impl WebDavTarget {
/// Build from a stored target row (secret stays in the OS store, resolved at
/// use via `credential_ref`).
pub fn from_row(row: &crate::storage::SyncTargetRow) -> Self {
Self {
base_url: row.base_url.clone().unwrap_or_default(),
remote_base_path: row.remote_base_path.clone(),
username: row.username.clone().unwrap_or_default(),
credential_ref: row.credential_ref.clone(),
third_party: row.kind != "webdav",
allow_plaintext_lan: row.allow_plaintext_lan,
}
}
/// 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 path = remote_path.trim_start_matches('/');
format!("{base}/{path}")
}
/// Resolves the secret and returns an authenticated request builder, after
/// enforcing TLS (or an explicit LAN plaintext opt-in).
fn request(
&self,
method: reqwest::Method,
url: &str,
) -> Result<reqwest::RequestBuilder, SyncError> {
enforce_transport(&self.base_url, self.allow_plaintext_lan)?;
let mut req = http_client()?.request(method, url);
// Send Basic auth only when a secret is stored — an anonymous target
// (no credential) is valid and must not hard-fail on a missing entry.
if let Ok(secret) = credentials::get(&self.credential_ref) {
req = req.basic_auth(&self.username, Some(secret));
}
Ok(req)
}
}
#[cfg(feature = "sync")]
#[async_trait]
impl SyncTarget for WebDavTarget {
@@ -87,32 +132,487 @@ impl SyncTarget for WebDavTarget {
fn is_third_party(&self) -> bool {
self.third_party
}
/// Reachability + auth check: PROPFIND depth 0 on the base path (T9.4).
async fn test(&self) -> Result<(), SyncError> {
// T9.4: PROPFIND base path; verify TLS (or explicit LAN opt-in); resolve creds from keyring.
todo!("Phase 9 — WebDAV test connection")
let method = reqwest::Method::from_bytes(b"PROPFIND")
.expect("PROPFIND is a valid HTTP method token");
let url = self.url_for(&self.remote_base_path);
let resp = self
.request(method, &url)?
.header("Depth", "0")
.send()
.await
.map_err(|e| SyncError::Unreachable(e.to_string()))?;
match resp.status().as_u16() {
401 | 403 => Err(SyncError::Auth),
// 207 Multi-Status is the success case; 404 means the base dir just
// doesn't exist yet (auth still worked) — creation is ensure_dir's job.
207 | 200 | 301 | 404 | 405 => Ok(()),
other => Err(SyncError::Unreachable(format!("PROPFIND {other}"))),
}
}
async fn ensure_dir(&self, _remote_dir: &str) -> Result<(), SyncError> {
// T9.2: MKCOL the meeting folder path.
todo!("Phase 9 — WebDAV ensure_dir (MKCOL)")
/// Create each missing path segment with MKCOL (idempotent — an existing
/// collection returns 405, which we treat as success).
async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError> {
let method =
reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method token");
let mut cumulative = String::new();
for segment in remote_dir.split('/').filter(|s| !s.is_empty()) {
cumulative.push('/');
cumulative.push_str(segment);
let url = self.url_for(&cumulative);
let resp = self
.request(method.clone(), &url)?
.send()
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
match resp.status().as_u16() {
// 201 created, 405 already exists, 301 redirect-to-existing.
201 | 405 | 301 => {}
401 | 403 => return Err(SyncError::Auth),
other => return Err(SyncError::Upload(format!("MKCOL {other}"))),
}
}
Ok(())
}
async fn exists(&self, _remote_path: &str, _sha256: &str) -> Result<bool, SyncError> {
todo!("Phase 9 — WebDAV exists / hash compare")
/// Whether the remote file is already present. WebDAV can't return a SHA-256,
/// so this is a presence check (HEAD); content-level skip is decided by the
/// manager from the recorded job hash.
async fn exists(&self, remote_path: &str, _sha256: &str) -> Result<bool, SyncError> {
let url = self.url_for(remote_path);
let resp = self
.request(reqwest::Method::HEAD, &url)?
.send()
.await
.map_err(|e| SyncError::Unreachable(e.to_string()))?;
Ok(resp.status().is_success())
}
/// Upload a file via PUT.
///
/// ponytail: single PUT reading the file into memory — fine for
/// transcript/notes/summary. Chunked/resumable upload for large `.wav`
/// recordings is a later refinement (Nextcloud chunked upload).
async fn put(
&self,
_local: &Path,
_remote_path: &str,
_prog: ProgressSink,
local: &Path,
remote_path: &str,
prog: ProgressSink,
) -> Result<(), SyncError> {
// T9.2/T9.5: PUT (chunked for large files); stream progress.
todo!("Phase 9 — WebDAV upload")
let bytes = tokio::fs::read(local)
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
let total = bytes.len() as u64;
let url = self.url_for(remote_path);
let resp = self
.request(reqwest::Method::PUT, &url)?
.body(bytes)
.send()
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
match resp.status().as_u16() {
code if (200..300).contains(&code) => {
let _ = prog.send((total, total));
Ok(())
}
401 | 403 => Err(SyncError::Auth),
other => Err(SyncError::Upload(format!("PUT {other}"))),
}
}
}
// Secondary OAuth providers (Phase 9b) — same trait, separate impls.
/// Shared HTTP client (rustls TLS via reqwest's `rustls-tls` feature).
#[cfg(feature = "sync")]
pub struct OneDriveTarget; // MS Graph
fn http_client() -> Result<reqwest::Client, SyncError> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| SyncError::Upload(e.to_string()))
}
/// TLS enforcement (T9.7, FR-SYNC-7): https always allowed; plaintext http only
/// for a LAN host when the target explicitly opted in; anything else refused.
#[cfg(feature = "sync")]
pub struct DropboxTarget;
fn enforce_transport(base_url: &str, allow_plaintext_lan: bool) -> Result<(), SyncError> {
let url =
reqwest::Url::parse(base_url).map_err(|e| SyncError::Upload(format!("bad url: {e}")))?;
match url.scheme() {
"https" => Ok(()),
"http" if allow_plaintext_lan && url.host_str().is_some_and(is_lan_host) => Ok(()),
"http" => Err(SyncError::InsecureTransport),
other => Err(SyncError::Upload(format!("unsupported scheme {other}"))),
}
}
/// Heuristic "is this a private/LAN address" for the plaintext opt-in: loopback,
/// RFC-1918 ranges, `.local`, and bare (dotless) hostnames.
#[cfg(feature = "sync")]
pub struct BoxTarget;
fn is_lan_host(host: &str) -> bool {
if host == "localhost" || host.ends_with(".local") || !host.contains('.') {
return true;
}
if let Ok(ip) = host.parse::<std::net::Ipv4Addr>() {
return ip.is_loopback() || ip.is_private() || ip.is_link_local();
}
host.parse::<std::net::Ipv6Addr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
/// UI-safe view of a stored target — never carries the secret (FR-SYNC-6).
#[cfg(feature = "sync")]
pub fn row_to_info(row: &crate::storage::SyncTargetRow) -> SyncTargetInfo {
let host = row
.base_url
.as_deref()
.and_then(|u| reqwest::Url::parse(u).ok())
.and_then(|u| u.host_str().map(str::to_string));
SyncTargetInfo {
id: row.id.clone(),
name: row.name.clone(),
kind: kind_from_str(&row.kind),
provider_hint: row.provider_hint.clone(),
base_url: row.base_url.clone(),
remote_base_path: row.remote_base_path.clone(),
username: row.username.clone(),
enabled: row.enabled,
third_party: row.kind != "webdav",
host,
}
}
#[cfg(feature = "sync")]
fn kind_from_str(s: &str) -> SyncKind {
match s {
"onedrive" => SyncKind::OneDrive,
"dropbox" => SyncKind::Dropbox,
"box" => SyncKind::Box,
_ => SyncKind::WebDav,
}
}
/// Secrets live in the OS credential store (Windows Credential Manager via DPAPI),
/// keyed by the target's `credential_ref` — never in settings.json/DB/logs (FR-SYNC-6).
#[cfg(feature = "sync")]
pub mod credentials {
use super::SyncError;
const SERVICE: &str = "WhispAssist-sync";
fn entry(credential_ref: &str) -> Result<keyring::Entry, SyncError> {
keyring::Entry::new(SERVICE, credential_ref)
.map_err(|e| SyncError::Credential(e.to_string()))
}
pub fn set(credential_ref: &str, secret: &str) -> Result<(), SyncError> {
entry(credential_ref)?
.set_password(secret)
.map_err(|e| SyncError::Credential(e.to_string()))
}
pub fn get(credential_ref: &str) -> Result<String, SyncError> {
entry(credential_ref)?
.get_password()
.map_err(|e| SyncError::Credential(e.to_string()))
}
pub fn delete(credential_ref: &str) -> Result<(), SyncError> {
// Missing entry is fine — deletion is best-effort cleanup.
match entry(credential_ref)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(SyncError::Credential(e.to_string())),
}
}
}
#[cfg(all(test, feature = "sync"))]
mod tests {
use super::*;
#[test]
fn https_is_always_allowed() {
assert!(enforce_transport("https://cloud.example.com/dav", false).is_ok());
}
#[test]
fn plaintext_http_is_refused_by_default() {
assert!(matches!(
enforce_transport("http://cloud.example.com/dav", false),
Err(SyncError::InsecureTransport)
));
}
#[test]
fn plaintext_http_allowed_only_for_lan_with_optin() {
// LAN host + opt-in: allowed.
assert!(enforce_transport("http://192.168.1.10/dav", true).is_ok());
assert!(enforce_transport("http://nas.local/dav", true).is_ok());
assert!(enforce_transport("http://localhost:8080/dav", true).is_ok());
// Public host even with opt-in: still refused (opt-in is LAN-only).
assert!(matches!(
enforce_transport("http://cloud.example.com/dav", true),
Err(SyncError::InsecureTransport)
));
}
#[test]
fn lan_host_classification() {
assert!(is_lan_host("localhost"));
assert!(is_lan_host("nas.local"));
assert!(is_lan_host("mynas")); // dotless bare hostname
assert!(is_lan_host("10.0.0.5"));
assert!(is_lan_host("172.16.4.4"));
assert!(!is_lan_host("8.8.8.8"));
assert!(!is_lan_host("cloud.example.com"));
}
#[test]
fn url_join_normalizes_slashes() {
let t = WebDavTarget {
base_url: "https://host/dav/".to_string(),
remote_base_path: "/WhispAssist".to_string(),
username: "u".to_string(),
credential_ref: "r".to_string(),
third_party: false,
allow_plaintext_lan: false,
};
assert_eq!(
t.url_for("/WhispAssist/m1/notes.md"),
"https://host/dav/WhispAssist/m1/notes.md"
);
}
/// 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`
/// (or with a user/pass). Run manually — see the increment-1 test notes.
#[tokio::test]
#[ignore = "requires a live WebDAV server; run manually"]
async fn webdav_round_trip() {
let Ok(base_url) = std::env::var("WA_WEBDAV_URL") else {
eprintln!("skip: set WA_WEBDAV_URL");
return;
};
let user = std::env::var("WA_WEBDAV_USER").unwrap_or_default();
let pass = std::env::var("WA_WEBDAV_PASS").unwrap_or_default();
let cred_ref = "wa-sync-test-roundtrip";
// Anonymous servers have no password; only store one when provided
// (Windows credential store won't persist an empty secret anyway).
if !pass.is_empty() {
credentials::set(cred_ref, &pass).unwrap();
}
let target = WebDavTarget {
base_url,
remote_base_path: "/WhispAssist-test".to_string(),
username: user,
credential_ref: cred_ref.to_string(),
third_party: false,
allow_plaintext_lan: true,
};
target.test().await.expect("test connection");
target
.ensure_dir("/WhispAssist-test/m1")
.await
.expect("mkcol");
let tmp = std::env::temp_dir().join("wa_webdav_probe.txt");
std::fs::write(&tmp, b"hello webdav").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
target
.put(&tmp, "/WhispAssist-test/m1/probe.txt", tx)
.await
.expect("put");
assert!(rx.recv().is_ok(), "progress reported");
assert!(
target
.exists("/WhispAssist-test/m1/probe.txt", "")
.await
.unwrap(),
"uploaded file should exist remotely"
);
let _ = credentials::delete(cred_ref);
}
}
#[cfg(feature = "sync")]
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Resolve a live access token for an OAuth target, refreshing (and re-storing)
/// it if it's expired. Tokens live in the OS credential store as JSON.
#[cfg(feature = "sync")]
async fn resolve_access_token(kind: &str, credential_ref: &str) -> Result<String, SyncError> {
let provider = oauth::provider_for(kind)
.ok_or_else(|| SyncError::Upload(format!("unknown oauth provider {kind}")))?;
let client_id = provider.client_id()?;
let json = credentials::get(credential_ref)?;
let mut tokens: oauth::TokenSet =
serde_json::from_str(&json).map_err(|e| SyncError::Credential(e.to_string()))?;
if tokens.is_expired(now_unix()) {
let refresh = tokens.refresh_token.clone().ok_or(SyncError::Auth)?;
tokens = oauth::refresh_tokens(&provider, &client_id, &refresh).await?;
let updated =
serde_json::to_string(&tokens).map_err(|e| SyncError::Credential(e.to_string()))?;
credentials::set(credential_ref, &updated)?;
}
Ok(tokens.access_token)
}
/// Construct the concrete `SyncTarget` for a stored row by its `kind`. This is
/// the one place that maps kind → provider impl (the pump/test dispatch here).
#[cfg(feature = "sync")]
pub fn build_sync_target(
row: &crate::storage::SyncTargetRow,
) -> Result<Box<dyn SyncTarget>, SyncError> {
match row.kind.as_str() {
"webdav" => Ok(Box::new(WebDavTarget::from_row(row))),
"onedrive" => Ok(Box::new(OneDriveTarget::from_row(row))),
// Linking works (tokens stored); upload lands next, same trait.
"dropbox" | "box" => Err(SyncError::Upload(format!(
"{} upload is not implemented yet (OAuth linking works)",
row.kind
))),
other => Err(SyncError::Upload(format!(
"unknown sync target kind {other}"
))),
}
}
// ---- Secondary OAuth providers (Phase 9b) — same trait, separate impls ----
/// OneDrive via Microsoft Graph. The reference OAuth provider impl.
#[cfg(feature = "sync")]
pub struct OneDriveTarget {
pub credential_ref: String,
}
#[cfg(feature = "sync")]
impl OneDriveTarget {
pub fn from_row(row: &crate::storage::SyncTargetRow) -> Self {
Self {
credential_ref: row.credential_ref.clone(),
}
}
async fn bearer(&self) -> Result<String, SyncError> {
resolve_access_token("onedrive", &self.credential_ref).await
}
/// Graph path addressing: `…/root:/{path}{suffix}` (suffix `""` = the item,
/// `:/content` = its bytes, `:/children` = its child collection).
fn item_url(remote_path: &str, suffix: &str) -> String {
let p = remote_path.trim_start_matches('/');
format!("https://graph.microsoft.com/v1.0/me/drive/root:/{p}{suffix}")
}
}
#[cfg(feature = "sync")]
#[async_trait]
impl SyncTarget for OneDriveTarget {
fn kind(&self) -> SyncKind {
SyncKind::OneDrive
}
fn is_third_party(&self) -> bool {
true
}
async fn test(&self) -> Result<(), SyncError> {
let token = self.bearer().await?;
let resp = http_client()?
.get("https://graph.microsoft.com/v1.0/me/drive")
.bearer_auth(token)
.send()
.await
.map_err(|e| SyncError::Unreachable(e.to_string()))?;
match resp.status().as_u16() {
code if (200..300).contains(&code) => Ok(()),
401 | 403 => Err(SyncError::Auth),
other => Err(SyncError::Unreachable(format!("graph {other}"))),
}
}
async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError> {
let token = self.bearer().await?;
let mut parent = String::new();
for segment in remote_dir.split('/').filter(|s| !s.is_empty()) {
let url = if parent.is_empty() {
"https://graph.microsoft.com/v1.0/me/drive/root/children".to_string()
} else {
format!("https://graph.microsoft.com/v1.0/me/drive/root:/{parent}:/children")
};
let body = serde_json::json!({
"name": segment,
"folder": {},
"@microsoft.graph.conflictBehavior": "replace",
});
let resp = http_client()?
.post(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
match resp.status().as_u16() {
200 | 201 | 409 => {} // created, or already exists
401 | 403 => return Err(SyncError::Auth),
other => return Err(SyncError::Upload(format!("mkdir {other}"))),
}
parent = if parent.is_empty() {
segment.to_string()
} else {
format!("{parent}/{segment}")
};
}
Ok(())
}
async fn exists(&self, remote_path: &str, _sha256: &str) -> Result<bool, SyncError> {
let token = self.bearer().await?;
let resp = http_client()?
.get(Self::item_url(remote_path, ""))
.bearer_auth(token)
.send()
.await
.map_err(|e| SyncError::Unreachable(e.to_string()))?;
Ok(resp.status().is_success())
}
/// ponytail: simple content upload (Graph caps this at 250 MB). A resumable
/// upload session for larger recordings is a follow-up.
async fn put(
&self,
local: &Path,
remote_path: &str,
prog: ProgressSink,
) -> Result<(), SyncError> {
let token = self.bearer().await?;
let bytes = tokio::fs::read(local)
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
let total = bytes.len() as u64;
let resp = http_client()?
.put(Self::item_url(remote_path, ":/content"))
.bearer_auth(token)
.body(bytes)
.send()
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
match resp.status().as_u16() {
code if (200..300).contains(&code) => {
let _ = prog.send((total, total));
Ok(())
}
401 | 403 => Err(SyncError::Auth),
other => Err(SyncError::Upload(format!("PUT {other}"))),
}
}
}
+406
View File
@@ -0,0 +1,406 @@
//! OAuth 2.0 Authorization Code + PKCE with a loopback redirect (Phase 9b,
//! ADR-0010, FR-SYNC-9). Desktop-appropriate: no client secret, a random
//! loopback port for the redirect, and tokens kept in the OS credential store.
//!
//! Provider-agnostic core (PKCE, auth-URL, token exchange/refresh, loopback
//! capture) is here and unit-tested. The concrete providers (OneDrive/Dropbox/
//! Box) are `SyncTarget` impls in `mod.rs`. **Client IDs come from the
//! environment** (`WA_OAUTH_<PROVIDER>_CLIENT_ID`) — an OAuth app registration
//! is required per provider; until one is set, `begin_oauth_link` errors clearly
//! rather than pretending.
use super::SyncError;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// A provider's OAuth endpoints + the scopes we request.
pub struct OAuthProvider {
pub kind: &'static str, // onedrive|dropbox|box
pub auth_endpoint: &'static str,
pub token_endpoint: &'static str,
pub scopes: &'static [&'static str],
pub client_id_env: &'static str,
}
pub fn provider_for(kind: &str) -> Option<OAuthProvider> {
match kind {
"onedrive" => Some(OAuthProvider {
kind: "onedrive",
auth_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
token_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
// offline_access → refresh token; Files.ReadWrite → user's OneDrive.
scopes: &["offline_access", "Files.ReadWrite", "User.Read"],
client_id_env: "WA_OAUTH_ONEDRIVE_CLIENT_ID",
}),
"dropbox" => Some(OAuthProvider {
kind: "dropbox",
auth_endpoint: "https://www.dropbox.com/oauth2/authorize",
token_endpoint: "https://api.dropboxapi.com/oauth2/token",
scopes: &["files.content.write", "files.content.read"],
client_id_env: "WA_OAUTH_DROPBOX_CLIENT_ID",
}),
"box" => Some(OAuthProvider {
kind: "box",
auth_endpoint: "https://account.box.com/api/oauth2/authorize",
token_endpoint: "https://api.box.com/oauth2/token",
scopes: &["root_readwrite"],
client_id_env: "WA_OAUTH_BOX_CLIENT_ID",
}),
_ => None,
}
}
impl OAuthProvider {
/// The registered client id, from the environment. Empty/unset → the flow
/// can't proceed (an app registration is required).
pub fn client_id(&self) -> Result<String, SyncError> {
std::env::var(self.client_id_env)
.ok()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
SyncError::Credential(format!(
"OAuth client id for {} not configured (set {})",
self.kind, self.client_id_env
))
})
}
}
/// Tokens as stored (JSON) in the OS credential store, keyed by the target's
/// `credential_ref`. `expires_at` is a unix timestamp for proactive refresh.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenSet {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub expires_at: i64,
}
impl TokenSet {
pub fn is_expired(&self, now: i64) -> bool {
// 60s skew so we refresh just before the token actually lapses.
self.expires_at != 0 && now >= self.expires_at - 60
}
}
/// PKCE verifier + S256 challenge (RFC 7636).
pub struct Pkce {
pub verifier: String,
pub challenge: String,
}
pub fn pkce_pair() -> Pkce {
// 32 bytes of randomness → 43-char base64url verifier (uuid v4 is CSPRNG-backed).
let mut bytes = [0u8; 32];
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
let verifier = base64url_nopad(&bytes);
Pkce {
challenge: challenge_for(&verifier),
verifier,
}
}
fn challenge_for(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
base64url_nopad(&hasher.finalize())
}
/// Base64url, no padding (RFC 4648 §5) — PKCE's required encoding.
fn base64url_nopad(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
let idx = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63];
for &i in idx.iter().take(chunk.len() + 1) {
out.push(CHARS[i as usize] as char);
}
}
out
}
/// The authorization URL to open in the user's browser.
pub fn build_auth_url(
provider: &OAuthProvider,
client_id: &str,
redirect_uri: &str,
challenge: &str,
state: &str,
) -> Result<String, SyncError> {
let scope = provider.scopes.join(" ");
let url = reqwest::Url::parse_with_params(
provider.auth_endpoint,
&[
("response_type", "code"),
("client_id", client_id),
("redirect_uri", redirect_uri),
("scope", &scope),
("code_challenge", challenge),
("code_challenge_method", "S256"),
("state", state),
],
)
.map_err(|e| SyncError::Upload(format!("bad auth url: {e}")))?;
Ok(url.to_string())
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<i64>,
}
fn token_set_from(resp: TokenResponse, now: i64, prior_refresh: Option<String>) -> TokenSet {
TokenSet {
access_token: resp.access_token,
// Providers omit a new refresh token on refresh — keep the prior one.
refresh_token: resp.refresh_token.or(prior_refresh),
expires_at: resp.expires_in.map(|s| now + s).unwrap_or(0),
}
}
/// Exchange an authorization code for tokens.
pub async fn exchange_code(
provider: &OAuthProvider,
client_id: &str,
code: &str,
verifier: &str,
redirect_uri: &str,
) -> Result<TokenSet, SyncError> {
let resp = super::http_client()?
.post(provider.token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("client_id", client_id),
("code", code),
("code_verifier", verifier),
("redirect_uri", redirect_uri),
])
.send()
.await
.map_err(|e| SyncError::Unreachable(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth);
}
let body: TokenResponse = resp
.json()
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
Ok(token_set_from(body, now_unix(), None))
}
/// Refresh an access token using the stored refresh token.
pub async fn refresh_tokens(
provider: &OAuthProvider,
client_id: &str,
refresh_token: &str,
) -> Result<TokenSet, SyncError> {
let resp = super::http_client()?
.post(provider.token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("client_id", client_id),
("refresh_token", refresh_token),
])
.send()
.await
.map_err(|e| SyncError::Unreachable(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth);
}
let body: TokenResponse = resp
.json()
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
Ok(token_set_from(
body,
now_unix(),
Some(refresh_token.to_string()),
))
}
/// Binds a loopback listener and waits (once) for the OAuth redirect, returning
/// the `code` after checking `state`. The port is chosen by the OS so the
/// redirect URI is `http://127.0.0.1:<port>/callback`.
pub struct LoopbackRedirect {
listener: std::net::TcpListener,
pub redirect_uri: String,
}
impl LoopbackRedirect {
pub fn bind() -> Result<Self, SyncError> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| SyncError::Upload(format!("loopback bind: {e}")))?;
let port = listener
.local_addr()
.map_err(|e| SyncError::Upload(e.to_string()))?
.port();
Ok(Self {
listener,
redirect_uri: format!("http://127.0.0.1:{port}/callback"),
})
}
/// Accept one request, parse the callback query, and return the auth code.
/// Blocking — call from `spawn_blocking`.
pub fn wait_for_code(self, expected_state: &str) -> Result<String, SyncError> {
use std::io::{Read, Write};
let (mut stream, _) = self
.listener
.accept()
.map_err(|e| SyncError::Upload(format!("loopback accept: {e}")))?;
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
let (code, state) = parse_callback(&request);
let body =
"<html><body>WhispAssist is now linked. You can close this window.</body></html>";
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
if state.as_deref() != Some(expected_state) {
return Err(SyncError::Auth); // CSRF / state mismatch
}
code.ok_or(SyncError::Auth)
}
}
/// Pull `code` and `state` out of the redirect's request line
/// (`GET /callback?code=…&state=… HTTP/1.1`).
fn parse_callback(request: &str) -> (Option<String>, Option<String>) {
let Some(line) = request.lines().next() else {
return (None, None);
};
let Some(path) = line.split_whitespace().nth(1) else {
return (None, None);
};
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
let mut code = None;
let mut state = None;
for pair in query.split('&') {
match pair.split_once('=') {
Some(("code", v)) => code = Some(percent_decode(v)),
Some(("state", v)) => state = Some(percent_decode(v)),
_ => {}
}
}
(code, state)
}
/// Minimal percent-decode for the two short query values we read.
fn percent_decode(s: &str) -> String {
let bytes = s.replace('+', " ");
let bytes = bytes.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64url_matches_known_vectors() {
assert_eq!(base64url_nopad(b""), "");
assert_eq!(base64url_nopad(b"f"), "Zg");
assert_eq!(base64url_nopad(b"fo"), "Zm8");
assert_eq!(base64url_nopad(b"foo"), "Zm9v");
assert_eq!(base64url_nopad(b"foob"), "Zm9vYg");
}
#[test]
fn pkce_challenge_matches_rfc7636_example() {
// RFC 7636 Appendix B vector.
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
assert_eq!(
challenge_for(verifier),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn generated_pkce_is_url_safe_and_deterministic_challenge() {
let p = pkce_pair();
assert!(p
.verifier
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
assert_eq!(challenge_for(&p.verifier), p.challenge);
}
#[test]
fn auth_url_carries_pkce_and_state() {
let provider = provider_for("onedrive").unwrap();
let url = build_auth_url(
&provider,
"client123",
"http://127.0.0.1:5000/callback",
"chal",
"st8",
)
.unwrap();
assert!(url.contains("code_challenge=chal"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("client_id=client123"));
assert!(url.contains("state=st8"));
// redirect_uri is percent-encoded by the url builder.
assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A5000%2Fcallback"));
}
#[test]
fn parse_callback_extracts_code_and_state() {
let req = "GET /callback?code=abc123&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
let (code, state) = parse_callback(req);
assert_eq!(code.as_deref(), Some("abc123"));
assert_eq!(state.as_deref(), Some("xyz"));
}
#[test]
fn percent_decode_handles_encoded_bytes() {
assert_eq!(percent_decode("a%2Fb"), "a/b");
assert_eq!(percent_decode("hello+world"), "hello world");
}
#[test]
fn expired_uses_skew() {
let t = TokenSet {
access_token: "a".into(),
refresh_token: None,
expires_at: 1000,
};
assert!(!t.is_expired(900));
assert!(t.is_expired(950)); // within the 60s skew
assert!(t.is_expired(1000));
}
}
+355
View File
@@ -0,0 +1,355 @@
//! At-rest encryption vault (Phase 8, T8.8, FR-SEC-3). Optional, off by default.
//!
//! Design (envelope encryption so the password can change without re-encrypting
//! every file):
//! - A random 32-byte **data key** encrypts artifacts (XChaCha20-Poly1305).
//! - The data key is **wrapped** (encrypted) under a **master key** derived from
//! the user's password via Argon2id + a random salt.
//! - `vault.json` stores only the salt, wrap nonce, and wrapped data key — never
//! the password or the plaintext key. The unlocked data key lives only in
//! process memory (`KEY`) until `lock()` or exit.
//!
//! `seal`/`open` are passthroughs when the vault is locked/disabled, and `open`
//! recognizes plaintext (unencrypted) files by the absence of the magic header —
//! so enabling the vault never breaks reading pre-existing files.
use crate::paths::wa_root;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::RwLock;
use zeroize::{Zeroize, Zeroizing};
/// Marks a sealed artifact so `open` can distinguish ciphertext from a plaintext
/// (pre-vault) file. "WhispAssist Encrypted Content v1".
const MAGIC: &[u8; 4] = b"WEC1";
const NONCE_LEN: usize = 24; // XChaCha20-Poly1305
const KEY_LEN: usize = 32;
const SALT_LEN: usize = 16;
/// The unlocked data key, held in memory only while the vault is unlocked.
/// ponytail: process-global rather than threaded through every call site — the
/// vault is inherently a single per-process state; upgrade to zeroize-on-drop if
/// key-scrubbing becomes a requirement.
static KEY: RwLock<Option<[u8; KEY_LEN]>> = RwLock::new(None);
#[derive(Debug, thiserror::Error)]
pub enum VaultError {
#[error("vault already exists")]
AlreadyExists,
#[error("vault is not set up")]
NotSetup,
#[error("vault is locked")]
Locked,
#[error("incorrect password")]
BadPassword,
#[error("crypto error")]
Crypto,
#[error("io error: {0}")]
Io(String),
#[error("key derivation failed")]
Kdf,
}
/// On-disk `vault.json`: everything needed to re-derive and unwrap the data key
/// from the password. Contains no secret usable without the password.
#[derive(Serialize, Deserialize)]
struct VaultMeta {
version: u32,
salt: Vec<u8>,
wrap_nonce: Vec<u8>,
wrapped_key: Vec<u8>,
}
fn vault_file() -> PathBuf {
wa_root().join("vault.json")
}
pub fn is_enabled() -> bool {
vault_file().exists()
}
pub fn is_unlocked() -> bool {
KEY.read().map(|k| k.is_some()).unwrap_or(false)
}
fn random_bytes(buf: &mut [u8]) -> Result<(), VaultError> {
getrandom::getrandom(buf).map_err(|_| VaultError::Crypto)
}
/// Argon2id: password + salt → 32-byte master key. Wrapped in `Zeroizing` so the
/// derived key is wiped from the stack when the caller drops it.
fn derive_master(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, VaultError> {
let mut key = Zeroizing::new([0u8; KEY_LEN]);
argon2::Argon2::default()
.hash_password_into(password.as_bytes(), salt, key.as_mut())
.map_err(|_| VaultError::Kdf)?;
Ok(key)
}
fn aead(key: &[u8]) -> Result<XChaCha20Poly1305, VaultError> {
XChaCha20Poly1305::new_from_slice(key).map_err(|_| VaultError::Crypto)
}
/// Encrypt `plaintext` with `key`, returning `nonce || ciphertext`.
fn encrypt_with(key: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
let mut nonce = [0u8; NONCE_LEN];
random_bytes(&mut nonce)?;
let ct = aead(key)?
.encrypt(XNonce::from_slice(&nonce), plaintext)
.map_err(|_| VaultError::Crypto)?;
let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
Ok(out)
}
/// Inverse of `encrypt_with`; an authentication failure means a wrong key.
fn decrypt_with(key: &[u8], data: &[u8]) -> Result<Vec<u8>, VaultError> {
if data.len() < NONCE_LEN {
return Err(VaultError::Crypto);
}
let (nonce, ct) = data.split_at(NONCE_LEN);
aead(key)?
.decrypt(XNonce::from_slice(nonce), ct)
.map_err(|_| VaultError::BadPassword)
}
fn read_meta() -> Result<VaultMeta, VaultError> {
let text = std::fs::read_to_string(vault_file()).map_err(|_| VaultError::NotSetup)?;
serde_json::from_str(&text).map_err(|e| VaultError::Io(e.to_string()))
}
fn write_meta(meta: &VaultMeta) -> Result<(), VaultError> {
std::fs::create_dir_all(wa_root()).map_err(|e| VaultError::Io(e.to_string()))?;
let text = serde_json::to_string_pretty(meta).map_err(|e| VaultError::Io(e.to_string()))?;
std::fs::write(vault_file(), text).map_err(|e| VaultError::Io(e.to_string()))
}
fn set_key(key: Option<[u8; KEY_LEN]>) {
if let Ok(mut guard) = KEY.write() {
// Wipe the previous key from memory before replacing it (T8.8 hardening).
if let Some(old) = guard.as_mut() {
old.zeroize();
}
*guard = key;
}
}
/// Create the vault: generate a data key, wrap it under the password-derived
/// master key, persist metadata, and leave the vault unlocked.
pub fn enable(password: &str) -> Result<(), VaultError> {
if is_enabled() {
return Err(VaultError::AlreadyExists);
}
let mut salt = [0u8; SALT_LEN];
random_bytes(&mut salt)?;
let mut data_key = [0u8; KEY_LEN];
random_bytes(&mut data_key)?;
let master = derive_master(password, &salt)?;
let wrapped = encrypt_with(&*master, &data_key)?;
let (wrap_nonce, wrapped_key) = wrapped.split_at(NONCE_LEN);
write_meta(&VaultMeta {
version: 1,
salt: salt.to_vec(),
wrap_nonce: wrap_nonce.to_vec(),
wrapped_key: wrapped_key.to_vec(),
})?;
set_key(Some(data_key));
Ok(())
}
/// Unlock the vault with the password, loading the data key into memory.
pub fn unlock(password: &str) -> Result<(), VaultError> {
let meta = read_meta()?;
let master = derive_master(password, &meta.salt)?;
let mut wrapped = meta.wrap_nonce.clone();
wrapped.extend_from_slice(&meta.wrapped_key);
let data_key = decrypt_with(&*master, &wrapped)?; // AEAD failure ⇒ BadPassword
let key: [u8; KEY_LEN] = data_key.try_into().map_err(|_| VaultError::Crypto)?;
set_key(Some(key));
Ok(())
}
pub fn lock() {
set_key(None);
}
/// Change the password: verify the old one, re-wrap the *same* data key under a
/// fresh salt+master derived from the new password (no artifact re-encryption).
pub fn change_password(old: &str, new: &str) -> Result<(), VaultError> {
let meta = read_meta()?;
let old_master = derive_master(old, &meta.salt)?;
let mut wrapped = meta.wrap_nonce.clone();
wrapped.extend_from_slice(&meta.wrapped_key);
let data_key = decrypt_with(&*old_master, &wrapped)?;
let mut salt = [0u8; SALT_LEN];
random_bytes(&mut salt)?;
let new_master = derive_master(new, &salt)?;
let rewrapped = encrypt_with(&*new_master, &data_key)?;
let (wrap_nonce, wrapped_key) = rewrapped.split_at(NONCE_LEN);
write_meta(&VaultMeta {
version: 1,
salt: salt.to_vec(),
wrap_nonce: wrap_nonce.to_vec(),
wrapped_key: wrapped_key.to_vec(),
})
}
/// Encrypt bytes for storage IF the vault is unlocked, tagging them with the
/// magic header; otherwise return them unchanged (vault off/locked ⇒ plaintext).
pub fn seal(plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
let guard = KEY.read().map_err(|_| VaultError::Crypto)?;
match guard.as_ref() {
Some(key) => {
let body = encrypt_with(key, plaintext)?;
let mut out = Vec::with_capacity(MAGIC.len() + body.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&body);
Ok(out)
}
None => Ok(plaintext.to_vec()),
}
}
/// Inverse of `seal`. Plaintext (no magic) passes through unchanged; sealed data
/// requires the vault to be unlocked.
pub fn open(data: &[u8]) -> Result<Vec<u8>, VaultError> {
if data.len() < MAGIC.len() || &data[..MAGIC.len()] != MAGIC {
return Ok(data.to_vec()); // plaintext / pre-vault file
}
let guard = KEY.read().map_err(|_| VaultError::Crypto)?;
let key = guard.as_ref().ok_or(VaultError::Locked)?;
decrypt_with(key, &data[MAGIC.len()..])
}
/// Ensure bytes are ciphertext for client-side-encrypted upload (T9.12,
/// FR-SYNC-10): already-sealed data (e.g. sealed at rest) passes through;
/// plaintext is sealed here, which requires the vault to be unlocked.
pub fn ensure_sealed(data: &[u8]) -> Result<Vec<u8>, VaultError> {
if data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC {
return Ok(data.to_vec());
}
seal_required(data)
}
/// Like `seal`, but errors if the vault is locked instead of passing through —
/// used where ciphertext is mandatory (encrypt-before-upload).
fn seal_required(plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
let guard = KEY.read().map_err(|_| VaultError::Crypto)?;
let key = guard.as_ref().ok_or(VaultError::Locked)?;
let body = encrypt_with(key, plaintext)?;
let mut out = Vec::with_capacity(MAGIC.len() + body.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&body);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
// The KEY state is process-global, so KEY-touching tests must not run
// concurrently — serialize them on a lock (cargo runs tests in parallel).
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_key<T>(key: Option<[u8; KEY_LEN]>, f: impl FnOnce() -> T) -> T {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
set_key(key);
let r = f();
set_key(None);
r
}
#[test]
fn seal_open_round_trips_when_unlocked() {
let key = [7u8; KEY_LEN];
with_key(Some(key), || {
let sealed = seal(b"secret notes").unwrap();
assert_eq!(&sealed[..4], MAGIC);
assert_ne!(&sealed[4..], b"secret notes"); // actually encrypted
assert_eq!(open(&sealed).unwrap(), b"secret notes");
});
}
#[test]
fn seal_is_passthrough_when_locked() {
with_key(None, || {
let out = seal(b"plain").unwrap();
assert_eq!(out, b"plain"); // no magic, unchanged
});
}
#[test]
fn open_passes_through_plaintext_files() {
// A pre-vault plaintext file must still read back even when unlocked.
with_key(Some([1u8; KEY_LEN]), || {
assert_eq!(open(b"{\"json\":true}").unwrap(), b"{\"json\":true}");
});
}
#[test]
fn sealed_data_requires_unlock_to_open() {
let sealed = with_key(Some([9u8; KEY_LEN]), || seal(b"x").unwrap());
// Now locked: opening sealed content fails rather than returning garbage.
with_key(None, || {
assert!(matches!(open(&sealed), Err(VaultError::Locked)));
});
}
#[test]
fn ensure_sealed_seals_plaintext_but_passes_sealed_through() {
let already = with_key(Some([4u8; KEY_LEN]), || seal(b"hi").unwrap());
with_key(Some([4u8; KEY_LEN]), || {
// Plaintext → sealed (tagged), and round-trips.
let sealed = ensure_sealed(b"plain").unwrap();
assert_eq!(&sealed[..4], MAGIC);
assert_eq!(open(&sealed).unwrap(), b"plain");
// Already-sealed data is returned unchanged (no double-seal).
assert_eq!(ensure_sealed(&already).unwrap(), already);
});
// Locked + plaintext → hard error (can't produce ciphertext).
with_key(None, || {
assert!(matches!(ensure_sealed(b"plain"), Err(VaultError::Locked)));
});
}
#[test]
fn wrong_key_fails_authentication() {
let sealed = with_key(Some([1u8; KEY_LEN]), || seal(b"data").unwrap());
with_key(Some([2u8; KEY_LEN]), || {
assert!(matches!(open(&sealed), Err(VaultError::BadPassword)));
});
}
#[test]
fn kdf_is_deterministic_and_password_sensitive() {
let salt = [3u8; SALT_LEN];
assert_eq!(
derive_master("hunter2", &salt).unwrap(),
derive_master("hunter2", &salt).unwrap()
);
assert_ne!(
derive_master("hunter2", &salt).unwrap(),
derive_master("hunter3", &salt).unwrap()
);
}
#[test]
fn key_wrap_round_trips_and_rejects_wrong_master() {
let master = derive_master("pw", &[5u8; SALT_LEN]).unwrap();
let data_key = [42u8; KEY_LEN];
let wrapped = encrypt_with(&*master, &data_key).unwrap();
assert_eq!(decrypt_with(&*master, &wrapped).unwrap(), data_key);
let wrong = derive_master("nope", &[5u8; SALT_LEN]).unwrap();
assert!(matches!(
decrypt_with(&*wrong, &wrapped),
Err(VaultError::BadPassword)
));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "WhispAssist",
"version": "0.0.0",
"version": "0.1.0",
"identifier": "bet.dou.whispassist",
"build": {
"frontendDist": "../dist",
+86
View File
@@ -19,6 +19,31 @@
let showSettings = $state(false);
let showConsent = $state(false);
// Vault unlock gate (T8.8): if the vault is enabled but locked at startup,
// prompt for the password so encrypted meetings are readable.
let vaultLocked = $state(false);
let vaultPw = $state("");
let vaultErr = $state<string | null>(null);
async function checkVault() {
try {
const v = await api.vaultStatus();
vaultLocked = v.enabled && !v.unlocked;
} catch {
vaultLocked = false;
}
}
async function submitUnlock() {
vaultErr = null;
try {
await api.unlockVault(vaultPw);
vaultPw = "";
vaultLocked = false;
meetings.load(); // refresh now that encrypted content is readable
} catch {
vaultErr = "Incorrect password";
}
}
// Note templates (T8.1, FR-NOTE-5) — picked before starting a recording,
// applied to notes.md's structure once the meeting finalizes.
let noteTemplates = $state<NoteTemplate[]>([]);
@@ -46,6 +71,7 @@
recording.init();
settings.load();
meetings.init();
checkVault();
api
.listNoteTemplates()
.then((t) => (noteTemplates = t))
@@ -224,6 +250,33 @@
<Settings onClose={() => (showSettings = false)} />
{/if}
{#if vaultLocked}
<div
class="vault-overlay"
role="dialog"
aria-modal="true"
aria-label="Unlock encryption vault"
tabindex="-1"
use:trapFocus
>
<div class="vault-card">
<h2>Unlock encryption vault</h2>
<p class="muted">Your meetings are encrypted at rest. Enter your password to read them.</p>
<input
type="password"
placeholder="Vault password"
bind:value={vaultPw}
onkeydown={(e) => e.key === "Enter" && submitUnlock()}
/>
{#if vaultErr}<p class="vault-err">{vaultErr}</p>{/if}
<div class="vault-actions">
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}>Unlock</button>
<button class="link" onclick={() => (vaultLocked = false)}>Continue locked</button>
</div>
</div>
</div>
{/if}
<main class="panes">
<aside class="left"><MeetingsList /></aside>
<section class="center"><TranscriptNotes /></section>
@@ -512,4 +565,37 @@
overflow: auto;
background: var(--bg);
}
.vault-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(10, 12, 16, 0.6);
}
.vault-card {
background: var(--panel, var(--bg));
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
width: min(360px, 90vw);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.vault-card input {
padding: 0.5rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
}
.vault-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vault-err {
color: var(--danger, #dc2626);
margin: 0;
}
</style>
+31 -4
View File
@@ -233,6 +233,20 @@ export interface SyncTargetConfig {
encrypt_before_upload?: boolean;
}
// A durable upload job for one artifact × target (sync_status). Snake_case,
// matching the Rust SyncJobInfo; the sync://job *event* is camelCase (onSyncJob).
export interface SyncJobInfo {
id: string;
target_id: string;
meeting_id: MeetingId;
artifact: string;
status: string; // pending|uploading|done|failed|skipped
attempts: number;
bytes_sent: number;
bytes_total: number | null;
last_error: string | null;
}
export interface AppSettings {
theme: string;
storage_root: string;
@@ -377,13 +391,13 @@ export const api = {
invoke<SyncTargetInfo>("update_sync_target", { config }),
removeSyncTarget: (id: string) => invoke<void>("remove_sync_target", { id }),
testSyncTarget: (configOrId: SyncTargetConfig | { id: string }) =>
invoke<{ ok: boolean; message: string }>("test_sync_target", { configOrId }),
invoke<{ ok: boolean; message: string }>("test_sync_target", { config: configOrId }),
setSyncEnabled: (enabled: boolean) => invoke<void>("set_sync_enabled", { enabled }),
beginOauthLink: (kind: "onedrive" | "dropbox" | "box") =>
invoke<{ ok: boolean; account?: string }>("begin_oauth_link", { kind }),
invoke<{ authUrl: string }>("begin_oauth_link", { kind }),
syncMeeting: (meetingId: MeetingId, targetId?: string) =>
invoke<void>("sync_meeting", { meetingId, targetId }),
syncStatus: (meetingId?: MeetingId) => invoke("sync_status", { meetingId }),
syncStatus: (meetingId?: MeetingId) => invoke<SyncJobInfo[]>("sync_status", { meetingId }),
retrySyncJob: (jobId: string) => invoke<void>("retry_sync_job", { jobId }),
// Feature briefs + MCP server (ADR-0011). MCP server is loopback-only and off by default.
@@ -413,6 +427,14 @@ export const api = {
updateSettings: (patch: Partial<AppSettings>) =>
invoke<AppSettings>("update_settings", { patch }),
privacySelfCheck: () => invoke<PrivacySelfCheck>("privacy_self_check"),
// At-rest encryption vault (Phase 8, T8.8, FR-SEC-3).
vaultStatus: () => invoke<{ enabled: boolean; unlocked: boolean }>("vault_status"),
enableVault: (password: string) => invoke<void>("enable_vault", { args: { password } }),
unlockVault: (password: string) => invoke<void>("unlock_vault", { args: { password } }),
lockVault: () => invoke<void>("lock_vault"),
changeVaultPassword: (oldPassword: string, newPassword: string) =>
invoke<void>("change_vault_password", { args: { oldPassword, newPassword } }),
};
// ---- Events (Rust → UI) ----
@@ -461,9 +483,14 @@ export const events = {
artifact: string;
status: string;
bytesSent: number;
bytesTotal: number;
bytesTotal: number | null;
attempts?: number;
error?: string | null;
}) => void,
): Promise<UnlistenFn> => listen("sync://job", (e) => cb(e.payload as never)),
onSyncLinked: (
cb: (p: { ok: boolean; kind: string; error?: string }) => void,
): Promise<UnlistenFn> => listen("sync://linked", (e) => cb(e.payload as never)),
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
listen("mcp://access", (e) => cb(e.payload as never)),
onAgentProgress: (
+72
View File
@@ -11,8 +11,21 @@ import {
type MeetingId,
type MeetingListItem,
type SearchHit,
type SyncJobInfo,
} from "../api";
/** Normalized (camelCase) upload-job view for the UI — folds the snake_case
* `sync_status` rows and the camelCase `sync://job` events into one shape. */
export interface SyncJob {
jobId: string;
artifact: string;
status: string;
bytesSent: number;
bytesTotal: number | null;
attempts: number;
error: string | null;
}
class MeetingsStore {
list = $state<MeetingListItem[]>([]);
selectedId = $state<MeetingId | null>(null);
@@ -37,6 +50,11 @@ class MeetingsStore {
summaryStream = $state("");
summaryError = $state<string | null>(null);
// Upload jobs for the selected meeting (Phase 9, T9.5). Fed by sync_status on
// select and kept live by sync://job events.
syncJobs = $state<SyncJob[]>([]);
uploading = $state(false);
async init() {
await this.loadTags();
await this.load();
@@ -53,6 +71,58 @@ class MeetingsStore {
if (this.summarizingId === meetingId) this.summarizingId = null;
if (this.selectedId === meetingId) await this.select(meetingId);
});
// Live upload progress for the open meeting (T9.5).
await events.onSyncJob((p) => {
if (p.meetingId !== this.selectedId) return;
const job: SyncJob = {
jobId: p.jobId,
artifact: p.artifact,
status: p.status,
bytesSent: p.bytesSent,
bytesTotal: p.bytesTotal,
attempts: p.attempts ?? 0,
error: p.error ?? null,
};
const i = this.syncJobs.findIndex((j) => j.jobId === job.jobId);
if (i >= 0) this.syncJobs[i] = job;
else this.syncJobs = [...this.syncJobs, job];
});
}
private static toSyncJob(info: SyncJobInfo): SyncJob {
return {
jobId: info.id,
artifact: info.artifact,
status: info.status,
bytesSent: info.bytes_sent,
bytesTotal: info.bytes_total,
attempts: info.attempts,
error: info.last_error,
};
}
async loadSyncJobs(id: MeetingId) {
try {
this.syncJobs = (await api.syncStatus(id)).map(MeetingsStore.toSyncJob);
} catch {
this.syncJobs = [];
}
}
/** Manual "Upload now" — (re)enqueues and pumps; progress streams via events. */
async uploadNow(id: MeetingId) {
this.uploading = true;
try {
await api.syncMeeting(id);
await this.loadSyncJobs(id);
} finally {
this.uploading = false;
}
}
async retryJob(jobId: string, meetingId: MeetingId) {
await api.retrySyncJob(jobId);
await this.loadSyncJobs(meetingId);
}
async generateSummary(id: MeetingId, templateId?: string) {
@@ -118,11 +188,13 @@ class MeetingsStore {
async select(id: MeetingId) {
this.selectedId = id;
this.selected = await api.getMeeting(id);
await this.loadSyncJobs(id);
}
deselect() {
this.selectedId = null;
this.selected = null;
this.syncJobs = [];
}
async remove(id: MeetingId) {
+33
View File
@@ -37,6 +37,8 @@ class SettingsStore {
loaded = $state(false);
/** Set when the backend isn't wired yet, so the UI can show a "stub" hint. */
backendStub = $state(false);
/** Status line for the OAuth linking flow (Phase 9b). */
linkMessage = $state<string | null>(null);
// Hardware + model management (Phase 3, T3.6/T3.7).
hardware = $state<HardwareStatus | null>(null);
@@ -72,9 +74,40 @@ class SettingsStore {
[id]: { received: receivedBytes, total: totalBytes },
};
});
// OAuth link completion (Phase 9b) — the flow finishes in the background.
await events.onSyncLinked(async ({ ok, kind, error }) => {
if (ok) {
this.linkMessage = `${kind} linked.`;
await this.reloadTargets();
await this.loadPrivacy();
} else {
this.linkMessage = `Link failed: ${error ?? "unknown error"}`;
}
});
this.loaded = true;
}
async reloadTargets() {
try {
this.targets = await api.listSyncTargets();
} catch {
/* keep the current list on a transient failure */
}
}
/** Start OAuth linking for a secondary provider: open the auth URL; the flow
* completes via the `sync://linked` event handled in load(). */
async linkOauth(kind: "onedrive" | "dropbox" | "box") {
this.linkMessage = "Opening browser…";
try {
const { authUrl } = await api.beginOauthLink(kind);
window.open(authUrl, "_blank");
this.linkMessage = "Finish sign-in in your browser…";
} catch (e) {
this.linkMessage = `Link failed: ${e}`;
}
}
async loadHardware() {
try {
this.hardware = await api.hardwareStatus();
+119 -2
View File
@@ -63,6 +63,58 @@
let showConsent = $state(false);
let testResult = $state<{ ok: boolean; message: string } | null>(null);
// ---- At-rest encryption vault (T8.8, FR-SEC-3) ----
let vault = $state<{ enabled: boolean; unlocked: boolean } | null>(null);
let vaultPw = $state("");
let vaultPw2 = $state("");
let vaultMsg = $state<string | null>(null);
async function loadVault() {
try {
vault = await api.vaultStatus();
} catch {
vault = null;
}
}
onMount(loadVault);
async function enableVault() {
vaultMsg = null;
try {
await api.enableVault(vaultPw);
vaultPw = "";
vaultMsg = "Vault enabled and unlocked.";
await loadVault();
} catch (e) {
vaultMsg = `${e}`;
}
}
async function unlockVault() {
vaultMsg = null;
try {
await api.unlockVault(vaultPw);
vaultPw = "";
vaultMsg = "Unlocked.";
await loadVault();
} catch (e) {
vaultMsg = `${e}`;
}
}
async function lockVault() {
await api.lockVault();
vaultMsg = "Locked.";
await loadVault();
}
async function changeVaultPassword() {
vaultMsg = null;
try {
await api.changeVaultPassword(vaultPw, vaultPw2);
vaultPw = "";
vaultPw2 = "";
vaultMsg = "Password changed.";
} catch (e) {
vaultMsg = `${e}`;
}
}
// ---- NPU package download (T3.4) ----
let npuBusy = $state(false);
let npuMsg = $state<string | null>(null);
@@ -143,6 +195,7 @@
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
};
}
function setKind(k: SyncKind) {
@@ -159,8 +212,10 @@
testResult = null;
}
function linkOAuth() {
// begin_oauth_link → loopback PKCE flow (stubbed). Adds a placeholder target on success.
settings.addTarget({ ...form, name: form.name || form.kind });
// begin_oauth_link → loopback PKCE flow; the target is created in the
// background on success (sync://linked), which the store handles.
if (form.kind === "webdav") return;
settings.linkOauth(form.kind);
}
</script>
@@ -516,6 +571,15 @@
><input type="checkbox" bind:checked={form.allow_plaintext_lan} /> Allow plaintext http for
a LAN address (not recommended)</label
>
<label class="row small">
<input
type="checkbox"
bind:checked={form.encrypt_before_upload}
disabled={!vault?.unlocked}
/>
Encrypt before upload (destination stores only ciphertext)
{#if !vault?.unlocked}<span class="muted">— requires an unlocked vault</span>{/if}
</label>
<div class="actions">
<button onclick={testConnection}>Test connection</button>
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}
@@ -546,6 +610,9 @@
store.</span
>
</div>
{#if settings.linkMessage}
<p class="muted">{settings.linkMessage}</p>
{/if}
{/if}
<p class="muted footnote">
@@ -607,6 +674,56 @@
{:else}
<p class="muted">Privacy self-check unavailable.</p>
{/if}
{#if vault}
<h4>Encryption vault</h4>
{#if !vault.enabled}
<p class="muted">
Encrypt notes, transcripts, and summaries at rest with a password. (Audio files are
not encrypted yet.)
</p>
<div class="grid">
<label class="wide"
>Vault password<input type="password" bind:value={vaultPw} /></label
>
</div>
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
>Enable vault</button
>
<p class="muted">
Use at least 8 characters. If you forget it, encrypted content can't be recovered.
</p>
{:else if !vault.unlocked}
<p class="muted">
Vault is <strong>locked</strong>. Unlock to read encrypted meetings.
</p>
<div class="grid">
<label class="wide">Password<input type="password" bind:value={vaultPw} /></label>
</div>
<button class="primary" onclick={unlockVault} disabled={!vaultPw}>Unlock</button>
{:else}
<p class="muted">
Vault is <strong>unlocked</strong>. New notes, transcripts, and summaries are
encrypted at rest.
</p>
<button onclick={lockVault}>Lock now</button>
<details>
<summary>Change password</summary>
<div class="grid">
<label class="wide"
>Current password<input type="password" bind:value={vaultPw} /></label
>
<label class="wide"
>New password<input type="password" bind:value={vaultPw2} /></label
>
</div>
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
>Change password</button
>
</details>
{/if}
{#if vaultMsg}<p class="muted">{vaultMsg}</p>{/if}
{/if}
</section>
{/if}
</div>
+86 -1
View File
@@ -3,9 +3,19 @@
import { onMount } from "svelte";
import { meetings } from "../stores/meetings.svelte";
import { calendar } from "../stores/calendar.svelte";
import { settings } from "../stores/settings.svelte";
import { api, type ActionItem, type CalendarEventDetail, type LlmStatus } from "../api";
import { renderMarkdown } from "../markdown";
import { Tags, Sparkles, ListTodo, CalendarDays, Users, Mic2, Bell } from "@lucide/svelte";
import {
Tags,
Sparkles,
ListTodo,
CalendarDays,
Users,
Mic2,
Bell,
UploadCloud,
} from "@lucide/svelte";
onMount(() => calendar.load());
@@ -132,6 +142,45 @@
</script>
<div class="wrap">
{#if meetings.selected && settings.settings.sync_enabled}
<h3><UploadCloud size={14} aria-hidden="true" /> Sync</h3>
<button
class="upload-now"
disabled={meetings.uploading}
onclick={() => {
const m = meetings.selected;
if (m) meetings.uploadNow(m.id);
}}
>
{meetings.uploading ? "Uploading…" : "Upload now"}
</button>
{#if meetings.syncJobs.length}
<ul class="jobs">
{#each meetings.syncJobs as job (job.jobId)}
<li>
<span class="artifact">{job.artifact}</span>
<span class="job-status {job.status}">{job.status}</span>
{#if job.status === "uploading" && job.bytesTotal}
<span class="muted">{Math.round((100 * job.bytesSent) / job.bytesTotal)}%</span>
{/if}
{#if job.status === "failed"}
{#if job.error}<span class="err" title={job.error}>⚠</span>{/if}
<button
class="link"
onclick={() => {
const m = meetings.selected;
if (m) meetings.retryJob(job.jobId, m.id);
}}>retry</button
>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="muted">No uploads yet for this meeting.</p>
{/if}
{/if}
<h3><Tags size={14} aria-hidden="true" /> Tags</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to tag it.</p>
@@ -472,4 +521,40 @@
font-size: 0.8rem;
padding: 0;
}
.upload-now {
align-self: start;
}
.jobs {
list-style: none;
padding: 0;
margin: 0.4rem 0 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.jobs li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
}
.artifact {
min-width: 5rem;
}
.job-status {
text-transform: capitalize;
color: var(--muted);
}
.job-status.done {
color: var(--success, #16a34a);
}
.job-status.uploading {
color: var(--accent, #2563eb);
}
.job-status.failed {
color: var(--danger, #dc2626);
}
.err {
cursor: help;
}
</style>