Feature another one #11
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
Generated
+119
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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")];
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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: (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user