84 Commits
Author SHA1 Message Date
iamdoubz 246fb60731 Merge pull request 'Feature chore bug 003' (#17) from feature_chore_bug_003 into main
Reviewed-on: #17
2026-07-06 20:06:53 -05:00
iamdoubz 233c6fa8cd chore(release): bump version to 0.2.0 + refresh README 2026-07-06 19:14:51 -05:00
iamdoubz 2eff32662b docs: document the single universal (Vulkan) installer build 2026-07-06 19:06:09 -05:00
iamdoubz f0480244bf chore: gitignore the staged vulkan-1.dll loader 2026-07-06 19:06:08 -05:00
iamdoubz 2a0379ce43 build: bundle vulkan-1.dll so the single Vulkan installer runs on non-GPU machines 2026-07-06 19:06:07 -05:00
iamdoubz 91677476cc build: stage redistributable vulkan-1.dll for the merged installer 2026-07-06 19:06:06 -05:00
iamdoubz 76a4f8ce08 build: lockfile after dropping protocol-asset 2026-07-06 18:40:18 -05:00
iamdoubz 2603e12b36 feat(ui): load recording from waaudio://; remove download affordance 2026-07-06 18:40:16 -05:00
iamdoubz 90ba14cc2d build: drop tauri protocol-asset feature (no longer used) 2026-07-06 18:40:15 -05:00
iamdoubz 7106dc1584 feat(recording): CSP media-src for waaudio://; drop asset protocol 2026-07-06 18:40:14 -05:00
iamdoubz 84b9087de7 feat(recording): register waaudio:// protocol + sweep plaintext temp files 2026-07-06 18:40:13 -05:00
iamdoubz 1634820523 feat(recording): stream + decrypt recordings in memory via waaudio:// (FR-REC-5) 2026-07-06 18:40:12 -05:00
iamdoubz 57127cfa07 docs: FR-CAP-7 now covers mic in the saved recording 2026-07-06 18:25:58 -05:00
iamdoubz 49893d5874 feat(audio): wire MicBridge so recordings include the user's voice 2026-07-06 18:25:57 -05:00
iamdoubz a14ca871e1 fix(audio): mix mic into the recording at native rate via MicBridge (FR-CAP-7) 2026-07-06 18:25:56 -05:00
iamdoubz 2fda4041ff docs: FR-CAP-8/9, FR-REC-5, FR-SYNC-11 + cancelled recording state 2026-07-06 17:56:19 -05:00
iamdoubz 0ecba7f055 docs(ui): correct playback requirement id to FR-REC-5 2026-07-06 17:56:18 -05:00
iamdoubz ea79fb656d docs(commands): correct playback requirement id to FR-REC-5 2026-07-06 17:56:16 -05:00
iamdoubz a7eba7fd82 build: lockfile for tauri protocol-asset (http-range) 2026-07-06 17:55:13 -05:00
iamdoubz d08d03077c feat(ui): recording player + live per-item sync progress bars 2026-07-06 17:55:05 -05:00
iamdoubz 3b0f27f5d9 feat(ui): Cancel button to discard an accidental recording 2026-07-06 17:55:04 -05:00
iamdoubz f2db826e8c feat(recording): cancel() + handle cancelled state 2026-07-06 17:55:02 -05:00
iamdoubz a5d81bd9e4 feat(api): cancelRecording + recordingPlaybackPath client bindings 2026-07-06 17:55:01 -05:00
iamdoubz 010941e620 feat: enable asset protocol + media-src CSP for recording playback 2026-07-06 17:52:00 -05:00
iamdoubz 90580da0af build: enable tauri protocol-asset feature for recording playback 2026-07-06 17:51:59 -05:00
iamdoubz e715c09ce0 feat: register cancel_recording + recording_playback_path commands 2026-07-06 17:51:58 -05:00
iamdoubz da25ccaec6 feat: cancel_recording, recording_playback_path, live sync progress 2026-07-06 17:51:57 -05:00
iamdoubz e7f628ad7a feat(sync): stream upload bodies with live byte progress (FR-SYNC-11) 2026-07-06 17:51:55 -05:00
iamdoubz 25d0232256 feat(vault): add is_sealed() helper 2026-07-06 17:51:54 -05:00
iamdoubz 34b4f4ae09 feat(audio): write recordings as 16-bit PCM to halve .wav size (FR-CAP-8) 2026-07-06 17:51:53 -05:00
iamdoubz 96b30ed99a feat(notes): single-pane Editor/Preview toggle 2026-07-06 17:44:05 -05:00
iamdoubz a37f766817 Merge pull request 'Feature chore bug 002' (#16) from feature_chore_bug_002 into main
Reviewed-on: #16
2026-07-06 17:05:58 -05:00
iamdoubz 4cdeb8f475 test(audio): real-hardware microphone open/stop smoke test (FR-CAP-7) 2026-07-06 16:29:56 -05:00
iamdoubz 5828023651 docs: FR-CAP-7 microphone capture + mixer contract 2026-07-06 16:26:27 -05:00
iamdoubz 3202f884af feat(settings): Microphone device picker under Audio Devices (FR-CAP-7) 2026-07-06 16:26:25 -05:00
iamdoubz 996c7edc99 feat(audio): load input devices + setMicrophone in settings store (FR-CAP-7) 2026-07-06 16:26:24 -05:00
iamdoubz bb688418bf feat(audio): add listInputDevices + mic settings to API client (FR-CAP-7) 2026-07-06 16:26:23 -05:00
iamdoubz 97b5f67049 feat(audio): wire mic capture + mixer into recording lifecycle (FR-CAP-7) 2026-07-06 16:26:17 -05:00
iamdoubz 55ddaad544 feat(audio): track mic capture handle; register list_input_devices (FR-CAP-7) 2026-07-06 16:26:16 -05:00
iamdoubz 75f7d19ade feat(audio): add microphone_enabled + audio_input_device settings (FR-CAP-7) 2026-07-06 16:26:14 -05:00
iamdoubz 2fb5fd6f30 feat(audio): microphone capture + stream mixer (FR-CAP-7) 2026-07-06 16:26:13 -05:00
iamdoubz b8dc5fc37f feat(settings): Audio Devices section under Hardware
A <select> defaulting to "Default system audio", populated from the
enumerated render devices — picking one overrides which device
WhispAssist loopback-captures from instead of always following
Windows' system default.
2026-07-06 16:01:00 -05:00
iamdoubz 38b9cb2910 feat(audio): add audioDevices state + loadAudioDevices/setAudioOutputDevice 2026-07-06 16:00:59 -05:00
iamdoubz 137605934c feat(audio): add AudioDeviceInfo type + listAudioDevices to the API client 2026-07-06 16:00:57 -05:00
iamdoubz 5b281531fb feat(audio): register list_audio_devices with the Tauri invoke handler 2026-07-06 16:00:50 -05:00
iamdoubz 2759a0e49a feat(audio): add list_audio_devices command; wire device into start_recording
list_audio_devices runs the (blocking, COM-based) enumeration off the
async runtime via spawn_blocking, same pattern as import_pst_core.
start_recording now passes settings.audio_output_device through to
WasapiCapture::start instead of always capturing the default device.
2026-07-06 16:00:49 -05:00
iamdoubz 8e3e22c0e5 feat(audio): add audio_output_device to Settings
Device::get_id() string; None = system default (unchanged behavior).
#[serde(default)] so an existing settings.json without this key still
deserializes.
2026-07-06 16:00:47 -05:00
iamdoubz bb1d1730f8 feat(audio): device-selectable WASAPI loopback capture
AudioCapture::start now takes an optional device id (Device::get_id())
instead of always resolving the system default render device.
open_capture_session/find_render_device resolve the configured device,
falling back to the system default if it's no longer present (same
degrade-gracefully spirit as the existing mid-recording reconnect,
which now retries the *same* selection rather than switching to
whatever's currently default).

Adds list_render_devices() (new AudioDeviceInfo) for the Settings
picker — enumeration was already fully supported by the wasapi crate,
just unused until now.
2026-07-06 16:00:46 -05:00
iamdoubz 16fcb154f3 feat(tags): Generate tags button + chip-based tag editor
Generate tags mirrors Generate summary: reads the transcript (the same
build_prompt assembly, non-streamed) and suggests 1-8 tags, merged
into the working tag list rather than replacing it outright so a
manually-added tag never silently disappears.

Replaces the old comma-separated text input with GitHub-topic-style
chips: typing a comma commits everything before it as its own chip
immediately, each chip is removable via its 'x', and clicking a chip's
label filters the meeting list to that tag (meetings.filterByTag).
2026-07-06 15:38:18 -05:00
iamdoubz cf4232cbae feat(tags): sync the sidebar tag dropdown with the shared filter state
Writable $derived instead of local $state, so clicking a tag chip
elsewhere (the Tags panel) is reflected here too, not just changes
made directly in this dropdown.
2026-07-06 15:38:17 -05:00
iamdoubz 4a30674008 feat(tags): add filterByTag to the meetings store
Same mechanism as the sidebar's tag dropdown (load with a tag filter,
drop search mode), just callable from anywhere a tag chip is clicked —
not only the dropdown itself.
2026-07-06 15:38:15 -05:00
iamdoubz e16d9e5ade feat(tags): add a GitHub-topics-style TagChip component
Rounded pill using the existing --accent-soft/--accent tokens.
Clicking the label filters the meeting list to this tag; an optional
'x' removes it from whatever list rendered it (the component doesn't
know or care what "remove" means to its caller).
2026-07-06 15:38:14 -05:00
iamdoubz 1a31cac65b feat(tags): add generateTags to the typed API client 2026-07-06 15:38:03 -05:00
iamdoubz ba1b577734 feat(llm): register generate_tags with the Tauri invoke handler 2026-07-06 15:38:01 -05:00
iamdoubz fb406d62da feat(llm): add generate_tags command
Reuses build_prompt's transcript+metadata assembly (same as
generate_summary) and the same recording-in-progress guard.
2026-07-06 15:38:00 -05:00
iamdoubz 68c32ffa06 feat(llm): add suggest_tags to LlmProvider for AI-generated tags
Non-streaming — a 1-8 tag reply is short enough that a second
token-stream event isn't worth wiring up. Implemented for Ollama and
the OpenAI-compatible path (reusing summarize()'s SSE plumbing, just
accumulating instead of forwarding to a TokenSink); the Anthropic stub
mirrors its existing "not built yet" summarize() error.

parse_tags/sanitize_tag/strip_ordinal_prefix handle a model that
ignores "comma-separated, nothing else" (numbered lists, newlines,
quotes) and normalize everything to lowercase, hyphenated, chip-safe
tags, capped at 8 and deduped.
2026-07-06 15:37:59 -05:00
iamdoubz 2efe29558a feat(meetings): editable title header above the transcript/notes pane
Nothing in the UI displayed or let you change a meeting's title
outside the sidebar list — recordings default to "Untitled meeting"
with no way to fix that. Adds an inline-editable header; blur/change
commits the rename.
2026-07-06 15:10:59 -05:00
iamdoubz 3cd267f737 feat(meetings): add renameMeeting; refresh the list after attach/rename
attachEvent and renameMeeting can both change a meeting's title now, so
both refresh the list (not just the selected-detail view) to keep the
sidebar in sync.
2026-07-06 15:10:58 -05:00
iamdoubz f9e75b7d17 feat(meetings): add renameMeeting to the typed API client 2026-07-06 15:10:56 -05:00
iamdoubz ae9e8398c5 feat(meetings): register rename_meeting with the Tauri invoke handler 2026-07-06 15:10:55 -05:00
iamdoubz 4f80c14f72 feat(meetings): add rename_meeting command
Validates a non-empty trimmed title before delegating to the store.
2026-07-06 15:10:54 -05:00
iamdoubz e68e888c07 feat(meetings): rename_meeting + mirror event subject on attach
rename_meeting backs a user-editable title (T2.2) — recordings had no
way to change their default "Untitled meeting" name from the UI at all.

attach_meeting_to_event now also copies the linked event's subject onto
the meeting's title when it has one: linking is meant to say "this
recording is that meeting," so the title should follow.
2026-07-06 15:10:53 -05:00
iamdoubz 7e646b3e0f feat(calendar): search/date filter on the meeting-linking event picker
A real mailbox import is thousands of events — searching/filtering
belongs where the user is actually tying a meeting to one, not just
the general browse list in Settings. Defaults the date filter to the
selected meeting's own recording date, since that's almost always the
event being linked.
2026-07-06 14:55:50 -05:00
iamdoubz 00de9d3f68 feat(calendar): remember .pst path, add auto-sync-on-launch toggle
Prefills the file path from settings once loaded (without clobbering
an in-progress browse/edit) and persists it after a successful import,
so the user doesn't have to re-browse to the same file every launch.
The new checkbox just flips pst_auto_sync, which lib.rs's one-shot
startup pass reads.
2026-07-06 14:55:49 -05:00
iamdoubz e6a2109c9c feat(settings): default pst_last_path/pst_auto_sync in the fallback settings object 2026-07-06 14:55:47 -05:00
iamdoubz 67ac4c3813 feat(settings): add pst_last_path/pst_auto_sync to AppSettings type 2026-07-06 14:55:46 -05:00
iamdoubz 9d757be415 feat(calendar): one-shot PST auto-sync on launch
Mirrors the existing sync-job-resume pattern right above it: runs once
at startup if pst_auto_sync is on and a path is remembered, no idle
timer (NFR-RES-1). Re-import is dedup'd by (source, raw_uid), so this
just catches up on new/changed events since last launch.
2026-07-06 14:55:36 -05:00
iamdoubz 871b11d75e refactor(calendar): split import_pst into a reusable core fn
import_pst_core takes &AppHandle/&dyn Store directly instead of the
State<AppState> extractor, so the startup auto-sync pass (lib.rs) can
run the same import logic without going through a Tauri command.
2026-07-06 14:55:34 -05:00
iamdoubz 75ec24ddf6 feat(calendar): add pst_last_path/pst_auto_sync to Settings
Backing fields for remembering the imported .pst path across sessions
and an opt-in startup auto-resync (T6.2). #[serde(default)] so an
existing settings.json without these keys still deserializes.
2026-07-06 14:55:33 -05:00
iamdoubz 4637ce8aac chore(deps): promote chrono to a direct dependency
Needed for DST-aware recurrence expansion (calendar/mod.rs); already
resolved transitively via sqlx, so this adds no new compiled crate.
2026-07-06 14:55:31 -05:00
iamdoubz 6f07f28cef fix(calendar): preserve local wall-clock time across DST in recurrence
expand_rrule reused dtstart's raw UTC time-of-day for every occurrence,
so a weekly meeting spanning a DST transition (e.g. created in winter
CST, recurring into summer CDT) drifted an hour once displayed in the
machine's local timezone.

Rewrites occurrence generation on chrono's Local/NaiveDate (already a
transitive dependency via sqlx, now promoted to direct) instead of
hand-rolled epoch-day arithmetic: each occurrence keeps dtstart's local
wall-clock hour/minute, re-resolving the UTC offset per occurrence date
so DST is applied correctly for that specific day. civil_from_days/
weekday_from_epoch_day/is_leap_year/days_in_month are no longer needed
(chrono's NaiveDate replaces them) and are removed.
2026-07-06 14:55:30 -05:00
iamdoubz fa8a7f2552 feat(calendar): expand recurring PST events into per-occurrence rows
RRULE was previously ignored entirely, so a recurring meeting only ever
stored its first occurrence — invisible to any later date filter/lookup.
Adds RRULE parsing + occurrence expansion (DAILY/WEEKLY/MONTHLY/YEARLY,
INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/BYMONTH) covering every pattern
found in a real 7.2GB mailbox (ADR-0008), each occurrence stored as its
own row keyed by "{uid}@{ymd}" so re-import dedup still applies.

Also fixes a real parser bug found via that mailbox: this libpst build
joins multi-value BYDAY with `;` instead of RFC 5545's `,`
(BYDAY=MO;TU;WE;TH;FR), which previously made RRULE parsing bail out
silently for any event with more than one weekday.

Indefinite rules (no COUNT/UNTIL — only YEARLY holidays in practice) are
capped at 10 years/500 occurrences; ponytail-flagged as the ceiling to
raise if a real series needs more.
2026-07-06 14:34:21 -05:00
iamdoubz 8f98332cdd feat(calendar): filter imported events by title and date
Client-side filter over the already-loaded events list (T6.3, FR-CAL-2)
— 1700+ events from a real mailbox is unwieldy to browse unfiltered.
2026-07-06 14:19:14 -05:00
iamdoubz 2ca4151444 fix(calendar): fall back to stdout when readpst's stderr is empty
readpst writes some failure messages to stdout rather than stderr, so a
failing run with empty stderr surfaced as a blank "parse failed: " error
with no diagnostic content.
2026-07-06 13:56:44 -05:00
iamdoubz 66aae232c5 fix(calendar): use shared errorMessage() for PST import errors
Tauri commands reject with a {kind, message} object, not a native Error,
so `e instanceof Error ? e.message : String(e)` always fell through to
String(e) -> "[object Object]", hiding the actual readpst failure reason.
2026-07-06 13:43:23 -05:00
iamdoubz 43497ca8cd Merge pull request 'Feature chore bug 001' (#15) from feature_chore_bug_001 into main
Reviewed-on: #15
2026-07-06 13:18:01 -05:00
iamdoubz 6a4b832628 chore(release): bump version to 0.1.6 2026-07-06 13:08:41 -05:00
iamdoubz 33c0397de8 fix(sync): enable keyring windows-native backend — creds were a silent no-op
keyring 3.x feature-gates its OS backends OFF by default, so with no feature the
credential store fell back to a mock where set/get don't share state: every
password write appeared to succeed but read back empty, so no Basic auth was
sent and WebDAV/Nextcloud auth always 401'd. windows-native pulls the real
Windows Credential Manager. Also affected MCP/hosted-AI keys + OAuth tokens.
2026-07-06 13:08:39 -05:00
iamdoubz 9c670f9b6b feat(settings): Nextcloud server-URL hint + edit-mode test uses stored password
Provider-aware Server URL placeholder/hint (Nextcloud/ownCloud: enter just the
server URL); Test in edit mode passes the target id so the backend reuses the
stored credential.
2026-07-06 11:10:46 -05:00
iamdoubz 8f6e8041d6 feat(sync): auto-build Nextcloud/ownCloud DAV path from the server URL
For nextcloud/owncloud, WebDavTarget now derives the canonical
…/remote.php/dav/files/<user>/ path from the entered server URL (origin only, so
a pasted full path normalizes too); other providers use the URL verbatim.
test_sync_target also reuses the stored password when editing an existing
webdav target (unless a new one is typed), so Test works after a URL fix without
re-entering the password. Fixes the confusing 'bare host connects, full URL
fails' behavior (the former was a 404 false-positive, the latter a blank-secret
401).
2026-07-06 11:10:44 -05:00
iamdoubz fb790091fd feat(settings): edit a saved sync target (fix URL/username/options)
Each webdav target gets an Edit button that loads it into the form; save routes
to update_sync_target. Password left blank keeps the stored one; kind is locked
during edit. Fixes not being able to correct a mistyped Server URL.
2026-07-06 10:41:02 -05:00
iamdoubz 496a3372c4 feat(sync): store.updateTarget() to edit a saved target 2026-07-06 10:41:01 -05:00
iamdoubz 45b2acbbc6 feat(api): add upload flags to SyncTargetInfo type 2026-07-06 10:40:59 -05:00
iamdoubz 149f2c995d feat(sync): expose upload flags in SyncTargetInfo for the edit form
row_to_info now returns upload_transcript/notes/summary/recording,
trigger_on_finalize, allow_plaintext_lan, encrypt_before_upload so the UI can
pre-fill an edit form accurately (still never the secret, FR-SYNC-6).
2026-07-06 10:40:57 -05:00
31 changed files with 3001 additions and 217 deletions
+5
View File
@@ -41,3 +41,8 @@ Thumbs.db
# NPU runtime bundle: a large binary artifact hosted as a Gitea package, not
# committed. The folder's README is tracked; the zip is produced locally.
packaging/npu-runtime/*.zip
# Vulkan loader staged next to the exe / into src-tauri by build.rs for the
# --features vulkan build (bundled into the installer); a redistributable blob,
# not committed.
src-tauri/vulkan-1.dll
+38 -17
View File
@@ -7,11 +7,13 @@ on-device acceleration (**NPU → GPU → CPU**), labels speakers, structures th
Markdown notes, and optionally augments them with a locally hosted LLM (Ollama). Audio and
transcripts **never leave the machine** unless you explicitly configure a destination.
> **Status: working application (v0.1.5).** Capture, transcription (CPU / Intel NPU / Vulkan
> GPU), speaker diarization, storage + crash recovery, local-LLM summaries, opt-in recording,
> at-rest encryption, and self-hosted sync are implemented and ship as signed **MSI + NSIS**
> installers. Outlook `.pst`/calendar context and the coding-agent (MCP) handoff are in
> progress. Build order and remaining tasks are in [`docs/05-roadmap.md`](docs/05-roadmap.md).
> **Status: working application (v0.2.0).** Capture (system audio **+ your microphone**),
> transcription (CPU / Intel NPU / Vulkan GPU), speaker diarization, storage + crash recovery,
> local-LLM summaries, AI tags, opt-in recording with **in-app playback**, at-rest encryption,
> and self-hosted sync are implemented and ship as a single signed **MSI + NSIS** universal
> installer. Outlook `.pst`/calendar context (recurring-event import + filtering) is landing;
> the coding-agent (MCP) handoff is in progress. Build order and remaining tasks are in
> [`docs/05-roadmap.md`](docs/05-roadmap.md).
## Why WhispAssist — Granola vs Meetily vs WhispAssist
@@ -44,10 +46,12 @@ accelerated, zero-egress-by-default** option: it exploits the NPU/GPU in modern
everything on the device unless you opt in, and adds Windows-specific context (Outlook) and a
coding-agent handoff.
## What's built (v0.1.5)
## What's built (v0.2.0)
- **Bot-free capture** — WASAPI loopback records the system mix (all participants) with no
meeting bot and no per-app plumbing.
- **Bot-free capture — now both sides** — WASAPI loopback records the system mix (all
participants), and an optional **microphone** path captures your own voice, mixed into both the
live transcript and the saved recording. Pick a specific output/input or turn the mic off in
**Settings ▸ Hardware**. No meeting bot, no per-app plumbing.
- **Local transcription with a hardware ladder** — whisper.cpp via `whisper-rs` on CPU; the
**Intel NPU** via ONNX Runtime + OpenVINO; **GPU via Vulkan** (a single binary that runs on
NVIDIA, AMD, and Intel). WA detects the hardware, picks the best backend
@@ -61,17 +65,24 @@ coding-agent handoff.
- **Storage & crash recovery** — SQLite + on-disk audio/transcripts under
`%LOCALAPPDATA%\WhispAssist`. Audio is the source of truth; notes and transcripts regenerate
after a crash.
- **Opt-in recording** — off by default; `.wav` retained only when you turn it on, after a
one-time consent notice.
- **Opt-in recording + in-app playback** — off by default; `.wav` retained only when you turn it
on, after a one-time consent notice. Play a saved recording back in the app — encrypted
recordings are decrypted **in memory on the fly** (nothing plaintext is written to disk).
Recordings are 16-bit for roughly half the size, and an accidental recording can be **cancelled**
(audio + transcript deleted).
- **Notes, summaries & AI tags** — Markdown notes with an **Editor/Preview** toggle; local-LLM
summaries and one-click **tag generation** with a chip-based tag editor and tag filtering.
- **At-rest encryption vault** — Argon2id key derivation + XChaCha20-Poly1305; transcripts,
notes, summaries, and recordings sealed on disk; startup unlock gate; keys zeroized on lock.
- **Self-hosted sync (optional, off by default)** — WebDAV (Nextcloud, ownCloud, Cloudreve,
Seafile, Synology) plus OneDrive/Dropbox/Box (OAuth 2.0 PKCE); durable retry queue with
backoff; **client-side encryption before upload** so the destination holds only ciphertext.
Credentials live only in the OS credential store.
backoff and **live per-item upload progress**; **client-side encryption before upload** so the
destination holds only ciphertext. Credentials live only in the OS credential store.
- **Optional hosted AI** — Anthropic and OpenAI-compatible providers behind the same
`LlmProvider` interface, off by default (third-party egress, keys in the OS credential store).
- **Installers** — signed MSI and NSIS `-setup.exe`.
- **One universal installer** — a single signed MSI and NSIS `-setup.exe` that covers every
machine: Vulkan for all GPUs, the Intel NPU path, and CPU fallback. The Vulkan loader is bundled
so it launches even on machines without a GPU driver.
**In progress:** Outlook `.pst` + calendar context, the local **MCP server** that hands meeting
context to your coding agents (Claude, Codex, Copilot, OpenCode), and MS Graph calendar.
@@ -142,18 +153,28 @@ npm install
npm run tauri dev # CPU/NPU build
```
**GPU (Vulkan) build.** whisper.cpp's GPU backends are compiled in (not downloaded at runtime),
so a GPU build needs a one-time toolchain setup — the **Vulkan SDK**, a **Ninja** generator, and
a short target dir (to dodge Windows' 260-char path limit in the shader build):
**Release build (single universal installer).** whisper.cpp's GPU backends are compiled in (not
downloaded at runtime), so the release build needs a one-time toolchain setup — the **Vulkan SDK**,
a **Ninja** generator, and a short target dir (to dodge Windows' 260-char path limit in the shader
build):
```bash
# after: Vulkan SDK installed, ninja.exe on PATH, vcvars64 loaded
set VULKAN_SDK=C:\VulkanSDK\1.4.350.0
set CMAKE_GENERATOR=Ninja
set CARGO_TARGET_DIR=C:\wt
npm run tauri build -- --features vulkan
npm run tauri build -- --features vulkan --config src-tauri/tauri.vulkan.conf.json
```
This one build covers **every** machine: Vulkan accelerates all GPUs (NVIDIA/AMD/Intel), the Intel
NPU path works via the runtime OpenVINO download, and CPU is the fallback. The `--features vulkan`
binary links `vulkan-1.dll`, so `build.rs` stages the redistributable Vulkan **loader** (from
`VULKAN_SDK\Bin`, or System32) next to the exe and `tauri.vulkan.conf.json` bundles it into the
installer — the app then launches even on a machine with no GPU driver (it reports zero Vulkan
devices and decodes on the CPU). DirectML is intentionally not offered here because Vulkan already
covers those GPUs; it's the GPU path only in the plain `npm run tauri build` (no Vulkan) variant,
kept as an internal fallback.
CUDA (NVIDIA-only, faster) is planned as an optional variant. The full, gotcha-annotated build
recipe lives in the project notes.
+5
View File
@@ -16,6 +16,9 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr
| FR-CAP-4 | M | 1 | Show an unambiguous "recording active" indicator (in-app banner + tray icon). |
| FR-CAP-5 | S | 7 | Render a live input waveform / level meter while recording. |
| FR-CAP-6 | S | 7 | Handle audio device changes mid-recording without losing the session. |
| FR-CAP-7 | S | 1 | Optionally capture the user's **microphone** alongside loopback, mixing it into **both** the live transcript and the saved recording (default ON, local-only/no egress, selectable device + "off"). |
| FR-CAP-8 | S | 1 | Write the retained recording as **16-bit PCM** at the device's native rate/channels — roughly half the size of the 32-bit-float mix, with no material quality loss for speech. |
| FR-CAP-9 | S | 1 | **Cancel** an in-progress recording: stop capture, delete working files, and remove the meeting from the DB entirely (for one started by mistake — no finalize/transcript/sync). |
### Recording retention & consent (REC) — see ADR-0009
@@ -25,6 +28,7 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr
| FR-REC-2 | M | 1 | Before retaining a recording for the first time (and shown near the toggle thereafter), display a consent notice: recording without participants' consent may be illegal in some regions; advise checking local laws. Require a one-time acknowledgment; store it. This is a caution, not legal advice. |
| FR-REC-3 | M | 1 | When retention is on, the UI indicates the meeting is being **saved** (in addition to the "recording active" indicator, FR-CAP-4). |
| FR-REC-4 | M | 2 | Deleting working audio on finalize happens only **after** the transcript is successfully finalized; never race with crash recovery (audio stays source of truth until then). |
| FR-REC-5 | S | 2 | Play a meeting's retained `.wav` back in the app (decrypting a vault-sealed recording on demand for playback). |
### Hardware acceleration (HW)
@@ -152,6 +156,7 @@ the local LLM endpoint, and it is **off by default**. Primary targets are self-h
| FR-SYNC-8 | M | 9 | Surface sync state in the UI and label targets: self-hosted/primary as "your server"; third-party clouds carry a clear "data leaves your device to a third party" banner. |
| FR-SYNC-9 | S | 9 | **Secondary** targets via provider APIs + OAuth 2.0 (PKCE, loopback redirect): **OneDrive** (MS Graph), **Dropbox**, **Box**. |
| FR-SYNC-10 | C | 9 | Optional client-side encryption of artifacts before upload (ties to FR-SEC-3): destination holds only ciphertext. |
| FR-SYNC-11 | S | 9 | Show **live per-item upload progress** while syncing (stream the upload body and report bytes sent per artifact). |
### UX, accessibility, recovery (UX)
+1 -1
View File
@@ -55,7 +55,7 @@ touches files, DB, or network directly — only Tauri commands/events (`04-api-c
| Service | Responsibility | Primary crate(s) |
|---|---|---|
| `audio` | WASAPI loopback capture; PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` |
| `audio` | WASAPI loopback capture (+ optional microphone, mixed into the transcript stream, FR-CAP-7); PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` |
| `hardware` | Enumerate NPU/GPU/CPU; rank backends; report capabilities | `ort`, DXGI via `windows` |
| `transcription` | Load model on a backend; stream segments (whisper.cpp) or NPU (ONNX) | `whisper-rs`, `ort` |
| `diarization` | Post-process audio → speaker spans; align to segments; merge | `sherpa-onnx` (FFI) |
+6 -1
View File
@@ -125,7 +125,7 @@ privacy_self_check(): {
## 2. Tauri events (Rust → frontend)
```ts
"recording://state" { meetingId, state: "recording"|"paused"|"stopped", elapsedMs }
"recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs }
"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5)
"recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6)
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2)
@@ -154,10 +154,15 @@ indicative (async where I/O-bound).
pub trait AudioCapture: Send + Sync {
/// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`.
fn start(&self, wav_path: &Path, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
/// Capture the user's microphone (FR-CAP-7); frames pushed to `sink`, no WAV.
fn start_microphone(&self, device_id: Option<&str>, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>;
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>;
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
}
// When the mic is enabled, `spawn_mixer` sums the loopback + mic 16kHz-mono
// frames into the single transcription stream (`list_input_devices` enumerates
// mic devices, mirroring `list_audio_devices` for render devices).
// hardware/mod.rs
pub trait HardwareDetector: Send + Sync {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "whispassist",
"private": true,
"version": "0.1.5",
"version": "0.2.0",
"type": "module",
"description": "Privacy-first, fully local Windows meeting assistant.",
"license": "MIT OR Apache-2.0",
+4 -1
View File
@@ -2209,7 +2209,9 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"log",
"windows-sys 0.60.2",
"zeroize",
]
@@ -5973,11 +5975,12 @@ dependencies = [
[[package]]
name = "whispassist"
version = "0.1.5"
version = "0.2.0"
dependencies = [
"argon2",
"async-trait",
"chacha20poly1305",
"chrono",
"docx-rs",
"futures-util",
"getrandom 0.2.17",
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "whispassist"
version = "0.1.5"
version = "0.2.0"
description = "Privacy-first, fully local Windows meeting assistant"
authors = ["WhispAssist contributors"]
license = "MIT OR Apache-2.0"
@@ -27,6 +27,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4"] }
# Local<->UTC, DST-aware — needed for calendar recurrence (T6.2); already in
# the dependency tree transitively (sqlx), this just promotes it to direct.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# storage
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] }
@@ -43,7 +46,7 @@ argon2 = "0.5"
chacha20poly1305 = "0.10"
getrandom = "0.2"
zeroize = "1" # wipe key material from memory on lock
keyring = { version = "3", optional = true } # OS credential store (sync + AI creds)
keyring = { version = "3", optional = true, features = ["windows-native"] } # OS credential store (sync + AI creds); windows-native = real Credential Manager (else keyring 3.x uses a no-op mock store)
rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011)
# audio / transcription / diarization / calendar are integrated per-phase and are
+46
View File
@@ -14,5 +14,51 @@ fn main() {
println!("cargo:rustc-env=WA_GIT_HASH={hash}");
println!("cargo:rerun-if-changed=../.git/logs/HEAD");
// The `vulkan` build links `vulkan-1.dll` at load time, so the exe won't
// launch on a machine that lacks the Vulkan loader (no GPU driver / bare VM).
// Bundling the redistributable loader (Apache-2.0) next to the exe makes the
// single universal installer start everywhere — with no GPU it simply reports
// zero devices and we fall back to CPU. Copied both next to the built exe (so
// `tauri dev`/`cargo run` work) and into the crate dir where the bundler picks
// it up as a resource (see tauri.vulkan.conf.json).
if std::env::var_os("CARGO_FEATURE_VULKAN").is_some() {
stage_vulkan_loader();
}
tauri_build::build();
}
/// Locate `vulkan-1.dll` (Vulkan SDK first, then System32) and copy it beside
/// the compiled exe and into the crate dir for bundling. Warns rather than fails
/// so a dev build on a machine with the loader already on PATH still succeeds.
fn stage_vulkan_loader() {
use std::path::{Path, PathBuf};
let source = std::env::var_os("VULKAN_SDK")
.map(|sdk| Path::new(&sdk).join("Bin").join("vulkan-1.dll"))
.filter(|p| p.exists())
.or_else(|| {
let sys = PathBuf::from(r"C:\Windows\System32\vulkan-1.dll");
sys.exists().then_some(sys)
});
let Some(source) = source else {
println!(
"cargo:warning=vulkan feature is on but vulkan-1.dll wasn't found \
(set VULKAN_SDK); the installer won't bundle the Vulkan loader"
);
return;
};
// Beside the exe: OUT_DIR is target/<profile>/build/<pkg>-<hash>/out, so three
// parents up is target/<profile> (correct even under CARGO_TARGET_DIR=C:\wt).
if let Some(out_dir) = std::env::var_os("OUT_DIR") {
if let Some(exe_dir) = Path::new(&out_dir).ancestors().nth(3) {
let _ = std::fs::copy(&source, exe_dir.join("vulkan-1.dll"));
}
}
// Into the crate dir for the bundler resource.
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
if let Err(e) = std::fs::copy(&source, Path::new(&manifest_dir).join("vulkan-1.dll")) {
println!("cargo:warning=failed to stage vulkan-1.dll for bundling: {e}");
}
}
File diff suppressed because it is too large Load Diff
+423 -16
View File
@@ -9,6 +9,7 @@
//! writes to it.
use crate::models::{AttendeeInfo, CalendarEvent, ImportedEvent};
use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc};
use std::path::Path;
use std::process::Command;
@@ -79,9 +80,15 @@ fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
_ => CalError::Open(e.to_string()),
})?;
if !output.status.success() {
return Err(CalError::Parse(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
// readpst writes some errors to stdout rather than stderr; show
// whichever stream actually has text, stderr first.
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = if stderr.is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr
};
return Err(CalError::Parse(message));
}
Ok(())
}
@@ -202,6 +209,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
let mut description = None;
let mut starts_at = None;
let mut ends_at = None;
let mut rrule: Option<String> = None;
let mut attendees: Vec<AttendeeInfo> = Vec::new();
for line in &lines {
@@ -217,22 +225,49 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
description = None;
starts_at = None;
ends_at = None;
rrule = None;
attendees = Vec::new();
}
"END" if value.eq_ignore_ascii_case("VEVENT") && in_event => {
events.push(ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
source: source.to_string(),
subject: summary.take(),
organizer: organizer.take(),
starts_at,
ends_at,
description: description.take(),
raw_uid: uid.take(),
},
attendees: std::mem::take(&mut attendees),
});
let attendees = std::mem::take(&mut attendees);
match (rrule.take(), &uid, starts_at) {
// A recurring event needs a UID to key its occurrences
// for dedup (source, raw_uid) — without one, fall back
// to importing just the single stored occurrence below.
(Some(rule), Some(base_uid), Some(dtstart)) => {
let duration = ends_at.map(|e| e - dtstart).unwrap_or(0);
for occ_start in expand_rrule(&rule, dtstart) {
events.push(ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
source: source.to_string(),
subject: summary.clone(),
organizer: organizer.clone(),
starts_at: Some(occ_start),
ends_at: Some(occ_start + duration),
description: description.clone(),
raw_uid: Some(format!("{base_uid}@{}", ymd_digits(occ_start))),
},
attendees: attendees.clone(),
});
}
}
_ => {
events.push(ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
source: source.to_string(),
subject: summary.take(),
organizer: organizer.take(),
starts_at,
ends_at,
description: description.take(),
raw_uid: uid.take(),
},
attendees,
});
}
}
in_event = false;
}
"UID" if in_event => uid = Some(value.to_string()),
@@ -240,6 +275,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
"DESCRIPTION" if in_event => description = Some(unescape_text(value)),
"DTSTART" if in_event => starts_at = parse_ics_datetime(value),
"DTEND" if in_event => ends_at = parse_ics_datetime(value),
"RRULE" if in_event => rrule = Some(value.to_string()),
"ORGANIZER" if in_event => {
let (name, email) = cal_address(params, value);
organizer = name.or(email);
@@ -261,6 +297,266 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
events
}
// ---- RRULE (RFC 5545 recurrence) expansion — pure, no I/O ----
// ponytail: caps for RRULEs with no COUNT/UNTIL (only YEARLY holidays do this
// in practice) and a hard ceiling regardless — extend both if a real series
// needs more instances than this.
const RECURRENCE_HORIZON_YEARS: i64 = 10;
const RECURRENCE_MAX_OCCURRENCES: usize = 500;
enum Freq {
Daily,
Weekly,
Monthly,
Yearly,
}
struct Rrule {
freq: Freq,
interval: i64,
count: Option<usize>,
until: Option<i64>,
byday: Vec<u32>, // weekday indices, 0=SU..6=SA
bymonthday: Vec<u32>, // 1..31
bymonth: Vec<u32>, // 1..12
}
fn weekday_code_to_index(code: &str) -> Option<u32> {
// Strips a leading ordinal like "2MO" ("2nd Monday") — not seen in this
// codebase's real-world data (only plain weekday codes are), but the
// weekday is all this parser uses either way.
let letters: String = code.chars().filter(|c| c.is_ascii_alphabetic()).collect();
match letters.to_ascii_uppercase().as_str() {
"SU" => Some(0),
"MO" => Some(1),
"TU" => Some(2),
"WE" => Some(3),
"TH" => Some(4),
"FR" => Some(5),
"SA" => Some(6),
_ => None,
}
}
/// Parses `FREQ=WEEKLY;COUNT=26;BYDAY=MO`-style RRULE values. Supports
/// DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/
/// BYMONTH — every combination confirmed present in a real 7.2GB mailbox
/// (ADR-0008). No BYSETPOS, no per-occurrence exceptions (RECURRENCE-ID).
fn parse_rrule(rule: &str) -> Option<Rrule> {
let mut freq = None;
let mut interval = 1i64;
let mut count = None;
let mut until = None;
let mut byday = Vec::new();
let mut bymonthday = Vec::new();
let mut bymonth = Vec::new();
let mut current_list_key = String::new();
for part in rule.split(';') {
// readpst joins a multi-value BYDAY with `;` instead of RFC 5545's
// `,` (e.g. `BYDAY=MO;TU;WE;TH;FR`), so a continuation token has no
// `=` at all — attribute it to whichever list key came before it.
let (key, v) = match part.split_once('=') {
Some((k, v)) => {
current_list_key = k.to_ascii_uppercase();
(current_list_key.as_str(), v)
}
None => (current_list_key.as_str(), part),
};
match key {
"FREQ" => {
freq = match v.to_ascii_uppercase().as_str() {
"DAILY" => Some(Freq::Daily),
"WEEKLY" => Some(Freq::Weekly),
"MONTHLY" => Some(Freq::Monthly),
"YEARLY" => Some(Freq::Yearly),
_ => None,
}
}
"INTERVAL" => interval = v.parse().unwrap_or(1).max(1),
"COUNT" => count = v.parse().ok(),
"UNTIL" => until = parse_ics_datetime(v),
"BYDAY" => byday.extend(v.split(',').filter_map(weekday_code_to_index)),
"BYMONTHDAY" => bymonthday.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
"BYMONTH" => bymonth.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
_ => {}
}
}
Some(Rrule {
freq: freq?,
interval,
count,
until,
byday,
bymonthday,
bymonth,
})
}
/// Resolves a local wall-clock datetime to a UTC unix timestamp, applying
/// whatever DST rule the OS has for that specific calendar date — this is
/// what keeps a recurring meeting at the same local time across a DST
/// transition instead of drifting by an hour. A skipped (spring-forward gap)
/// or ambiguous (fall-back overlap) local time resolves to the OS's earliest
/// matching instant rather than failing outright.
fn local_to_utc_secs(dt: chrono::NaiveDateTime) -> i64 {
match Local.from_local_datetime(&dt) {
chrono::LocalResult::Single(ldt) | chrono::LocalResult::Ambiguous(ldt, _) => {
ldt.with_timezone(&Utc).timestamp()
}
chrono::LocalResult::None => dt.and_utc().timestamp(),
}
}
fn ymd_digits(unix_secs: i64) -> String {
match Utc.timestamp_opt(unix_secs, 0).single() {
Some(dt) => {
let d = dt.with_timezone(&Local).date_naive();
format!("{:04}{:02}{:02}", d.year(), d.month(), d.day())
}
None => String::new(),
}
}
/// Expands an RRULE into occurrence start timestamps (unix seconds). Each
/// occurrence keeps `dtstart`'s *local* wall-clock time-of-day (assuming the
/// meeting's timezone matches this machine's — reasonable for a single-user
/// tool reading its own Outlook data), re-resolving the UTC offset per
/// occurrence so a series spanning a DST transition doesn't drift by an hour.
fn expand_rrule(rule: &str, dtstart: i64) -> Vec<i64> {
let Some(r) = parse_rrule(rule) else {
return vec![dtstart];
};
let Some(dtstart_utc) = Utc.timestamp_opt(dtstart, 0).single() else {
return vec![dtstart];
};
let local_start = dtstart_utc.with_timezone(&Local).naive_local();
let (hour, min, sec) = (
local_start.hour(),
local_start.minute(),
local_start.second(),
);
let start_date = local_start.date();
let indefinite = r.count.is_none() && r.until.is_none();
let effective_until = if indefinite {
dtstart + RECURRENCE_HORIZON_YEARS * 365 * 86_400
} else {
r.until.unwrap_or(i64::MAX)
};
let count_cap = r
.count
.unwrap_or(RECURRENCE_MAX_OCCURRENCES)
.min(RECURRENCE_MAX_OCCURRENCES);
let at = |date: NaiveDate| date.and_hms_opt(hour, min, sec).map(local_to_utc_secs);
let mut occurrences = Vec::new();
match r.freq {
// Outlook emits "every weekday" as either FREQ, always with BYDAY —
// both iterate calendar weeks and keep the requested weekdays.
Freq::Weekly | Freq::Daily if !r.byday.is_empty() => {
let step_weeks = if matches!(r.freq, Freq::Weekly) {
r.interval
} else {
1
};
let mut week_start =
start_date - Duration::days(start_date.weekday().num_days_from_sunday() as i64);
'weeks: loop {
for &wd in &r.byday {
let date = week_start + Duration::days(wd as i64);
if date < start_date {
continue;
}
let Some(ts) = at(date) else { continue };
if ts > effective_until || occurrences.len() >= count_cap {
break 'weeks;
}
occurrences.push(ts);
}
week_start += Duration::weeks(step_weeks);
}
}
Freq::Daily => {
let mut date = start_date;
while let Some(ts) = at(date) {
if ts > effective_until || occurrences.len() >= count_cap {
break;
}
occurrences.push(ts);
date += Duration::days(r.interval);
}
}
Freq::Weekly => {
let mut date = start_date;
while let Some(ts) = at(date) {
if ts > effective_until || occurrences.len() >= count_cap {
break;
}
occurrences.push(ts);
date += Duration::weeks(r.interval);
}
}
Freq::Monthly => {
let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day());
let mut idx: i64 = 0;
loop {
let total = start_date.month0() as i64 + idx * r.interval;
let year = start_date.year() + total.div_euclid(12) as i32;
let month = (total.rem_euclid(12) + 1) as u32;
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) {
if date >= start_date {
if let Some(ts) = at(date) {
if ts > effective_until || occurrences.len() >= count_cap {
break;
}
occurrences.push(ts);
}
}
}
idx += 1;
if idx as usize > RECURRENCE_MAX_OCCURRENCES * 2 {
break; // safety valve against a pathological rule
}
}
}
Freq::Yearly => {
let months = if r.bymonth.is_empty() {
vec![start_date.month()]
} else {
r.bymonth.clone()
};
let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day());
let mut year = start_date.year();
loop {
for &month in &months {
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) {
if date >= start_date {
if let Some(ts) = at(date) {
if ts <= effective_until && occurrences.len() < count_cap {
occurrences.push(ts);
}
}
}
}
}
year += r.interval as i32;
if occurrences.len() >= count_cap
|| year > start_date.year() + (RECURRENCE_HORIZON_YEARS * 2) as i32
{
break;
}
}
}
}
if occurrences.is_empty() {
vec![dtstart]
} else {
occurrences
}
}
/// Parses an iCalendar DATE-TIME (`20260701T090000Z` / `20260701T090000`) or
/// DATE (`20260701`) value to a unix epoch. Both `Z`-suffixed and floating
/// (no `Z`, no `TZID`) values are treated as UTC — full IANA timezone
@@ -402,4 +698,115 @@ END:VCALENDAR\r\n";
});
assert!(matches!(result, Err(CalError::Open(_))));
}
// These assert on *local* wall-clock time rather than raw UTC offsets —
// that's the entire point of the DST fix (a fixed UTC time-of-day is
// exactly the bug: a recurring meeting drifts an hour across a DST
// transition). Local-time assertions depend on this machine's configured
// timezone, same as the production code they're testing.
fn local_hms(ts: i64) -> (u32, u32, u32) {
let dt = Utc.timestamp_opt(ts, 0).unwrap().with_timezone(&Local);
(dt.hour(), dt.minute(), dt.second())
}
fn local_date(ts: i64) -> NaiveDate {
Utc.timestamp_opt(ts, 0)
.unwrap()
.with_timezone(&Local)
.date_naive()
}
#[test]
fn expand_rrule_weekly_single_byday_matches_the_real_1on1_pattern() {
// The exact rule readpst produced for a real "Weekly 1:1" on Mondays.
let dtstart = ymd_hms_to_unix(2026, 1, 12, 16, 30, 0); // a Monday
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=26;BYDAY=MO", dtstart);
assert_eq!(occurrences.len(), 26);
assert_eq!(occurrences[0], dtstart);
let expected_hms = local_hms(dtstart);
for pair in occurrences.windows(2) {
assert_eq!((local_date(pair[1]) - local_date(pair[0])).num_days(), 7);
}
for occ in &occurrences {
assert_eq!(
local_hms(*occ),
expected_hms,
"local wall-clock time must not drift across a DST transition"
);
assert_eq!(local_date(*occ).weekday(), chrono::Weekday::Mon);
}
}
#[test]
fn expand_rrule_weekly_multi_byday_covers_every_weekday_in_order() {
let dtstart = ymd_hms_to_unix(2026, 1, 12, 9, 0, 0); // Monday
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=10;BYDAY=MO;TU;WE;TH;FR", dtstart);
assert_eq!(occurrences.len(), 10);
// Mon..Fri week 1, then Mon..Fri week 2 — a flat +1 day step except
// the weekend gap between index 4 (Fri) and 5 (next Mon).
for i in 0..4 {
assert_eq!(
(local_date(occurrences[i + 1]) - local_date(occurrences[i])).num_days(),
1
);
}
assert_eq!(
(local_date(occurrences[5]) - local_date(occurrences[4])).num_days(),
3
);
let expected_hms = local_hms(dtstart);
for occ in &occurrences {
assert_eq!(local_hms(*occ), expected_hms);
}
}
#[test]
fn expand_rrule_monthly_bymonthday_steps_calendar_months() {
let dtstart = ymd_hms_to_unix(2026, 1, 1, 9, 0, 0);
let occurrences = expand_rrule("FREQ=MONTHLY;COUNT=7;BYMONTHDAY=1", dtstart);
assert_eq!(occurrences.len(), 7);
let last = local_date(occurrences[6]);
assert_eq!((last.year(), last.month(), last.day()), (2026, 7, 1));
let expected_hms = local_hms(dtstart);
for occ in &occurrences {
assert_eq!(local_hms(*occ), expected_hms);
}
}
#[test]
fn expand_rrule_yearly_with_no_count_or_until_is_capped_by_the_horizon() {
let dtstart = ymd_hms_to_unix(2020, 11, 11, 17, 0, 0); // afternoon UTC, safe midnight margin
let occurrences = expand_rrule("FREQ=YEARLY;BYMONTHDAY=11;BYMONTH=11", dtstart);
assert!(
!occurrences.is_empty() && occurrences.len() <= RECURRENCE_HORIZON_YEARS as usize + 2
);
for occ in &occurrences {
let d = local_date(*occ);
assert_eq!((d.month(), d.day()), (11, 11));
}
}
#[test]
fn parse_vevents_expands_a_recurring_event_into_distinct_occurrences() {
let ics = "BEGIN:VEVENT\r\n\
UID:series-1\r\n\
SUMMARY:Weekly 1:1\r\n\
DTSTART:20260112T163000Z\r\n\
DTEND:20260112T170000Z\r\n\
RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO\r\n\
END:VEVENT\r\n";
let events = parse_vevents(ics, "pst");
assert_eq!(events.len(), 3);
let raw_uids: Vec<_> = events.iter().map(|e| e.event.raw_uid.clone()).collect();
assert_eq!(
raw_uids.len(),
raw_uids
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
);
for e in &events {
assert_eq!(e.event.subject.as_deref(), Some("Weekly 1:1"));
assert_eq!(e.event.ends_at.unwrap() - e.event.starts_at.unwrap(), 1800);
}
}
}
+432 -22
View File
@@ -60,6 +60,11 @@ fn default_settings() -> Settings {
sync_enabled: false,
retention_max_age_days: None,
retention_max_size_gb: None,
pst_last_path: None,
pst_auto_sync: false,
audio_output_device: None,
microphone_enabled: true,
audio_input_device: None,
}
}
@@ -283,9 +288,49 @@ pub async fn start_recording(
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
// Level updates + device-change notices (FR-CAP-5/6); low-volume, forwarded to events below.
let (event_tx, event_rx) = std::sync::mpsc::sync_channel::<crate::audio::CaptureEvent>(8);
let capture = WasapiCapture
.start(&wav_path, frame_tx, event_tx)
.map_err(|e| WaError::new("audio", e.to_string()))?;
// When the mic is enabled (FR-CAP-7), capture it alongside loopback and let
// the mixer sum both into `frame_tx`; with it off, loopback feeds the
// transcription worker directly, exactly as before (no mixer overhead). A
// microphone that fails to open must not sink the meeting: we log and fall
// back to loopback-only (the mixer forwards loopback alone once its sink
// drops).
let (capture, mic_capture) = if settings.microphone_enabled {
// The mixer sums both streams for the live transcript; the bridge carries
// the mic into the loopback thread so the recorded WAV holds both sides
// at native quality (FR-CAP-7).
let bridge = crate::audio::MicBridge::shared();
let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx);
let capture = WasapiCapture
.start_loopback_recording(
&wav_path,
settings.audio_output_device.as_deref(),
loop_sink,
event_tx.clone(),
bridge.clone(),
)
.map_err(|e| WaError::new("audio", e.to_string()))?;
let mic = WasapiCapture
.start_microphone_recording(
settings.audio_input_device.as_deref(),
mic_sink,
event_tx,
bridge,
)
.map_err(|e| tracing::warn!("microphone capture unavailable: {e}"))
.ok();
(capture, mic)
} else {
let capture = WasapiCapture
.start(
&wav_path,
settings.audio_output_device.as_deref(),
frame_tx,
event_tx,
)
.map_err(|e| WaError::new("audio", e.to_string()))?;
(capture, None)
};
// Fire-and-forget: exits on its own once `event_tx` drops at capture stop;
// nothing downstream needs to join it.
@@ -441,6 +486,7 @@ pub async fn start_recording(
*guard = Some(RecordingSession {
meeting_id: meeting_id.clone(),
capture,
mic_capture,
retention: args.record,
wav_path,
started_at: std::time::Instant::now(),
@@ -489,8 +535,13 @@ pub async fn stop_recording(
let summary = WasapiCapture
.stop(session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
// The capture thread has dropped its `FrameSink`; the transcription worker's
// `recv()` now returns `Err` and the worker exits on its own — join to
// Stop the mic too (FR-CAP-7) so the mixer sees both sinks drop and closes
// `frame_tx`; ignore its summary/errors — the loopback WAV is the recording.
if let Some(mic) = session.mic_capture {
let _ = WasapiCapture.stop(mic);
}
// Both capture threads have now dropped their `FrameSink`s; the transcription
// worker's `recv()` returns `Err` and the worker exits on its own — join to
// guarantee it has fully drained the audio before we act on retention.
let _ = session.transcription_worker.join();
@@ -599,6 +650,58 @@ pub async fn stop_recording(
Ok(())
}
/// Abandon the in-progress recording: stop capture, drop the transcript, and
/// delete the meeting row + its working files entirely — for a recording that
/// was started by mistake. Unlike `stop_recording`, nothing is finalized,
/// transcribed further, diarized, retained, or synced.
#[tauri::command]
pub async fn cancel_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let mut guard = state.session.lock().await;
let session = match guard.take() {
Some(s) if s.meeting_id == meeting_id => s,
Some(s) => {
*guard = Some(s);
return Err(WaError::new(
"recording",
"meeting_id does not match the active recording",
));
}
None => {
return Err(WaError::new(
"recording",
"no meeting is currently recording",
))
}
};
drop(guard);
// Stop both captures so their frame sinks drop and the transcription worker
// exits; join it so nothing is still touching the files we're about to delete.
let _ = WasapiCapture.stop(session.capture);
if let Some(mic) = session.mic_capture {
let _ = WasapiCapture.stop(mic);
}
let _ = session.transcription_worker.join();
// Remove the DB row + the whole meeting folder (working audio.wav included).
state
.store
.delete_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
crate::update_tray_tooltip(&app, "WhispAssist — idle");
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "cancelled", "elapsedMs": 0 }),
);
Ok(())
}
#[tauri::command]
pub async fn pause_recording(
app: AppHandle,
@@ -613,6 +716,11 @@ pub async fn pause_recording(
WasapiCapture
.pause(&session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
if let Some(mic) = session.mic_capture.as_ref() {
WasapiCapture
.pause(mic)
.map_err(|e| WaError::new("audio", e.to_string()))?;
}
drop(guard);
let _ = app.emit(
"recording://state",
@@ -635,6 +743,11 @@ pub async fn resume_recording(
WasapiCapture
.resume(&session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
if let Some(mic) = session.mic_capture.as_ref() {
WasapiCapture
.resume(mic)
.map_err(|e| WaError::new("audio", e.to_string()))?;
}
drop(guard);
let _ = app.emit(
"recording://state",
@@ -903,6 +1016,28 @@ pub async fn hardware_status() -> WaResult<serde_json::Value> {
}))
}
/// Lists active render (playback) devices for the "Audio Devices" picker
/// (Settings ▸ Hardware) — loopback-only, matching the app's only capture
/// path (FR-CAP-1). Runs off the async runtime thread since device
/// enumeration is a blocking COM call.
#[tauri::command]
pub async fn list_audio_devices() -> WaResult<Vec<crate::audio::AudioDeviceInfo>> {
tauri::async_runtime::spawn_blocking(crate::audio::list_render_devices)
.await
.map_err(|e| WaError::new("audio", e.to_string()))?
.map_err(|e| WaError::new("audio", e.to_string()))
}
/// Enumerate capture (microphone) devices for the Settings "Microphone" picker
/// (FR-CAP-7). Blocking COM enumeration, so it runs off the async runtime thread.
#[tauri::command]
pub async fn list_input_devices() -> WaResult<Vec<crate::audio::AudioDeviceInfo>> {
tauri::async_runtime::spawn_blocking(crate::audio::list_capture_devices)
.await
.map_err(|e| WaError::new("audio", e.to_string()))?
.map_err(|e| WaError::new("audio", e.to_string()))
}
#[derive(Deserialize)]
pub struct SetPreferredBackendArgs {
pub backend: String, // "auto"|"npu"|"nvidia"|"amd"|"intel"|"cpu"
@@ -1513,6 +1648,141 @@ pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Return the `waaudio://` URL the in-app player loads for a meeting's recording
/// (FR-REC-5). The bytes are decrypted in memory on demand by `serve_recording`
/// — nothing plaintext is ever written to disk. Prechecks that a recording
/// exists and (if sealed) the vault is unlocked, so the UI can show a clear
/// error before playback; also clears any stale plaintext `audio.play.wav` left
/// by the previous file-based player.
#[tauri::command]
pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult<String> {
let id = meeting_id.clone();
tauri::async_runtime::spawn_blocking(move || {
let dir = meeting_dir(&id);
let wav = dir.join("audio.wav");
if !wav.exists() {
return Err(WaError::new(
"recording",
"no saved recording for this meeting",
));
}
// Cheap sealed check — read only the magic prefix, not the whole file.
let mut head = [0u8; 8];
let sealed = std::fs::File::open(&wav)
.and_then(|mut f| {
use std::io::Read;
let n = f.read(&mut head)?;
Ok(n)
})
.map(|n| crate::vault::is_sealed(&head[..n]))
.unwrap_or(false);
if sealed && !crate::vault::is_unlocked() {
return Err(WaError::new(
"recording",
"unlock the vault to play this recording",
));
}
// Drop any plaintext temp the old file-based player left behind.
let _ = std::fs::remove_file(dir.join("audio.play.wav"));
Ok(())
})
.await
.map_err(|e| WaError::new("recording", e.to_string()))??;
Ok(format!("http://waaudio.localhost/{meeting_id}"))
}
/// Custom-scheme handler backing the `waaudio://` URL: reads the meeting's
/// `audio.wav`, decrypts it in memory if sealed (T8.8), and streams the PCM to
/// the `<audio>` element with byte-range support for seeking. The plaintext
/// never touches the filesystem, so playback can't undermine encryption at rest.
pub(crate) fn serve_recording(
request: &tauri::http::Request<Vec<u8>>,
) -> tauri::http::Response<Vec<u8>> {
use tauri::http::{header, Response, StatusCode};
let fail = |code: StatusCode| {
Response::builder()
.status(code)
.body(Vec::new())
.expect("static error response")
};
// Guard the id against path traversal before joining it into a path.
let id = request.uri().path().trim_start_matches('/').to_string();
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return fail(StatusCode::BAD_REQUEST);
}
let raw = match std::fs::read(meeting_dir(&id).join("audio.wav")) {
Ok(b) => b,
Err(_) => return fail(StatusCode::NOT_FOUND),
};
let plain = match crate::vault::open(&raw) {
Ok(p) => p,
Err(_) => return fail(StatusCode::FORBIDDEN), // sealed + vault locked
};
let total = plain.len();
let base = || {
Response::builder()
.header(header::CONTENT_TYPE, "audio/wav")
.header(header::ACCEPT_RANGES, "bytes")
};
// Honour a single `Range` request so the player can seek.
if let Some(range) = request
.headers()
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
{
if let Some((start, end)) = parse_byte_range(range, total) {
return base()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"))
.header(header::CONTENT_LENGTH, (end - start + 1).to_string())
.body(plain[start..=end].to_vec())
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR));
}
}
base()
.status(StatusCode::OK)
.header(header::CONTENT_LENGTH, total.to_string())
.body(plain)
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR))
}
/// Parse a single `bytes=start-end` range against `total`, returning inclusive
/// clamped indices. Supports open-ended (`start-`) and suffix (`-n`) forms; only
/// the common single-range case is handled (enough for an `<audio>` element).
fn parse_byte_range(header: &str, total: usize) -> Option<(usize, usize)> {
if total == 0 {
return None;
}
let (s, e) = header.strip_prefix("bytes=")?.split_once('-')?;
if s.is_empty() {
let n: usize = e.parse().ok()?;
return Some((total.saturating_sub(n), total - 1));
}
let start: usize = s.parse().ok()?;
let end = if e.is_empty() {
total - 1
} else {
e.parse::<usize>().ok()?.min(total - 1)
};
(start <= end && start < total).then_some((start, end))
}
/// Remove any leftover plaintext `audio.play.wav` files from the previous
/// file-based player, so no decrypted audio lingers on disk (T8.8).
pub(crate) fn cleanup_playback_temp() {
let Ok(entries) = std::fs::read_dir(crate::paths::meetings_dir()) else {
return;
};
for entry in entries.flatten() {
let play = entry.path().join("audio.play.wav");
if play.exists() {
let _ = std::fs::remove_file(play);
}
}
}
#[tauri::command]
pub async fn update_notes(
state: State<'_, AppState>,
@@ -1939,6 +2209,46 @@ pub async fn confirm_action_items(
Ok(())
}
/// Suggests 1-8 short topical tags from the transcript (T8.3, FR-SEARCH-2) —
/// same LLM path/prompt assembly as generate_summary, just a much shorter
/// non-streamed reply. Suggestions are NOT saved automatically; the caller
/// reviews/merges them and still calls set_tags to persist, same as a
/// manually typed tag.
#[tauri::command]
pub async fn generate_tags(
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<Vec<String>> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"llm",
"cannot generate tags while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
let settings = load_settings();
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let prompt = build_prompt(&meeting, None);
let text = format!("{}\n\n{}", prompt.metadata, prompt.transcript);
provider
.suggest_tags(&text)
.await
.map_err(|e| WaError::new("llm", e.to_string()))
}
// ---- Calendar / .pst (Phase 6) ----
/// Import events + attendees from a `.pst` (T6.1/T6.2, FR-CAL-1). The
@@ -1947,10 +2257,12 @@ pub async fn confirm_action_items(
/// progress, so `pst://progress` is a start/done signal rather than
/// per-item — real per-item progress would mean reimplementing readpst's
/// internals, not worth it for a one-shot import.
#[tauri::command]
pub async fn import_pst(
app: AppHandle,
state: State<'_, AppState>,
///
/// Split from the `#[tauri::command]` wrapper so the startup auto-sync pass
/// (T6.2, `pst_auto_sync`) can call the same logic without a `State` extractor.
pub(crate) async fn import_pst_core(
app: &AppHandle,
store: &dyn crate::storage::Store,
path: String,
password: Option<String>,
) -> WaResult<u32> {
@@ -1967,8 +2279,7 @@ pub async fn import_pst(
serde_json::json!({ "processed": 0, "total": total }),
);
let imported = state
.store
let imported = store
.import_calendar_events(events)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
@@ -1980,6 +2291,16 @@ pub async fn import_pst(
Ok(imported)
}
#[tauri::command]
pub async fn import_pst(
app: AppHandle,
state: State<'_, AppState>,
path: String,
password: Option<String>,
) -> WaResult<u32> {
import_pst_core(&app, state.store.as_ref(), path, password).await
}
/// Browse imported calendar events (T6.3, FR-CAL-2).
#[tauri::command]
pub async fn list_calendar_events(
@@ -2023,6 +2344,25 @@ pub async fn attach_meeting_to_event(
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Manually rename a meeting — recordings default to "Untitled meeting" with
/// no prior way to change that from the UI.
#[tauri::command]
pub async fn rename_meeting(
state: State<'_, AppState>,
meeting_id: MeetingId,
title: String,
) -> WaResult<()> {
let title = title.trim();
if title.is_empty() {
return Err(WaError::new("storage", "title cannot be empty"));
}
state
.store
.rename_meeting(&meeting_id, title)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
// ---- Sync / upload (Phase 9, ADR-0010) ----
/// Config payload for add/update (snake_case, matching the TS `SyncTargetConfig`
@@ -2349,15 +2689,54 @@ pub async fn test_sync_target(
let hints = provider_setup_hints(config.provider_hint.as_deref());
let (target, temp_ref): (Box<dyn SyncTarget>, Option<String>) =
if let Some(id) = config.id.clone() {
// Existing target (any kind) — dispatch to its concrete impl.
let row = state
.store
.get_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
let target = crate::sync::build_sync_target(&row)
.map_err(|e| WaError::new("sync", e.to_string()))?;
(target, None)
if row.kind == "webdav" {
// Editing an existing webdav target: test the values on screen but
// reuse the STORED password (via its credential_ref) unless the user
// typed a new one — so "Test" works after a URL fix without having to
// re-enter the password.
let (credential_ref, temp_ref) =
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4());
crate::sync::credentials::set(&temp_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
(temp_ref.clone(), Some(temp_ref))
} else {
(row.credential_ref.clone(), None)
};
let target = crate::sync::WebDavTarget {
base_url: config
.base_url
.clone()
.or(row.base_url.clone())
.unwrap_or_default(),
provider_hint: config.provider_hint.clone().or(row.provider_hint.clone()),
remote_base_path: config
.remote_base_path
.clone()
.unwrap_or(row.remote_base_path.clone()),
username: config
.username
.clone()
.or(row.username.clone())
.unwrap_or_default(),
credential_ref,
third_party: false,
allow_plaintext_lan: config
.allow_plaintext_lan
.unwrap_or(row.allow_plaintext_lan),
};
(Box::new(target), temp_ref)
} else {
// Non-webdav (OAuth) target — dispatch to its concrete impl as-is.
let target = crate::sync::build_sync_target(&row)
.map_err(|e| WaError::new("sync", e.to_string()))?;
(target, None)
}
} else {
// Unsaved WebDAV target: stash the secret under a temp credential_ref so
// the same resolve-at-use path works, then clean it up after the test.
@@ -2372,6 +2751,7 @@ pub async fn test_sync_target(
}
let target = crate::sync::WebDavTarget {
base_url,
provider_hint: config.provider_hint.clone(),
remote_base_path: config
.remote_base_path
.clone()
@@ -2579,6 +2959,7 @@ async fn upload_job(
target: &dyn crate::sync::SyncTarget,
row: &crate::storage::SyncJobRow,
encrypt: bool,
mut on_progress: impl FnMut(u64, u64) + Send + 'static,
) -> Result<u64, crate::sync::SyncError> {
use crate::sync::SyncError;
let remote_dir = row
@@ -2588,6 +2969,22 @@ async fn upload_job(
.unwrap_or("");
target.ensure_dir(remote_dir).await?;
let (tx, rx) = std::sync::mpsc::channel();
// Forward live upload progress off the async task (the sink is a blocking
// std channel): throttled so a large recording emits a steady stream of
// `sync://job` updates without flooding the UI. Ends when `put` drops `tx`.
let fallback_total = row.bytes_total.unwrap_or(0) as u64;
let drain = std::thread::spawn(move || {
let mut last = 0u64;
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_millis(500);
while let Ok((sent, total)) = rx.recv() {
last = sent;
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
on_progress(sent, total);
last_emit = std::time::Instant::now();
}
}
last
});
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
@@ -2609,12 +3006,10 @@ async fn upload_job(
.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)
// `tx` is dropped now that `put` returned, so the drain thread finishes and
// hands back the final byte count it observed.
let sent = drain.join().unwrap_or(fallback_total);
Ok(if sent > 0 { sent } else { fallback_total })
}
/// Drive all due jobs once: upload each, emit `sync://job` on every transition,
@@ -2639,8 +3034,16 @@ pub(crate) async fn pump_sync(app: &AppHandle, store: &dyn crate::storage::Store
let _ = store.update_sync_job(job.clone()).await;
emit_sync_job(app, &job);
// Emit a live `sync://job` (status still "uploading") as bytes go out, so
// the UI shows a moving per-item progress bar (FR-SYNC-11).
let app_prog = app.clone();
let mut prog_row = job.clone();
let on_progress = move |sent: u64, _total: u64| {
prog_row.bytes_sent = sent as i64;
emit_sync_job(&app_prog, &prog_row);
};
let outcome = match crate::sync::build_sync_target(&target) {
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload).await,
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload, on_progress).await,
Err(e) => Err(e),
};
match outcome {
@@ -3028,6 +3431,13 @@ mod tests {
enabled,
third_party,
host: Some(host.to_string()),
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
}
}
+35
View File
@@ -44,6 +44,10 @@ pub struct AppState {
pub struct RecordingSession {
pub meeting_id: models::MeetingId,
pub capture: audio::CaptureHandle,
/// The user's microphone capture (FR-CAP-7), mixed into the transcript
/// stream. `None` when the mic is disabled in Settings or failed to open —
/// the meeting proceeds on loopback alone either way.
pub mic_capture: Option<audio::CaptureHandle>,
/// Audio retention for this meeting (ADR-0009); toggle-able mid-meeting.
pub retention: bool,
pub wav_path: PathBuf,
@@ -84,6 +88,11 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
// In-memory streaming of recordings for the player (FR-REC-5): decrypts
// on the fly so no plaintext audio is ever written to disk.
.register_uri_scheme_protocol("waaudio", |_ctx, request| {
commands::serve_recording(&request)
})
.manage(AppState {
store,
session: Mutex::new(None),
@@ -123,6 +132,10 @@ pub fn run() {
// Startup recovery + retention + reminder-reconcile pass (FR-REL-1,
// 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).
// Sweep away any plaintext playback temp files left by the previous
// file-based player (T8.8) — playback is now in-memory only.
commands::cleanup_playback_temp();
let store = store_for_setup;
let startup_app = app.handle().clone();
tauri::async_runtime::spawn(async move {
@@ -161,12 +174,30 @@ pub fn run() {
if commands::load_settings().sync_enabled {
commands::pump_sync(&startup_app, store.as_ref()).await;
}
// Re-import the last .pst path if the user opted into auto-sync
// (T6.2). One-shot on startup, same as the sync-job resume above
// — no idle timer (NFR-RES-1). Re-import is dedup'd by
// (source, raw_uid), so this just catches up on new/changed events.
let pst_settings = commands::load_settings();
if pst_settings.pst_auto_sync {
if let Some(path) = pst_settings.pst_last_path {
if let Err(e) =
commands::import_pst_core(&startup_app, store.as_ref(), path, None)
.await
{
tracing::warn!("startup PST auto-sync failed: {e:?}");
}
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::start_recording,
commands::stop_recording,
commands::cancel_recording,
commands::recording_playback_path,
commands::pause_recording,
commands::resume_recording,
commands::set_recording_retention,
@@ -175,6 +206,8 @@ pub fn run() {
commands::app_info,
commands::open_url,
commands::hardware_status,
commands::list_audio_devices,
commands::list_input_devices,
commands::set_preferred_backend,
commands::list_models,
commands::download_npu_package,
@@ -200,12 +233,14 @@ pub fn run() {
commands::set_llm_provider,
commands::generate_summary,
commands::confirm_action_items,
commands::generate_tags,
commands::llm_setup_suggestions,
commands::pull_ollama_model,
commands::import_pst,
commands::list_calendar_events,
commands::get_calendar_event,
commands::attach_meeting_to_event,
commands::rename_meeting,
commands::list_sync_targets,
commands::add_sync_target,
commands::update_sync_target,
+181
View File
@@ -42,6 +42,10 @@ pub type TokenSink = std::sync::mpsc::Sender<String>;
pub trait LlmProvider: Send + Sync {
async fn status(&self) -> LlmStatus;
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError>;
/// Suggests 1-8 short topical tags for a transcript (T8.3, FR-SEARCH-2).
/// Non-streaming — the reply is short enough that a single round trip is
/// simpler than wiring up another token-stream event for it.
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError>;
/// True if the endpoint resolves to loopback/local (FR-LLM-6, FR-SEC-1).
fn is_local(&self) -> bool;
}
@@ -164,6 +168,69 @@ fn bullet_text(line: &str) -> Option<String> {
}
}
// ---- Tag suggestion (T8.3, FR-SEARCH-2) ----
const TAG_INSTRUCTIONS: &str = "You generate short topical tags for a meeting transcript. Respond \
with ONLY a comma-separated list of 1 to 8 short tags, nothing else — no numbering, no \
explanation, no quotes. Each tag: lowercase, 1-3 words, hyphenated instead of spaces (e.g. \
\"budget-review\" not \"budget review\").";
fn build_tag_messages(transcript: &str) -> Vec<serde_json::Value> {
vec![
serde_json::json!({ "role": "system", "content": TAG_INSTRUCTIONS }),
serde_json::json!({ "role": "user", "content": format!("Transcript:\n{transcript}") }),
]
}
/// Strips a leading "1. "/"1) " ordinal a model sometimes adds despite being
/// asked for a plain comma list — only when digits are immediately followed
/// by `.`/`)`, so a legitimate tag like "3d-printing" is left alone.
fn strip_ordinal_prefix(s: &str) -> &str {
let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if digits_end > 0 && matches!(s[digits_end..].chars().next(), Some('.') | Some(')')) {
s[digits_end..].trim_start_matches(['.', ')']).trim_start()
} else {
s
}
}
/// Lowercases, collapses whitespace/underscores to hyphens, and drops any
/// other punctuation — regardless of how well the model followed
/// `TAG_INSTRUCTIONS`, every tag that reaches the UI is chip-safe.
fn sanitize_tag(raw: &str) -> Option<String> {
let cleaned: String = raw
.trim()
.to_ascii_lowercase()
.chars()
.filter_map(|c| match c {
c if c.is_ascii_alphanumeric() || c == '-' => Some(c),
' ' | '_' => Some('-'),
_ => None,
})
.collect();
let cleaned = cleaned.trim_matches('-').to_string();
(!cleaned.is_empty()).then_some(cleaned)
}
/// Parses a model's tag-list reply into at most 8 deduped, sanitized tags.
/// Splits on comma AND newline since a model doesn't always follow the
/// requested comma-separated format.
fn parse_tags(text: &str) -> Vec<String> {
let mut tags = Vec::new();
for raw in text.split([',', '\n']) {
let Some(tag) = sanitize_tag(strip_ordinal_prefix(raw.trim())) else {
continue;
};
if !tags.contains(&tag) {
tags.push(tag);
}
if tags.len() >= 8 {
break;
}
}
tags
}
/// Whether a host is on the user's own machine or private LAN — i.e. not the
/// public internet or a third party. Loopback and RFC-1918 / link-local ranges
/// (e.g. `192.168.0.0/24`, `10.0.0.0/8`) plus `*.local` count as local, so a
@@ -347,6 +414,30 @@ impl LlmProvider for OllamaProvider {
Ok(parse_summary(&full_text))
}
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError> {
let body = serde_json::json!({
"model": self.model,
"messages": build_tag_messages(transcript),
"stream": false,
});
let resp = reqwest::Client::new()
.post(format!("{}/api/chat", self.base()))
.json(&body)
.send()
.await
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
if !resp.status().is_success() {
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
}
let chunk: OllamaChatChunk = resp
.json()
.await
.map_err(|e| LlmError::Request(e.to_string()))?;
Ok(parse_tags(
&chunk.message.map(|m| m.content).unwrap_or_default(),
))
}
fn is_local(&self) -> bool {
is_local_endpoint(&self.endpoint)
}
@@ -490,6 +581,52 @@ impl LlmProvider for OpenAiCompatProvider {
Ok(parse_summary(&full_text))
}
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError> {
// Reuses the same SSE streaming path as summarize() (this API has no
// simpler non-streaming reply shape worth a second response struct
// for) — just accumulates instead of forwarding to a TokenSink.
let body = serde_json::json!({
"model": self.model,
"messages": build_tag_messages(transcript),
"stream": true,
});
let req = self.auth(
reqwest::Client::new()
.post(format!("{}/v1/chat/completions", self.base()))
.json(&body),
);
let resp = req
.send()
.await
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
if !resp.status().is_success() {
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
}
let mut full_text = String::new();
stream_lines(resp, |line| {
let Some(payload) = line.strip_prefix("data:") else {
return true;
};
let payload = payload.trim();
if payload == "[DONE]" {
return false;
}
let Ok(chunk) = serde_json::from_str::<OpenAiChatChunk>(payload) else {
return true;
};
for choice in chunk.choices {
if let Some(content) = choice.delta.content {
full_text.push_str(&content);
}
}
true
})
.await?;
Ok(parse_tags(&full_text))
}
fn is_local(&self) -> bool {
self.credential_ref.is_none() && is_local_endpoint(&self.endpoint)
}
@@ -613,6 +750,11 @@ impl LlmProvider for AnthropicProvider {
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
}
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
Err(LlmError::Request(
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
}
fn is_local(&self) -> bool {
false
}
@@ -648,6 +790,45 @@ mod tests {
assert!(summary.action_items.is_empty());
}
#[test]
fn parse_tags_splits_a_well_formed_comma_list() {
let tags = parse_tags("golang, webrtc, dtls, wasm");
assert_eq!(tags, vec!["golang", "webrtc", "dtls", "wasm"]);
}
#[test]
fn parse_tags_handles_newline_separated_and_numbered_replies() {
// Models don't always follow "comma-separated, nothing else" exactly.
let tags = parse_tags("1. Budget Review\n2) Q3 Planning\n3. hiring");
assert_eq!(tags, vec!["budget-review", "q3-planning", "hiring"]);
}
#[test]
fn parse_tags_dedupes_and_caps_at_eight() {
let text = (1..=10)
.map(|i| format!("tag{i}"))
.collect::<Vec<_>>()
.join(", ")
+ ", tag1";
let tags = parse_tags(&text);
assert_eq!(tags.len(), 8);
assert_eq!(tags[0], "tag1");
}
#[test]
fn parse_tags_does_not_mangle_a_tag_that_starts_with_a_digit() {
// strip_ordinal_prefix must only fire for "<digits>." / "<digits>)",
// not any tag that happens to start with a number.
let tags = parse_tags("3d-printing, web3");
assert_eq!(tags, vec!["3d-printing", "web3"]);
}
#[test]
fn parse_tags_ignores_blank_entries_and_quotes() {
let tags = parse_tags("\"golang\", , \"web-dev\" ,,");
assert_eq!(tags, vec!["golang", "web-dev"]);
}
#[test]
fn is_local_endpoint_accepts_loopback_and_lan_but_rejects_public_hosts() {
// Loopback.
+34
View File
@@ -207,6 +207,31 @@ pub struct Settings {
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
pub retention_max_age_days: Option<u32>,
pub retention_max_size_gb: Option<u32>,
// Calendar / .pst (T6.2) — remembered so the user doesn't re-browse every
// launch. `pst_auto_sync` re-imports this path once at startup if set.
#[serde(default)]
pub pst_last_path: Option<String>,
#[serde(default)]
pub pst_auto_sync: bool,
// Audio capture device override (FR-CAP-1). `Device::get_id()` string;
// None = system default render device (loopback / system audio).
#[serde(default)]
pub audio_output_device: Option<String>,
// Microphone capture (FR-CAP-7): mix the user's own voice into the live
// transcript. Local-only, no egress; default ON. Turn off to transcribe just
// the system/loopback audio, as WA did before.
#[serde(default = "default_true")]
pub microphone_enabled: bool,
// Microphone device override — `Device::get_id()` string; None = system
// default capture device.
#[serde(default)]
pub audio_input_device: Option<String>,
}
/// serde default for a `bool` field that should be `true` when absent from an
/// older `settings.json` (so upgrading users get the microphone, FR-CAP-7).
fn default_true() -> bool {
true
}
// ---- Sync (ADR-0010) ----
@@ -233,6 +258,15 @@ pub struct SyncTargetInfo {
pub enabled: bool,
pub third_party: bool,
pub host: Option<String>,
// Upload selection + options, so the UI can pre-fill an edit form (the secret
// is never included — FR-SYNC-6).
pub upload_transcript: bool,
pub upload_notes: bool,
pub upload_summary: bool,
pub upload_recording: bool,
pub trigger_on_finalize: bool,
pub allow_plaintext_lan: bool,
pub encrypt_before_upload: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+153 -8
View File
@@ -234,12 +234,18 @@ pub trait Store: Send + Sync {
/// pre-meeting context panel and the speaker-naming attendee dropdown.
async fn get_calendar_event(&self, id: &str) -> Result<CalendarEventDetail, StoreError>;
/// Link a meeting (current or historical) to a calendar event (T6.3/T6.6,
/// FR-CAL-2/4). Errs if either id doesn't exist.
/// FR-CAL-2/4). Errs if either id doesn't exist. Also mirrors the event's
/// subject onto the meeting's title when it has one — linking is meant to
/// say "this recording is that meeting," so a recording still sitting at
/// its default "Untitled meeting" name should follow it.
async fn attach_meeting_to_event(
&self,
meeting_id: &MeetingId,
event_id: &str,
) -> Result<(), StoreError>;
/// Manually rename a meeting — recordings otherwise default to "Untitled
/// meeting" with no other way to change that.
async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError>;
/// Names a speaker AND links them to a known `Participant` (T6.5/T6.6,
/// FR-SPK-4): the display name comes from the participant record, and
/// the shared `participant_id` is what gives naming "continuity" across
@@ -931,21 +937,49 @@ impl Store for SqliteStore {
meeting_id: &MeetingId,
event_id: &str,
) -> Result<(), StoreError> {
let exists: Option<String> =
sqlx::query_scalar("SELECT id FROM calendar_events WHERE id = ?")
let row: Option<(String, Option<String>)> =
sqlx::query_as("SELECT id, subject FROM calendar_events WHERE id = ?")
.bind(event_id)
.fetch_optional(&self.pool)
.await?;
if exists.is_none() {
let Some((_, subject)) = row else {
return Err(StoreError::NotFound(format!("calendar event {event_id}")));
}
let result =
sqlx::query("UPDATE meetings SET calendar_event_id = ?, updated_at = ? WHERE id = ?")
};
let result = match subject.filter(|s| !s.is_empty()) {
Some(title) => sqlx::query(
"UPDATE meetings SET calendar_event_id = ?, title = ?, updated_at = ? WHERE id = ?",
)
.bind(event_id)
.bind(title)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?,
None => {
sqlx::query(
"UPDATE meetings SET calendar_event_id = ?, updated_at = ? WHERE id = ?",
)
.bind(event_id)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?;
.await?
}
};
if result.rows_affected() == 0 {
return Err(StoreError::NotFound(meeting_id.clone()));
}
Ok(())
}
async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError> {
let result = sqlx::query("UPDATE meetings SET title = ?, updated_at = ? WHERE id = ?")
.bind(title)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(StoreError::NotFound(meeting_id.clone()));
}
@@ -1525,6 +1559,117 @@ mod tests {
}
}
#[tokio::test]
async fn rename_meeting_updates_the_title() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
})
.await
.unwrap();
store.rename_meeting(&id, "Sprint planning").await.unwrap();
assert_eq!(
store.get_meeting(&id).await.unwrap().title,
"Sprint planning"
);
}
#[tokio::test]
async fn rename_meeting_errs_for_an_unknown_id() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let result = store.rename_meeting(&"no-such-id".to_string(), "x").await;
assert!(matches!(result, Err(StoreError::NotFound(_))));
}
#[tokio::test]
async fn attach_meeting_to_event_mirrors_the_events_subject_onto_the_title() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let meeting_id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
})
.await
.unwrap();
store
.import_calendar_events(vec![ImportedEvent {
event: CalendarEvent {
id: "ev1".to_string(),
source: "pst".to_string(),
subject: Some("Jerry / Daniel - Weekly 1:1".to_string()),
organizer: None,
starts_at: None,
ends_at: None,
description: None,
raw_uid: Some("uid-1".to_string()),
},
attendees: vec![],
}])
.await
.unwrap();
let event_id = store.list_calendar_events(None, None).await.unwrap()[0]
.id
.clone();
store
.attach_meeting_to_event(&meeting_id, &event_id)
.await
.unwrap();
let meeting = store.get_meeting(&meeting_id).await.unwrap();
assert_eq!(meeting.title, "Jerry / Daniel - Weekly 1:1");
assert_eq!(
meeting.calendar_event_id.as_deref(),
Some(event_id.as_str())
);
}
#[tokio::test]
async fn attach_meeting_to_event_leaves_the_title_alone_when_the_event_has_no_subject() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let meeting_id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
})
.await
.unwrap();
store
.import_calendar_events(vec![ImportedEvent {
event: CalendarEvent {
id: "ev1".to_string(),
source: "pst".to_string(),
subject: None,
organizer: None,
starts_at: None,
ends_at: None,
description: None,
raw_uid: Some("uid-2".to_string()),
},
attendees: vec![],
}])
.await
.unwrap();
let event_id = store.list_calendar_events(None, None).await.unwrap()[0]
.id
.clone();
store
.attach_meeting_to_event(&meeting_id, &event_id)
.await
.unwrap();
assert_eq!(
store.get_meeting(&meeting_id).await.unwrap().title,
"Untitled meeting"
);
}
#[tokio::test]
async fn sync_target_crud_round_trips() {
let store = SqliteStore::connect_in_memory().await.unwrap();
+97 -13
View File
@@ -75,7 +75,11 @@ pub trait SyncManager: Send + Sync {
/// WebDAV provider — primary targets + Synology (feature `sync`).
#[cfg(feature = "sync")]
pub struct WebDavTarget {
pub base_url: String, // https://host/remote.php/dav/files/<user>/ , /seafdav , /dav , …
/// For Nextcloud/ownCloud this is just the server URL (e.g. `https://host`);
/// the canonical `/remote.php/dav/files/<user>/` path is derived. For other
/// providers it's the full DAV URL entered by the user (`…/seafdav`, `…/dav`).
pub base_url: String,
pub provider_hint: Option<String>,
pub remote_base_path: String,
pub username: String,
pub credential_ref: String, // key into the OS credential store — resolved at use, not stored here
@@ -90,6 +94,7 @@ impl WebDavTarget {
pub fn from_row(row: &crate::storage::SyncTargetRow) -> Self {
Self {
base_url: row.base_url.clone().unwrap_or_default(),
provider_hint: row.provider_hint.clone(),
remote_base_path: row.remote_base_path.clone(),
username: row.username.clone().unwrap_or_default(),
credential_ref: row.credential_ref.clone(),
@@ -98,11 +103,38 @@ impl WebDavTarget {
}
}
/// The effective WebDAV root the paths hang off. For Nextcloud/ownCloud the
/// user supplies only their server URL and we build the canonical
/// `…/remote.php/dav/files/<user>/` path (deriving the origin, so a pasted
/// full DAV URL is normalized too). Other providers use the URL verbatim.
fn dav_root(&self) -> String {
match self.provider_hint.as_deref() {
Some("nextcloud") | Some("owncloud") => {
let origin = reqwest::Url::parse(&self.base_url)
.ok()
.map(|u| u.origin().ascii_serialization())
.unwrap_or_else(|| self.base_url.trim_end_matches('/').to_string());
// ponytail: raw username in the path — fine for typical Nextcloud
// usernames; add percent-encoding if one ever contains `/` or spaces.
format!(
"{origin}/remote.php/dav/files/{}",
self.username.trim_matches('/')
)
}
_ => self.base_url.trim_end_matches('/').to_string(),
}
}
/// Full URL for a path under this target, resolving `//` at the join.
fn url_for(&self, remote_path: &str) -> String {
let base = self.base_url.trim_end_matches('/');
let base = self.dav_root();
let base = base.trim_end_matches('/');
let path = remote_path.trim_start_matches('/');
format!("{base}/{path}")
if path.is_empty() {
base.to_string()
} else {
format!("{base}/{path}")
}
}
/// Resolves the secret and returns an authenticated request builder, after
@@ -209,21 +241,42 @@ impl SyncTarget for WebDavTarget {
let url = self.url_for(remote_path);
let resp = self
.request(reqwest::Method::PUT, &url)?
.body(bytes)
.header(reqwest::header::CONTENT_LENGTH, total)
.body(progress_body(bytes, prog))
.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(())
}
code if (200..300).contains(&code) => Ok(()),
401 | 403 => Err(SyncError::Auth),
other => Err(SyncError::Upload(format!("PUT {other}"))),
}
}
}
/// Wrap in-memory bytes as a reqwest streaming body that reports cumulative
/// bytes-sent to `prog` as the request drains it — the basis for live per-item
/// upload progress (FR-SYNC-11). Chunked so the UI sees steady movement on a
/// large recording instead of a single 0→100% jump at the end.
#[cfg(feature = "sync")]
fn progress_body(bytes: Vec<u8>, prog: ProgressSink) -> reqwest::Body {
const CHUNK: usize = 64 * 1024;
let total = bytes.len() as u64;
let stream = futures_util::stream::unfold((bytes, 0usize), move |(bytes, pos)| {
let prog = prog.clone();
async move {
if pos >= bytes.len() {
return None;
}
let end = (pos + CHUNK).min(bytes.len());
let chunk = bytes[pos..end].to_vec();
let _ = prog.send((end as u64, total));
Some((Ok::<Vec<u8>, std::io::Error>(chunk), (bytes, end)))
}
});
reqwest::Body::wrap_stream(stream)
}
/// Shared HTTP client (rustls TLS via reqwest's `rustls-tls` feature).
#[cfg(feature = "sync")]
fn http_client() -> Result<reqwest::Client, SyncError> {
@@ -281,6 +334,13 @@ pub fn row_to_info(row: &crate::storage::SyncTargetRow) -> SyncTargetInfo {
enabled: row.enabled,
third_party: row.kind != "webdav",
host,
upload_transcript: row.upload_transcript,
upload_notes: row.upload_notes,
upload_summary: row.upload_summary,
upload_recording: row.upload_recording,
trigger_on_finalize: row.trigger_on_finalize,
allow_plaintext_lan: row.allow_plaintext_lan,
encrypt_before_upload: row.encrypt_before_upload,
}
}
@@ -373,6 +433,7 @@ mod tests {
fn url_join_normalizes_slashes() {
let t = WebDavTarget {
base_url: "https://host/dav/".to_string(),
provider_hint: None,
remote_base_path: "/WhispAssist".to_string(),
username: "u".to_string(),
credential_ref: "r".to_string(),
@@ -385,6 +446,30 @@ mod tests {
);
}
#[test]
fn nextcloud_builds_dav_path_from_server_url() {
let mk = |base: &str| WebDavTarget {
base_url: base.to_string(),
provider_hint: Some("nextcloud".to_string()),
remote_base_path: "/WhispAssist".to_string(),
username: "dwdoubet".to_string(),
credential_ref: "r".to_string(),
third_party: false,
allow_plaintext_lan: false,
};
// Bare server URL → canonical DAV path built from username.
assert_eq!(
mk("https://box.dou.bet").url_for("/WhispAssist"),
"https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist"
);
// A pasted full DAV URL normalizes to the same thing (origin only).
assert_eq!(
mk("https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist")
.url_for("/WhispAssist"),
"https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist"
);
}
/// Full WebDAV round-trip against a real server, opt-in (needs one running):
/// WA_WEBDAV_URL, WA_WEBDAV_USER, WA_WEBDAV_PASS
/// e.g. `wsgidav --host=127.0.0.1 --port=8899 --root=<dir> --auth=anonymous`
@@ -407,6 +492,7 @@ mod tests {
let target = WebDavTarget {
base_url,
provider_hint: None,
remote_base_path: "/WhispAssist-test".to_string(),
username: user,
credential_ref: cred_ref.to_string(),
@@ -602,15 +688,13 @@ impl SyncTarget for OneDriveTarget {
let resp = http_client()?
.put(Self::item_url(remote_path, ":/content"))
.bearer_auth(token)
.body(bytes)
.header(reqwest::header::CONTENT_LENGTH, total)
.body(progress_body(bytes, prog))
.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(())
}
code if (200..300).contains(&code) => Ok(()),
401 | 403 => Err(SyncError::Auth),
other => Err(SyncError::Upload(format!("PUT {other}"))),
}
+6
View File
@@ -216,6 +216,12 @@ pub fn seal(plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
}
}
/// True if `data` begins with the vault's sealed-file magic (i.e. it's
/// ciphertext at rest, not a plaintext/pre-vault file).
pub fn is_sealed(data: &[u8]) -> bool {
data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
}
/// 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> {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "WhispAssist",
"version": "0.1.5",
"version": "0.2.0",
"identifier": "bet.dou.whispassist",
"build": {
"frontendDist": "../dist",
@@ -21,7 +21,7 @@
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; media-src 'self' http://waaudio.localhost; style-src 'self' 'unsafe-inline'"
},
"trayIcon": {
"iconPath": "icons/tray.png",
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "gen/schemas/desktop-schema.json",
"bundle": {
"resources": ["vulkan-1.dll"]
}
}
+34 -1
View File
@@ -14,7 +14,7 @@
import { api, type NoteTemplate } from "./lib/api";
import { onMount } from "svelte";
import ThemeToggle from "./lib/components/ThemeToggle.svelte";
import { Circle, Square, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
import { Circle, Square, Trash2, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
let showSettings = $state(false);
let showConsent = $state(false);
@@ -99,6 +99,12 @@
else recording.stop();
}
async function cancelRecording() {
if (!confirm("Discard this recording? Its audio and transcript will be deleted.")) return;
await recording.cancel();
meetings.deselect();
}
// Global shortcuts (T7.4, FR-UX-3): record start/stop, view toggles.
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
@@ -199,6 +205,14 @@
<Square size={11} fill="currentColor" aria-hidden="true" />
Stop
</button>
<button
class="cancel-btn"
onclick={cancelRecording}
title="Discard this recording and delete it"
>
<Trash2 size={12} aria-hidden="true" />
Cancel
</button>
<span class="rec">
<span class="rec-dot" aria-hidden="true"></span>
Recording…
@@ -461,6 +475,25 @@
.stop-btn:hover {
background: var(--border);
}
.cancel-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: var(--radius-full);
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
font-size: 0.85rem;
cursor: pointer;
transition:
color 150ms ease-out,
border-color 150ms ease-out;
}
.cancel-btn:hover {
color: var(--danger);
border-color: var(--danger);
}
.rec {
display: inline-flex;
align-items: center;
+30
View File
@@ -48,6 +48,14 @@ export interface HardwareStatus {
directml?: { applicable: boolean; runtimeReady: boolean; modelInstalled: boolean };
}
// One enumerated audio device — a render (playback) device for the loopback
// picker (FR-CAP-1) or a capture (microphone) device for the mic picker
// (FR-CAP-7). `id` is the persisted `Device::get_id()`; `name` is display-only.
export interface AudioDeviceInfo {
id: string;
name: string;
}
export interface LlmStatus {
provider: string; // ollama|custom|off (Phase 10a adds anthropic|openai)
reachable: boolean;
@@ -202,6 +210,13 @@ export interface SyncTargetInfo {
enabled: boolean;
third_party: boolean;
host: string | null;
upload_transcript: boolean;
upload_notes: boolean;
upload_summary: boolean;
upload_recording: boolean;
trigger_on_finalize: boolean;
allow_plaintext_lan: boolean;
encrypt_before_upload: boolean;
}
// privacy_self_check() response (FR-SEC-2) — proves local-only handling.
@@ -276,6 +291,11 @@ export interface AppSettings {
mcp_enabled: boolean;
retention_max_age_days: number | null;
retention_max_size_gb: number | null;
pst_last_path: string | null;
pst_auto_sync: boolean;
audio_output_device: string | null;
microphone_enabled: boolean;
audio_input_device: string | null;
}
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
@@ -319,6 +339,11 @@ export const api = {
}),
listNoteTemplates: () => invoke<NoteTemplate[]>("list_note_templates"),
stopRecording: (meetingId: MeetingId) => invoke<void>("stop_recording", { meetingId }),
// Abandon an accidental recording: stop + delete files + drop the DB row.
cancelRecording: (meetingId: MeetingId) => invoke<void>("cancel_recording", { meetingId }),
// Absolute path to a playable audio.wav (decrypted if sealed), for convertFileSrc.
recordingPlaybackPath: (meetingId: MeetingId) =>
invoke<string>("recording_playback_path", { meetingId }),
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
@@ -328,6 +353,8 @@ export const api = {
appInfo: () => invoke<AppInfo>("app_info"),
openUrl: (url: string) => invoke<void>("open_url", { url }),
hardwareStatus: () => invoke<HardwareStatus>("hardware_status"),
listAudioDevices: () => invoke<AudioDeviceInfo[]>("list_audio_devices"),
listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"),
setPreferredBackend: (backend: BackendId | "auto") =>
invoke<void>("set_preferred_backend", { args: { backend } }),
downloadNpuPackage: () => invoke<void>("download_npu_package"),
@@ -387,6 +414,7 @@ export const api = {
invoke<void>("generate_summary", { meetingId, templateId }),
confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) =>
invoke<void>("confirm_action_items", { meetingId, items }),
generateTags: (meetingId: MeetingId) => invoke<string[]>("generate_tags", { meetingId }),
importPst: (path: string, password?: string) => invoke<number>("import_pst", { path, password }),
listCalendarEvents: (from?: number, to?: number) =>
@@ -395,6 +423,8 @@ export const api = {
invoke<CalendarEventDetail>("get_calendar_event", { eventId }),
attachMeetingToEvent: (meetingId: MeetingId, eventId: string) =>
invoke<void>("attach_meeting_to_event", { meetingId, eventId }),
renameMeeting: (meetingId: MeetingId, title: string) =>
invoke<void>("rename_meeting", { meetingId, title }),
renameSpeaker: (meetingId: MeetingId, label: string, name: string) =>
invoke<void>("rename_speaker", { meetingId, label, name }),
mapSpeakerToParticipant: (meetingId: MeetingId, label: string, participantId: string) =>
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
// GitHub-topic-style pill (T8.3, FR-SEARCH-2). Clicking the label filters
// the meeting list to this tag; the optional 'x' removes it from whatever
// list it's rendered in (not a global delete — the caller decides what
// "remove" means).
import { X } from "@lucide/svelte";
interface Props {
tag: string;
removable?: boolean;
onRemove?: () => void;
onClick?: () => void;
}
let { tag, removable = false, onRemove, onClick }: Props = $props();
</script>
<span class="chip" class:clickable={!!onClick}>
<button
type="button"
class="label"
onclick={onClick}
disabled={!onClick}
title={onClick ? `Filter meetings tagged "${tag}"` : undefined}
>
{tag}
</button>
{#if removable}
<button type="button" class="remove" onclick={onRemove} aria-label={`Remove tag ${tag}`}>
<X size={10} aria-hidden="true" />
</button>
{/if}
</span>
<style>
.chip {
display: inline-flex;
align-items: center;
background: var(--accent-soft);
color: var(--accent);
border-radius: var(--radius-full);
font-size: 0.78rem;
font-weight: 500;
}
.label {
background: none;
border: none;
color: inherit;
font: inherit;
padding: 0.15rem 0.65rem;
border-radius: var(--radius-full);
cursor: default;
}
.label:disabled {
opacity: 1; /* a non-clickable chip should still read as fully legible */
}
.chip.clickable .label {
cursor: pointer;
}
.chip.clickable .label:hover {
background: var(--accent);
color: var(--accent-fg);
}
.label:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.remove {
display: grid;
place-items: center;
width: 1.05rem;
height: 1.05rem;
margin: 0 0.3rem 0 -0.25rem;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
opacity: 0.65;
cursor: pointer;
transition:
background-color 150ms ease-out,
opacity 150ms ease-out;
}
.remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.18);
}
.remove:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
// Imported calendar events (Phase 6, FR-CAL-*). Svelte 5 runes store, same
// shape as settings.svelte.ts/meetings.svelte.ts.
import { api, events, type CalendarEvent } from "../api";
import { api, errorMessage, events, type CalendarEvent } from "../api";
class CalendarStore {
events = $state<CalendarEvent[]>([]);
@@ -30,7 +30,7 @@ class CalendarStore {
await api.importPst(path, password);
await this.load();
} catch (e) {
this.importError = e instanceof Error ? e.message : String(e);
this.importError = errorMessage(e);
} finally {
this.importing = false;
this.importProgress = null;
+19
View File
@@ -169,6 +169,15 @@ class MeetingsStore {
await this.load();
}
/** Click-to-filter from a tag chip anywhere (T8.3, FR-SEARCH-2) — same
* mechanism as the sidebar's tag dropdown, just triggered elsewhere.
* Keeps any existing date filter, drops search mode (a tag filter and a
* text search are two different views over the same list). */
async filterByTag(tag: string) {
this.searchResults = null;
await this.load({ ...this.filter, tag });
}
/** `null` clears search mode and reverts the list view to `load()`'s results. */
async search(query: string | null) {
if (!query || !query.trim()) {
@@ -224,7 +233,17 @@ class MeetingsStore {
/** Link a recording to a calendar event (T6.3/T6.6, FR-CAL-2/4). */
async attachEvent(id: MeetingId, eventId: string) {
await api.attachMeetingToEvent(id, eventId);
// Attaching mirrors the event's subject onto the title server-side
// (FR-CAL-2) — refresh the list too, not just the detail view.
if (this.selectedId === id) await this.select(id);
await this.load();
}
/** Manual rename (T2.2) — recordings otherwise default to "Untitled meeting". */
async renameMeeting(id: MeetingId, title: string) {
await api.renameMeeting(id, title);
if (this.selectedId === id) await this.select(id);
await this.load();
}
/** Free-text speaker rename (T4.4, FR-SPK-2) — the "add new name" escape
+18 -3
View File
@@ -18,10 +18,14 @@ class RecordingStore {
async init() {
await events.onRecordingState((p) => {
const e = p as { state: "recording" | "paused" | "stopped"; elapsedMs: number };
this.state = e.state === "stopped" ? "idle" : e.state;
const e = p as {
state: "recording" | "paused" | "stopped" | "cancelled";
elapsedMs: number;
};
const ended = e.state === "stopped" || e.state === "cancelled";
this.state = ended ? "idle" : (e.state as "recording" | "paused");
this.elapsedMs = e.elapsedMs ?? this.elapsedMs;
if (e.state === "stopped") {
if (ended) {
this.levelRms = 0;
this.levelPeak = 0;
}
@@ -60,6 +64,17 @@ class RecordingStore {
this.deviceNotice = null;
}
/** Abandon an accidental recording: stop capture and delete it entirely. */
async cancel() {
if (this.meetingId) await api.cancelRecording(this.meetingId);
this.meetingId = null;
this.segments = [];
this.state = "idle";
this.levelRms = 0;
this.levelPeak = 0;
this.deviceNotice = null;
}
/** Toggle audio retention mid-meeting (FR-REC-1); caller must have gated consent already. */
async setRetention(record: boolean) {
if (!this.meetingId) return;
+56
View File
@@ -8,6 +8,7 @@ import {
errorMessage,
events,
type AppSettings,
type AudioDeviceInfo,
type SyncTargetInfo,
type SyncTargetConfig,
type HardwareStatus,
@@ -31,6 +32,11 @@ const DEFAULT_SETTINGS: AppSettings = {
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
retention_max_age_days: null, // no cap by default (FR-STORE-2)
retention_max_size_gb: null,
pst_last_path: null,
pst_auto_sync: false,
audio_output_device: null, // system default render device (FR-CAP-1)
microphone_enabled: true, // capture the user's mic into the transcript (FR-CAP-7)
audio_input_device: null, // system default capture device
};
class SettingsStore {
@@ -45,6 +51,8 @@ class SettingsStore {
// Hardware + model management (Phase 3, T3.6/T3.7).
hardware = $state<HardwareStatus | null>(null);
models = $state<ModelInfo[]>([]);
audioDevices = $state<AudioDeviceInfo[]>([]);
inputDevices = $state<AudioDeviceInfo[]>([]);
downloadProgress = $state<Record<string, { received: number; total: number | null }>>({});
// Privacy self-check (T7.6, FR-SEC-2).
@@ -69,6 +77,8 @@ class SettingsStore {
this.backendStub = true;
}
await this.loadHardware();
await this.loadAudioDevices();
await this.loadInputDevices();
await this.loadModels();
await this.loadPrivacy();
await this.loadLlmStatus();
@@ -123,6 +133,32 @@ class SettingsStore {
}
}
async loadAudioDevices() {
try {
this.audioDevices = await api.listAudioDevices();
} catch {
this.audioDevices = [];
}
}
async setAudioOutputDevice(deviceId: string | null) {
await this.patch({ audio_output_device: deviceId });
}
async loadInputDevices() {
try {
this.inputDevices = await api.listInputDevices();
} catch {
this.inputDevices = [];
}
}
/** Set the microphone selection in one patch (FR-CAP-7): `enabled=false`
* disables mic capture entirely; `deviceId=null` uses the system default. */
async setMicrophone(enabled: boolean, deviceId: string | null) {
await this.patch({ microphone_enabled: enabled, audio_input_device: deviceId });
}
async loadModels() {
try {
this.models = await api.listModels();
@@ -248,6 +284,19 @@ class SettingsStore {
await this.loadPrivacy();
}
async updateTarget(config: SyncTargetConfig & { id: string }) {
try {
const updated = await api.updateSyncTarget(config);
this.targets = this.targets.map((t) => (t.id === config.id ? updated : t));
} catch {
this.backendStub = true;
this.targets = this.targets.map((t) =>
t.id === config.id ? { ...t, ...stubTarget(config), id: config.id } : t,
);
}
await this.loadPrivacy();
}
async removeTarget(id: string) {
this.targets = this.targets.filter((t) => t.id !== id);
try {
@@ -297,6 +346,13 @@ function stubTarget(c: SyncTargetConfig): SyncTargetInfo {
enabled: c.enabled ?? false,
third_party: c.kind !== "webdav",
host,
upload_transcript: c.upload_transcript ?? true,
upload_notes: c.upload_notes ?? true,
upload_summary: c.upload_summary ?? true,
upload_recording: c.upload_recording ?? false,
trigger_on_finalize: c.trigger_on_finalize ?? true,
allow_plaintext_lan: c.allow_plaintext_lan ?? false,
encrypt_before_upload: c.encrypt_before_upload ?? false,
};
}
+4 -1
View File
@@ -17,7 +17,10 @@
// Tag/date filters (T8.3, FR-SEARCH-2) apply to the plain list, not
// full-text search — changing one drops out of search mode so the
// filtered list is immediately visible rather than hidden behind results.
let tagFilter = $state("");
// Writable derived (not $state+$effect) so this dropdown also reflects a
// tag filter triggered elsewhere (e.g. clicking a chip in the Tags panel),
// while still being directly editable via bind:value below.
let tagFilter = $derived(meetings.filter.tag ?? "");
let fromFilter = $state("");
let toFilter = $state("");
+182 -20
View File
@@ -8,7 +8,7 @@
import ConsentNotice from "../components/ConsentNotice.svelte";
import { open } from "@tauri-apps/plugin-dialog";
import { api, errorMessage, events } from "../api";
import type { BackendId, SyncKind, SyncTargetConfig } from "../api";
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api";
import { trapFocus } from "../actions/trapFocus";
import {
X,
@@ -173,6 +173,34 @@
// ---- Calendar / .pst import (T6.1/T6.2/T6.3, FR-CAL-1/2) ----
let pstPath = $state("");
let pstPassword = $state("");
let eventTitleFilter = $state("");
let eventDateFilter = $state("");
function eventLocalYmd(unixSecs: number | null): string {
if (!unixSecs) return "";
const d = new Date(unixSecs * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let filteredEvents = $derived(
calendar.events.filter((ev) => {
if (eventDateFilter && eventLocalYmd(ev.starts_at) !== eventDateFilter) return false;
if (
eventTitleFilter &&
!(ev.subject ?? "").toLowerCase().includes(eventTitleFilter.toLowerCase())
)
return false;
return true;
}),
);
// Prefills the remembered path once settings load, without clobbering
// whatever the user is actively typing/browsing to.
$effect(() => {
if (!pstPath && settings.settings.pst_last_path) {
pstPath = settings.settings.pst_last_path;
}
});
async function pickPstFile() {
const path = await open({ filters: [{ name: "Outlook data file", extensions: ["pst"] }] });
@@ -182,6 +210,10 @@
if (!pstPath) return;
await calendar.importPst(pstPath, pstPassword || undefined);
pstPassword = "";
if (!calendar.importError) await settings.patch({ pst_last_path: pstPath });
}
function onToggleAutoSync(e: Event) {
settings.patch({ pst_auto_sync: (e.target as HTMLInputElement).checked });
}
function formatEventDate(unixSecs: number | null): string {
if (!unixSecs) return "";
@@ -343,6 +375,12 @@
// ---- Add-target form ----
const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"];
let form = $state<SyncTargetConfig>(blankForm("webdav"));
// When set, the form is editing an existing target rather than adding one.
let editingId = $state<string | null>(null);
// Nextcloud/ownCloud only need the server URL — the app builds the DAV path.
let davAutoPath = $derived(
form.provider_hint === "nextcloud" || form.provider_hint === "owncloud",
);
function blankForm(kind: SyncKind): SyncTargetConfig {
return {
@@ -368,14 +406,51 @@
testResult = null;
}
async function testConnection() {
const r = await settings.test(form);
// In edit mode, pass the id so the backend reuses the stored password
// (unless a new one was typed) while still testing the edited URL/fields.
const r = await settings.test(editingId ? { ...form, id: editingId } : form);
testResult = { ok: r.ok, message: r.message };
}
async function addTarget() {
await settings.addTarget(form);
form = blankForm(form.kind);
// Load an existing target into the form for editing. The secret is never
// returned to the UI (FR-SYNC-6), so it stays blank — left blank on save it
// keeps the stored password; typed, it rotates. No `id` on the form itself, so
// "Test connection" checks the typed values (needs the password re-entered).
function startEdit(t: SyncTargetInfo) {
editingId = t.id;
form = {
name: t.name,
kind: t.kind,
provider_hint: t.provider_hint ?? undefined,
base_url: t.base_url ?? "",
remote_base_path: t.remote_base_path,
username: t.username ?? "",
secret: "",
enabled: t.enabled,
upload_transcript: t.upload_transcript,
upload_notes: t.upload_notes,
upload_summary: t.upload_summary,
upload_recording: t.upload_recording,
trigger_on_finalize: t.trigger_on_finalize,
allow_plaintext_lan: t.allow_plaintext_lan,
encrypt_before_upload: t.encrypt_before_upload,
};
testResult = null;
}
function cancelEdit() {
editingId = null;
form = blankForm("webdav");
testResult = null;
}
async function saveTarget() {
if (editingId) {
await settings.updateTarget({ ...form, id: editingId });
cancelEdit();
} else {
await settings.addTarget(form);
form = blankForm(form.kind);
testResult = null;
}
}
function linkOAuth() {
// begin_oauth_link → loopback PKCE flow; the target is created in the
// background on success (sync://linked), which the store handles.
@@ -488,6 +563,50 @@
to load.
</p>
<h4>Audio Devices</h4>
<label
>Recording device
<select
value={settings.settings.audio_output_device ?? ""}
onchange={(e) =>
settings.setAudioOutputDevice((e.target as HTMLSelectElement).value || null)}
>
<option value="">Default system audio</option>
{#each settings.audioDevices as d (d.id)}
<option value={d.id}>{d.name}</option>
{/each}
</select>
</label>
<p class="muted">
WhispAssist records whatever this device plays (loopback) — the other side of the call.
Pick a specific output if you don't want it following Windows' system default.
</p>
<label
>Microphone
<select
value={!settings.settings.microphone_enabled
? "off"
: (settings.settings.audio_input_device ?? "")}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
if (v === "off") settings.setMicrophone(false, null);
else settings.setMicrophone(true, v || null);
}}
>
<option value="off">Off — don't capture my microphone</option>
<option value="">Default microphone</option>
{#each settings.inputDevices as d (d.id)}
<option value={d.id}>{d.name}</option>
{/each}
</select>
</label>
<p class="muted">
Adds your own voice to the live transcript so both sides of the meeting are captured.
Stays on your device — nothing is uploaded. Choose “Off” to transcribe only the system
audio above.
</p>
{#if settings.hardware.npu?.present}
{@const npu = settings.hardware.npu}
{@const ready = npu.runtimeReady && npu.modelInstalled}
@@ -654,6 +773,18 @@
>
{/if}
</div>
<label class="row">
<input
type="checkbox"
checked={settings.settings.pst_auto_sync}
onchange={onToggleAutoSync}
/>
<span>Re-import this file automatically on launch</span>
</label>
<p class="muted small">
Runs once at startup, not on a timer — re-import is safe to repeat (existing events are
matched and updated, not duplicated).
</p>
{#if calendar.importError}
<p class="error">
Import failed: {calendar.importError} — the file itself is untouched; check the path and try
@@ -665,8 +796,21 @@
{#if calendar.events.length === 0}
<p class="muted">No events imported yet.</p>
{:else}
<div class="grid">
<label class="wide"
>Search title
<input type="text" bind:value={eventTitleFilter} placeholder="Meeting name…" />
</label>
<label
>Date
<input type="date" bind:value={eventDateFilter} />
</label>
</div>
<p class="muted">
{filteredEvents.length} of {calendar.events.length} events
</p>
<ul class="events">
{#each calendar.events as ev (ev.id)}
{#each filteredEvents as ev (ev.id)}
<li>
<span class="name">{ev.subject ?? "(untitled)"}</span>
<span class="muted">{formatEventDate(ev.starts_at)}</span>
@@ -711,6 +855,9 @@
>{t.third_party ? "third-party" : "your server"}</span
>
<span class="host">{t.host ?? t.kind}</span>
{#if !t.third_party}
<button class="link" onclick={() => startEdit(t)}>Edit</button>
{/if}
<button class="link danger" onclick={() => settings.removeTarget(t.id)}
>Remove</button
>
@@ -719,14 +866,16 @@
</ul>
{/if}
<h4>Add a target</h4>
<div class="kinds">
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
>{k}</button
>
{/each}
</div>
<h4>{editingId ? "Edit target" : "Add a target"}</h4>
{#if !editingId}
<div class="kinds">
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
>{k}</button
>
{/each}
</div>
{/if}
{#if form.kind === "webdav"}
<p class="muted">
@@ -744,16 +893,26 @@
<label class="wide"
>Server URL<input
bind:value={form.base_url}
placeholder="https://cloud.example.com/remote.php/dav/files/me/"
/></label
>
placeholder={davAutoPath
? "https://cloud.example.com"
: "https://host/remote.php/dav/… (or /seafdav, /dav)"}
/>
{#if davAutoPath}
<small class="muted"
>Just your server URL — WhispAssist adds
<code>/remote.php/dav/files/&lt;username&gt;/</code> automatically.</small
>
{/if}
</label>
<label>Remote folder<input bind:value={form.remote_base_path} /></label>
<label>Username<input bind:value={form.username} /></label>
<label
>App password<input
type="password"
bind:value={form.secret}
placeholder="stored in OS credential store"
placeholder={editingId
? "leave blank to keep current password"
: "stored in OS credential store"}
/></label
>
</div>
@@ -782,9 +941,12 @@
</label>
<div class="actions">
<button onclick={testConnection}>Test connection</button>
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}
>Add target</button
<button class="primary" onclick={saveTarget} disabled={!form.name || !form.base_url}
>{editingId ? "Save changes" : "Add target"}</button
>
{#if editingId}
<button class="link" onclick={cancelEdit}>Cancel</button>
{/if}
{#if testResult}
<span class="test" class:ok={testResult.ok} class:fail={!testResult.ok}>
{#if testResult.ok}<Check size={14} aria-hidden="true" />{:else}<X
+224 -20
View File
@@ -4,8 +4,15 @@
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 {
api,
errorMessage,
type ActionItem,
type CalendarEventDetail,
type LlmStatus,
} from "../api";
import { renderMarkdown } from "../markdown";
import TagChip from "../components/TagChip.svelte";
import {
Tags,
Sparkles,
@@ -19,6 +26,26 @@
onMount(() => calendar.load());
// ---- Recording playback (FR-REC-5) ----
let audioSrc = $state<string | null>(null);
let audioError = $state<string | null>(null);
$effect(() => {
const m = meetings.selected;
audioSrc = null;
audioError = null;
if (m?.recorded) {
const id = m.id;
api
.recordingPlaybackPath(id)
.then((url) => {
if (meetings.selected?.id === id) audioSrc = url;
})
.catch((e) => {
if (meetings.selected?.id === id) audioError = errorMessage(e);
});
}
});
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
let llmStatus = $state<LlmStatus | null>(null);
onMount(async () => {
@@ -94,6 +121,35 @@
await meetings.attachEvent(m.id, eventId);
}
// ---- Search/date filter for the event picker (a real mailbox import can
// be thousands of events) — defaults to the recording's own date since
// that's almost always the event being linked. ----
let eventLinkSearch = $state("");
let eventLinkDateFilter = $state("");
$effect(() => {
const m = meetings.selected;
eventLinkDateFilter = m ? eventLocalYmd(m.started_at) : "";
eventLinkSearch = "";
});
function eventLocalYmd(unixSecs: number | null): string {
if (!unixSecs) return "";
const d = new Date(unixSecs * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let filteredLinkEvents = $derived(
calendar.events.filter((ev) => {
if (eventLinkDateFilter && eventLocalYmd(ev.starts_at) !== eventLinkDateFilter) return false;
if (
eventLinkSearch &&
!(ev.subject ?? "").toLowerCase().includes(eventLinkSearch.toLowerCase())
)
return false;
return true;
}),
);
// ---- Attendee-aware speaker naming (T6.5, FR-SPK-4) ----
const NEW_NAME = "__new__";
let addingNameFor = $state<string | null>(null);
@@ -121,27 +177,102 @@
}
// ---- Tags (T8.3, FR-SEARCH-2) ----
// Writable derived: reflects meetings.selected.tags, but typing (bind:value)
// locally overrides it until the selection changes again.
let tagsInput = $derived((meetings.selected?.tags ?? []).join(", "));
// pendingTags is the editable working copy — resynced from
// meetings.selected.tags whenever a *different* meeting is selected, same
// guard pattern as notesText in TranscriptNotes.svelte, so it isn't
// clobbered by other reactivity while the user is mid-edit.
let pendingTags = $state<string[]>([]);
let tagDraft = $state("");
let loadedTagsForId: string | null = null;
$effect(() => {
const m = meetings.selected;
if (m && m.id !== loadedTagsForId) {
pendingTags = [...m.tags];
tagDraft = "";
loadedTagsForId = m.id;
} else if (!m) {
loadedTagsForId = null;
}
});
function addTag(raw: string) {
const t = raw.trim().toLowerCase();
if (t && !pendingTags.includes(t)) pendingTags.push(t);
}
// GitHub-topics-style input: a comma commits everything before it as its
// own chip immediately, leaving whatever's after as the live draft.
function onTagInput() {
if (!tagDraft.includes(",")) return;
const parts = tagDraft.split(",");
tagDraft = parts.pop() ?? "";
parts.forEach(addTag);
}
function onTagInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
commitDraft();
}
}
function commitDraft() {
if (tagDraft.trim()) addTag(tagDraft);
tagDraft = "";
}
function removeTag(t: string) {
pendingTags = pendingTags.filter((x) => x !== t);
}
let savingTags = $state(false);
async function saveTags() {
const m = meetings.selected;
if (!m) return;
commitDraft();
savingTags = true;
try {
const tags = tagsInput
.split(",")
.map((t) => t.trim())
.filter(Boolean);
await meetings.setTags(m.id, tags);
await meetings.setTags(m.id, pendingTags);
} finally {
savingTags = false;
}
}
let generatingTags = $state(false);
let tagGenError = $state<string | null>(null);
async function generateTagsNow() {
const m = meetings.selected;
if (!m) return;
generatingTags = true;
tagGenError = null;
try {
const suggested = await api.generateTags(m.id);
suggested.forEach(addTag);
} catch (e) {
tagGenError = errorMessage(e);
} finally {
generatingTags = false;
}
}
</script>
<div class="wrap">
{#if meetings.selected?.recorded}
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
{#if audioSrc}
<!-- svelte-ignore a11y_media_has_caption -- a meeting recording has no caption track -->
<!-- controlsList/contextmenu: no download affordance — the decrypted audio
must not be savable to disk (would undermine encryption at rest). -->
<audio
class="player"
controls
controlsList="nodownload noplaybackrate"
oncontextmenu={(e) => e.preventDefault()}
src={audioSrc}
></audio>
{:else if audioError}
<p class="muted">{audioError}</p>
{:else}
<p class="muted">Loading recording…</p>
{/if}
{/if}
{#if meetings.selected && settings.settings.sync_enabled}
<h3><UploadCloud size={14} aria-hidden="true" /> Sync</h3>
<button
@@ -161,6 +292,7 @@
<span class="artifact">{job.artifact}</span>
<span class="job-status {job.status}">{job.status}</span>
{#if job.status === "uploading" && job.bytesTotal}
<progress class="job-bar" max={job.bytesTotal} value={job.bytesSent}></progress>
<span class="muted">{Math.round((100 * job.bytesSent) / job.bytesTotal)}%</span>
{/if}
{#if job.status === "failed"}
@@ -185,21 +317,42 @@
{#if !meetings.selected}
<p class="muted">Select a meeting to tag it.</p>
{:else}
<input
class="grow"
list="known-tags"
placeholder="project, client, topic…"
bind:value={tagsInput}
onkeydown={(e) => e.key === "Enter" && saveTags()}
/>
<div class="tag-editor">
{#each pendingTags as t (t)}
<TagChip
tag={t}
removable
onRemove={() => removeTag(t)}
onClick={() => meetings.filterByTag(t)}
/>
{/each}
<input
class="tag-input"
list="known-tags"
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
bind:value={tagDraft}
oninput={onTagInput}
onkeydown={onTagInputKeydown}
onblur={commitDraft}
/>
</div>
<datalist id="known-tags">
{#each meetings.allTags as t (t)}
<option value={t}></option>
{/each}
</datalist>
<button class="link" onclick={saveTags} disabled={savingTags}>
{savingTags ? "Saving…" : "Save tags"}
</button>
<div class="actions">
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
<Sparkles size={14} aria-hidden="true" />
{generatingTags ? "Generating…" : "Generate tags"}
</button>
<button class="link" onclick={saveTags} disabled={savingTags}>
{savingTags ? "Saving…" : "Save tags"}
</button>
</div>
{#if tagGenError}
<p class="error">{tagGenError}</p>
{/if}
{/if}
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
@@ -275,11 +428,15 @@
{#if !meetings.selected}
<p class="muted">Select a meeting to link it to a calendar event.</p>
{:else}
<div class="row">
<input type="text" bind:value={eventLinkSearch} placeholder="Search event title…" />
<input type="date" bind:value={eventLinkDateFilter} />
</div>
<label class="pick"
>Linked event
<select value={meetings.selected.calendar_event_id ?? ""} onchange={onPickEvent}>
<option value="" disabled>{eventDetail ? "Change event…" : "Link an event…"}</option>
{#each calendar.events as ev (ev.id)}
{#each filteredLinkEvents as ev (ev.id)}
<option value={ev.id}
>{ev.subject ?? "(untitled)"} — {formatEventTime(ev.starts_at)}</option
>
@@ -290,6 +447,8 @@
<p class="muted small">
No events imported yet — import a <code>.pst</code> from Settings → Calendar.
</p>
{:else if filteredLinkEvents.length === 0}
<p class="muted small">No events match this search/date — try clearing one.</p>
{/if}
{#if eventDetail}
@@ -400,6 +559,30 @@
font-size: 0.85rem;
margin: 0;
}
.tag-editor {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0;
}
.tag-input {
flex: 1;
min-width: 6rem;
border: none;
background: none;
padding: 0.2rem 0;
font-size: 0.8rem;
}
.tag-input:focus {
outline: none;
}
.actions {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.4rem;
}
.muted.small {
font-size: 0.78rem;
}
@@ -541,6 +724,27 @@
.artifact {
min-width: 5rem;
}
.job-bar {
flex: 1;
height: 0.4rem;
min-width: 3rem;
border: none;
border-radius: var(--radius-full);
overflow: hidden;
accent-color: var(--accent, #2563eb);
}
.job-bar::-webkit-progress-bar {
background: var(--bg-hover);
border-radius: var(--radius-full);
}
.job-bar::-webkit-progress-value {
background: var(--accent, #2563eb);
border-radius: var(--radius-full);
}
.player {
width: 100%;
margin: 0.2rem 0 0.6rem;
}
.job-status {
text-transform: capitalize;
color: var(--muted);
+78 -13
View File
@@ -21,6 +21,8 @@
RefreshCw,
MessageSquareText,
NotebookPen,
Eye,
Pencil,
} from "@lucide/svelte";
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
@@ -28,6 +30,9 @@
}
let notesText = $state("");
// Notes is a single pane: raw markdown ("Editor") or the rendered result
// ("Preview"), toggled by one button whose label flips to the other mode.
let notesPreview = $state(false);
let editorEl: HTMLTextAreaElement | undefined = $state();
let saveTimer: ReturnType<typeof setTimeout> | undefined;
let loadedForId: string | null = null;
@@ -43,6 +48,19 @@
}
});
// Recordings default to "Untitled meeting" (T2.2) — this is the only
// rename affordance, since nothing else in the UI shows the title at all.
async function onTitleChange(e: Event) {
const m = meetings.selected;
const value = (e.target as HTMLInputElement).value.trim();
if (!m) return;
if (!value) {
(e.target as HTMLInputElement).value = m.title; // revert an empty edit
return;
}
if (value !== m.title) await meetings.renameMeeting(m.id, value);
}
function scheduleSave() {
const id = meetings.selected?.id;
if (!id) return;
@@ -128,6 +146,12 @@
<div class="wrap">
{#if meetings.selected}
{@const m = meetings.selected}
<input
class="meeting-title"
value={m.title}
onchange={onTitleChange}
aria-label="Meeting title"
/>
<div class="split">
<div class="pane transcript">
<h4><MessageSquareText size={14} aria-hidden="true" /> Transcript</h4>
@@ -182,6 +206,20 @@
>
<ListChecks size={14} aria-hidden="true" />
</button>
<button
class="toggle"
onclick={() => (notesPreview = !notesPreview)}
title={notesPreview ? "Edit the raw markdown" : "Render the markdown"}
aria-pressed={notesPreview}
>
{#if notesPreview}
<Pencil size={14} aria-hidden="true" />
Editor
{:else}
<Eye size={14} aria-hidden="true" />
Preview
{/if}
</button>
<span class="spacer"></span>
<button onclick={exportMd} title="Export notes as .md">
<FileText size={13} aria-hidden="true" />
@@ -201,14 +239,17 @@
</button>
</div>
<div class="editor-preview">
<textarea
bind:this={editorEl}
bind:value={notesText}
oninput={scheduleSave}
placeholder="Notes…"
></textarea>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
<div class="preview">{@html renderMarkdown(notesText)}</div>
{#if notesPreview}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
<div class="preview">{@html renderMarkdown(notesText)}</div>
{:else}
<textarea
bind:this={editorEl}
bind:value={notesText}
oninput={scheduleSave}
placeholder="Notes…"
></textarea>
{/if}
</div>
</div>
</div>
@@ -229,6 +270,22 @@
<style>
.wrap {
height: 100%;
display: flex;
flex-direction: column;
}
.meeting-title {
flex: none;
border: none;
background: transparent;
font-size: 1.05rem;
font-weight: 600;
padding: 0.75rem 1rem 0.25rem;
color: inherit;
}
.meeting-title:hover,
.meeting-title:focus {
background: var(--border);
outline: none;
}
.pad {
padding: 1rem;
@@ -249,7 +306,8 @@
.split {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
flex: 1;
min-height: 0;
}
.pane {
overflow: auto;
@@ -330,11 +388,14 @@
}
.editor-preview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
height: calc(100% - 2.5rem);
}
.toolbar .toggle {
font-weight: 600;
}
.toolbar .toggle[aria-pressed="true"] {
background: var(--bg-hover);
}
textarea {
resize: none;
width: 100%;
@@ -352,8 +413,12 @@
border-color: var(--accent);
}
.preview {
height: 100%;
box-sizing: border-box;
overflow: auto;
padding: 0.25rem 0.5rem;
padding: 0.6rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: 0.9rem;
line-height: 1.5;
}