14 Commits

Author SHA1 Message Date
150059e174 fix(rms_vad): correct types and threshold order in flush idempotency test
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Two issues in flush_is_idempotent_and_leaves_clean_state from
581a098:

1. silence_close_samples and max_chunk_samples were cast `as u64`
   but with_thresholds takes usize — wouldn't compile.
2. enter_threshold was 0.005 and exit_threshold 0.01, which
   violates the hysteresis invariant (enter must be >= exit) and
   panics in debug_assert at runtime. Swap to 0.01 / 0.005 so the
   test actually runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:22:22 +01:00
Claude
73f8c45f86 fix(live): self-stop worker when both result and status channels are dead
`emit_live_result` already detected a lost result_channel listener: it
sent a one-shot status warning and from then on short-circuited future
result sends. But if the status_channel listener was also gone — which
is what happens when the user closes the main window without calling
stop_live_transcription_session — the worker kept polling inflight
inference every 10 ms forever, holding a model loaded on the GPU and
keeping the WAV writer file handle open until the process exited.

When the warning send to status_channel also returns Err, the entire
frontend channel pair is dead. Self-assert stop_flag from inside
emit_live_result so the worker drains and exits cleanly. Existing user-
initiated stop semantics are unchanged.

- Threaded `stop_flag: &Arc<AtomicBool>` through `emit_live_result` and
  the free `poll_inference` (instance method already had access via
  `self.stop_flag`).
- Existing `result_listener_loss_is_warned_once_*` test updated to pass
  a stop_flag and assert it stays false when only result_channel fails.
- New test `dead_result_and_status_channels_self_assert_stop_flag` proves
  the self-stop fires when both channels Err.

(src-tauri doesn't build in the audit sandbox — needs webkit2gtk; CI
cross-platform compiles it.)

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 09:38:16 +00:00
Claude
d5d751c9ad fix(hotkey): exit listener cleanly when event channel is dropped
The evdev listener's run loop did `let _ = event_tx.send(event).await`
inside the trigger-key match arm. If the receiver was dropped without
the explicit shutdown signal (set hotkey to None), the send returned
Err and the loop kept polling — sending into a closed channel forever
until something else terminated the task.

Replace with explicit handling: on Err, log via log::warn! once and
return Ok(()) from `run`. The shutdown-via-None path is unaffected.
kon-hotkey still 4/4.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 09:38:03 +00:00
Claude
a7fdc96e7b fix(audio): log dropped capture errors instead of silently discarding
The cpal stream-error closure used `let _ = err_tx.try_send(...)` against a
bounded sync_channel(16). If the live session's listener stalled or the
frontend disconnected, runtime stream errors were silently dropped — the
diagnostic bundle showed nothing for a session that mysteriously stopped
working.

- Bump the error channel capacity 16 → 32 (matches AUDIO_CHANNEL_CAPACITY).
- On try_send failure, log to stderr with the device name + a per-session
  drop counter so the symptom is visible in the diagnostic bundle even
  when the typed event never reached the frontend.
- Plumb a new `dropped_errors: Arc<AtomicU64>` through `build_input_stream`
  alongside the existing `dropped_chunks`, mirroring the same pattern.

(kon-audio doesn't build in the audit sandbox: it links against ALSA
which the sandbox lacks. CI cross-platform compiles it.)

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 09:37:51 +00:00
Claude
581a098508 fix(rms_vad): flush always leaves the chunker in clean state
The earlier audit noted that `flush()` had three exit paths but only two
of them explicitly cleared all state-machine fields:
1. InSpeech with non-empty active_chunk: emit_active_chunk_and_close()
   handled it.
2. InSpeech with empty active_chunk (hit_max-mid-flush): handled inline.
3. Idle (no padded frame, or padded frame closed cleanly): no explicit
   reset — silent_tail_samples / pending_onset_frames / onset_buffer
   could carry stale values from `consume_frame` calls inside the same
   flush.

In the worst case, the first feed of a fresh recording could see leftover
onset bookkeeping and produce a chunk start that doesn't match the new
session's audio. Reusing the same `RmsVadChunker` across stop/start is
the main path that would hit this.

Add a single defence-in-depth reset block at the end of flush — every
exit path lands the chunker in the same fields a fresh chunker has,
except `next_sample_index` (the running total-samples counter, intent-
ionally preserved). Test asserts: a second flush after a full speech →
silence → partial-pending sequence emits zero chunks, and a subsequent
silent feed also emits zero, proving no stale state leaked.

(kon-transcription doesn't build in the audit sandbox because ort-sys's
build script can't reach pyke's CDN; CI cross-platform compiles it.)

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 09:37:39 +00:00
Claude
c04c719d48 perf(storage): prune error_log on startup with 90-day retention
The error_log table had no retention policy: every backend error was
appended forever, so across months of dogfooding it grew unbounded. That
silently bloats the diagnostic-bundle export and slows the
list_recent_errors query the Settings → About panel runs.

- New `kon_storage::prune_error_log(pool, keep_days)` does a single
  `DELETE FROM error_log WHERE timestamp < datetime('now', '-Nd days')`
  and returns the row count removed.
- src-tauri/src/lib.rs runs it once during setup() with a const
  ERROR_LOG_RETENTION_DAYS = 90. Failure is logged to stderr but does not
  block startup — a prune that fails is strictly less important than the
  app coming up.
- Test: insert three rows at now / -30d / -200d, verify a 90-day prune
  removes only the oldest, and a subsequent 14-day prune removes the
  -30d row. Storage suite at 60/60.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 09:37:24 +00:00
Claude
38da407942 fix(export): split bulk-export toast across success / partial / all-failed
The Phase 9 bulk export utility had a single success toast that was emitted
even when zero of N writes succeeded — "Exported 0 of 5 transcripts to
folder/" reading like the user just deliberately exported nothing.

Branch on the result count:
- 0 of N: error toast pointing at the console for write failures.
- N of N: success toast.
- M of N: warn toast — partial export, with the same console pointer.

Single-file save (`saveTranscriptAsMarkdown`) was already correct:
explicit success on save, error on failure, silent on user-cancelled —
left untouched.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 09:37:13 +00:00
Claude
fd48f55edb perf(meeting): only run process poller while auto-capture is enabled
Previously the 15-second meeting-detection setInterval was started in
onMount unconditionally (when Tauri runtime was available). When
`settings.meetingAutoCapture` was disabled the callback still fired every
15 s, just to early-return — burning a wakeup that did no useful work
and confusing "is this firing? did the toggle take effect?" debugging.

Move the timer into a `$effect` whose only tracked dependency is
`settings.meetingAutoCapture`. Toggling off now clears the interval; toggling
on creates a fresh one. Reads of `meetingAutoCaptureApps` and `globalHotkey`
happen inside the interval callback (post-setup) so they don't trigger the
effect to tear down on every keystroke in the apps editor.

The `meetingCapturePoller` variable and its `onDestroy` cleanup are gone —
the effect's own cleanup return takes care of it on unmount.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 09:37:05 +00:00
Claude
41be27b410 fix(toasts): cap items array at 50 to bound runaway error toasts
Error toasts are sticky (duration: 0) so a misbehaving command that fires
errors in a loop — a backend that flaps, a polling effect over a broken
endpoint — accumulates toast items in the in-memory store indefinitely.
The audit found no other unbounded $state arrays in the frontend stores
(history caps at 500, recentNudges prunes by time, tasks/taskLists
replace-on-load), but `items.push(toast)` had no upper bound.

Add MAX_TOASTS = 50 with FIFO eviction. Doesn't change behaviour for
realistic toast volumes; only kicks in if something is genuinely wrong.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 09:09:29 +00:00
Claude
ab3bb9370c fix(viewer): hand off transcript ID only, fetch row from SQLite on mount
HistoryPage previously serialised the full TranscriptDto — text, segments,
manual + LLM tags, audio path — into `localStorage["kon_viewer_item"]` so
the viewer window could pick it up on mount. On a multi-hour transcript
that's MB-scale of user voice content sitting in storage that any
same-origin script in any open Kon window can read.

Hand off only `{ id }` (and a timestamp on re-saves). The viewer fetches
the canonical row from SQLite via the existing `get_transcript` Tauri
command and hydrates via the now-exported `mapTranscriptRow`. Cross-window
sync via the `storage` event still works — the receiving window re-fetches
on event instead of trusting the payload.

- HistoryPage `openViewer` + `openEditor`: write `{ id }` only.
- viewer `onMount` + `handleStorageChange`: route through new
  `loadFromHandoff` which calls `invoke("get_transcript", { id })`.
- viewer `saveItemToHistory`: re-stamp localStorage with `{ id, stamp }`
  to retrigger the storage event in sibling windows without leaking
  content.
- `mapTranscriptRow` exported from page.svelte.ts for the viewer's use.

Backward-compatible at the parse layer: the `{ id }` shape extracts cleanly
from a stale full-DTO payload (TranscriptEntry already carries `id` at top
level), so a session that survives the upgrade picks up the new path on
next handoff without manual cleanup.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 08:47:12 +00:00
Claude
3d568148b8 perf(meeting): cache sysinfo System for the meeting-detection poller
`detect_meeting_processes` is called every 15 s when meeting-auto-capture
is enabled. The previous `list_running_process_names` allocated a fresh
`sysinfo::System` per call and walked /proc cold; on a busy host
(~300 processes) that's ~50–100 ms of work, every poll, forever.

Add `kon_core::process_watch::ProcessLister`, a thin wrapper around a
long-lived `System` whose process table is refreshed in place. The Tauri
host holds one behind a `Mutex<ProcessLister>` in a new `MeetingState`
managed at app setup. The free `list_running_process_names` is kept as a
convenience that constructs a fresh `ProcessLister` per call — its only
remaining caller is the existing smoke test.

- ProcessLister + Default in crates/core/src/process_watch.rs.
- MeetingState in src-tauri/src/commands/meeting.rs; the command takes
  it via `tauri::State` and locks for the duration of the snapshot.
- src-tauri/src/lib.rs registers MeetingState alongside the other
  managed states.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 08:46:57 +00:00
Claude
f3fd86185e perf(storage): composite (profile_id, created_at DESC) index on transcripts
Migration v15 adds a composite index covering the dominant transcripts
query path:

    SELECT ... FROM transcripts
    WHERE profile_id = ? ORDER BY created_at DESC LIMIT ?

Previously SQLite had to choose between idx_transcripts_profile_id
(filter by profile, then in-memory sort by date) and idx_transcripts_created
(scan dates and filter on profile). Both work fine at hundreds of rows
and degrade past a few thousand.

`migration_v15_creates_profile_created_index` asserts (a) the index exists
and (b) `EXPLAIN QUERY PLAN` shows the planner picks it for the canonical
profile-scoped, date-ordered list query.

Test count assertions in `test_migrations_run_on_empty_db` and
`test_migrations_idempotent` bumped 14 → 15.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 08:46:45 +00:00
Claude
90f4d9b0fb fix(mcp): open Kon database read-only
The kon-mcp stdio server is documented as "read-only, no auth, local-only"
but until now opened the SQLite store via `kon_storage::init`, which returns
a writable pool and runs migrations. Read-only-ness was enforced only by the
exposed tool surface (list_transcripts, get_transcript, search_transcripts,
list_tasks); a future bug or a malformed dispatch could escape into a write
against the user's primary database.

Add `kon_storage::init_readonly` that opens with `SqliteConnectOptions
::read_only(true)` and `create_if_missing(false)`, no migrations. The
constraint is now structural — SQLite rejects writes at the connection
level regardless of which handler runs.

- New `init_readonly(path)` in crates/storage/src/database.rs.
- Re-exported from kon_storage.
- crates/mcp/src/main.rs switched over and updated startup banner.
- Two tests: writes fail on the read-only pool, reads succeed; opening a
  non-existent DB returns an error instead of silently creating one.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 08:46:35 +00:00
Claude
dfa6457f1f fix(history): bulk delete + clear-all now persist to SQLite
The Phase 9 bulk-delete path passed UUID strings to deleteFromHistory(index),
which expected an integer; JS coerced the string to NaN and splice(NaN, 1)
collapsed to splice(0, 1), so bulk-delete silently removed the first N visible
rows instead of the selected ones, then fired delete_transcript against the
wrong IDs.

Clear-all called saveHistory(), which was a no-op stub left over from the
same incomplete-refactor pattern that produced the manualTags persistence bug
fixed in 7eb52d9. The in-memory array was emptied, but SQLite still held
every transcript, so they reappeared on next loadHistory().

- Add deleteFromHistoryById(id) next to the index-keyed deleteFromHistory.
- bulkDelete now calls deleteFromHistoryById.
- clearAll now awaits an explicit per-id delete loop and surfaces a toast on
  partial failure.
- Remove the saveHistory() stub and its sole caller's import.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 06:54:01 +00:00
17 changed files with 641 additions and 96 deletions

View File

@@ -370,11 +370,15 @@ fn open_and_validate(
let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
let requeue_tx = tx.clone();
let dropped_chunks = Arc::new(AtomicU64::new(0));
// Bounded channel for runtime stream errors. Capacity 16 = plenty for
// the rare error case; if it ever fills, we drop newer errors silently
// because they would be redundant noise in a stream that is already
// failing. (Codex review 2026/04/17 M2)
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(16);
// Bounded channel for runtime stream errors. Capacity 32 = plenty for
// the rare error case; if it ever fills, drops are reported via stderr
// and counted in `dropped_errors` so the symptom is visible in the
// diagnostic bundle even when the listener has gone away. Errors
// beyond the cap are by definition redundant noise in a stream that
// is already failing. (Codex review 2026/04/17 M2; capacity bump and
// drop logging added 2026/04/25 audit pass.)
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32);
let dropped_errors = Arc::new(AtomicU64::new(0));
let stream = match format {
SampleFormat::F32 => build_input_stream::<f32>(
@@ -385,6 +389,7 @@ fn open_and_validate(
tx,
dropped_chunks.clone(),
err_tx.clone(),
dropped_errors.clone(),
name.to_string(),
),
SampleFormat::I16 => build_input_stream::<i16>(
@@ -395,6 +400,7 @@ fn open_and_validate(
tx,
dropped_chunks.clone(),
err_tx.clone(),
dropped_errors.clone(),
name.to_string(),
),
SampleFormat::U16 => build_input_stream::<u16>(
@@ -405,6 +411,7 @@ fn open_and_validate(
tx,
dropped_chunks.clone(),
err_tx.clone(),
dropped_errors.clone(),
name.to_string(),
),
other => {
@@ -503,6 +510,7 @@ fn build_input_stream<T>(
tx: mpsc::SyncSender<AudioChunk>,
dropped_chunks: Arc<AtomicU64>,
err_tx: mpsc::SyncSender<CaptureRuntimeError>,
dropped_errors: Arc<AtomicU64>,
device_name: String,
) -> std::result::Result<cpal::Stream, cpal::BuildStreamError>
where
@@ -532,10 +540,24 @@ where
// frontend can show a toast. Also keep the eprintln for ops
// logs. (Codex review 2026/04/17 M2)
eprintln!("[kon-audio] capture error: {err}");
let _ = err_tx.try_send(CaptureRuntimeError {
device_name: err_device_name.clone(),
message: err.to_string(),
});
if err_tx
.try_send(CaptureRuntimeError {
device_name: err_device_name.clone(),
message: err.to_string(),
})
.is_err()
{
// Channel full — listener has stalled or detached. Note
// it in stderr and the dropped-errors counter so the
// diagnostic bundle still shows the symptom even if the
// frontend never received the typed event.
let prior = dropped_errors.fetch_add(1, Ordering::Relaxed);
eprintln!(
"[kon-audio] capture error channel full; dropped error #{} for device '{}'",
prior + 1,
err_device_name,
);
}
},
None,
)

View File

@@ -8,18 +8,56 @@
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
/// Reusable wrapper around a `sysinfo::System` whose process table is
/// refreshed in place on every poll, instead of allocating a fresh one.
///
/// On a busy host (~300 processes), `System::new_with_specifics` followed by
/// `refresh_processes` walks `/proc` cold and costs ~50100 ms; reusing the
/// same instance reuses sysinfo's per-process bookkeeping so subsequent
/// refreshes are dominated by diffing rather than allocation. The Tauri
/// host holds one of these behind a `Mutex` for the meeting-detection
/// command to call every 15 s.
pub struct ProcessLister {
system: System,
}
impl Default for ProcessLister {
fn default() -> Self {
Self::new()
}
}
impl ProcessLister {
pub fn new() -> Self {
Self {
system: System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
),
}
}
/// Refresh the process table in place and return the current
/// lowercased executable names.
pub fn snapshot(&mut self) -> Vec<String> {
self.system
.refresh_processes(ProcessesToUpdate::All, true);
self.system
.processes()
.values()
.map(|process| process.name().to_string_lossy().to_lowercase())
.collect()
}
}
/// Snapshot the current process list's executable/command names. Lowercased
/// for case-insensitive pattern matching.
///
/// Convenience wrapper that allocates a fresh `ProcessLister` per call.
/// Hot paths (the meeting-detection poller) should hold a long-lived
/// `ProcessLister` and call `snapshot()` directly to avoid the per-call
/// allocation of `System`'s internal bookkeeping.
pub fn list_running_process_names() -> Vec<String> {
let mut system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
);
system.refresh_processes(ProcessesToUpdate::All, true);
system
.processes()
.values()
.map(|process| process.name().to_string_lossy().to_lowercase())
.collect()
ProcessLister::new().snapshot()
}
/// Match a snapshot of process names against case-insensitive substring

View File

@@ -317,10 +317,26 @@ async fn device_listener(
&& alt_held == combo.alt
&& super_held == combo.super_key
{
if pressed {
let _ = event_tx.send(HotkeyEvent::Pressed).await;
let to_send = if pressed {
Some(HotkeyEvent::Pressed)
} else if released {
let _ = event_tx.send(HotkeyEvent::Released).await;
Some(HotkeyEvent::Released)
} else {
None
};
if let Some(event) = to_send {
if event_tx.send(event).await.is_err() {
// Receiver was dropped without an
// explicit None-on-hotkey-rx
// shutdown. Log once and exit so
// the listener doesn't spin
// sending into a closed channel.
log::warn!(
"Hotkey event channel closed; \
listener for device exiting"
);
return Ok(());
}
}
}
}

View File

@@ -7,8 +7,15 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let db_path = kon_storage::database_path();
eprintln!("[kon-mcp] opening Kon database at {}", db_path.display());
let pool = kon_storage::init(&db_path).await?;
eprintln!(
"[kon-mcp] opening Kon database at {} (read-only)",
db_path.display()
);
// Open read-only at the connection level so the MCP server cannot write
// to the user's database, regardless of which tools the dispatcher
// exposes. Migrations are deliberately skipped — this binary never owns
// the schema; the main app is the single migration writer.
let pool = kon_storage::init_readonly(&db_path).await?;
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");
let mut lines = BufReader::new(tokio::io::stdin()).lines();

View File

@@ -31,6 +31,25 @@ pub async fn init(db_path: &Path) -> Result<SqlitePool> {
Ok(pool)
}
/// Open the SQLite database in read-only mode without running migrations.
///
/// Used by `kon-mcp` so the MCP server cannot write to the user's database
/// regardless of which tools the dispatcher exposes — `read_only(true)` makes
/// the constraint structural rather than relying on the request handler being
/// well-behaved. Fails cleanly if the DB doesn't exist (no `create_if_missing`).
pub async fn init_readonly(db_path: &Path) -> Result<SqlitePool> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(false)
.read_only(true);
SqlitePoolOptions::new()
.max_connections(2)
.connect_with(options)
.await
.map_err(|e| KonError::StorageError(format!("Read-only connect failed: {e}")))
}
/// Run schema migrations via the versioned migration system.
async fn run_migrations(pool: &SqlitePool) -> Result<()> {
crate::migrations::run_migrations(pool).await
@@ -1136,6 +1155,26 @@ pub struct ErrorLogRow {
pub metadata: Option<String>,
}
/// Delete `error_log` rows older than `keep_days` whole days. Returns the
/// row count removed.
///
/// Called once on app startup so the table doesn't grow unbounded across
/// months of dogfooding — without this it would eventually balloon the
/// diagnostic-bundle export and slow the `list_recent_errors` query.
/// 90 days is the default: long enough to triage a "this happened a few
/// weeks ago" report, short enough that the table stays kilobyte-scale.
pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result<u64> {
let result = sqlx::query(
"DELETE FROM error_log \
WHERE timestamp < datetime('now', ?)",
)
.bind(format!("-{keep_days} days"))
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Prune error_log failed: {e}")))?;
Ok(result.rows_affected())
}
pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<ErrorLogRow>> {
let rows = sqlx::query(
"SELECT id, timestamp, context, error_code, message, metadata \
@@ -2388,4 +2427,108 @@ mod tests {
let total: u32 = series.iter().map(|d| d.count).sum();
assert_eq!(total, 2, "exactly two completions across the window");
}
#[tokio::test]
async fn init_readonly_rejects_writes_and_serves_reads() {
let dir = std::env::temp_dir().join(format!("kon-storage-ro-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ro.db");
let _ = std::fs::remove_file(&path);
// Seed via the writable path so migrations run.
let writable = init(&path).await.unwrap();
insert_transcript(
&writable,
&InsertTranscriptParams {
id: "ro1",
text: "seed",
source: "microphone",
profile_id: crate::DEFAULT_PROFILE_ID,
title: None,
audio_path: None,
duration: 0.0,
engine: None,
model_id: None,
inference_ms: None,
sample_rate: None,
audio_channels: None,
format_mode: None,
remove_fillers: false,
british_english: false,
anti_hallucination: false,
},
)
.await
.unwrap();
writable.close().await;
// Reopen read-only.
let ro = init_readonly(&path).await.expect("read-only open");
assert_eq!(count_transcripts(&ro).await.unwrap(), 1, "reads must work");
let write_attempt = sqlx::query("DELETE FROM transcripts WHERE id = 'ro1'")
.execute(&ro)
.await;
assert!(
write_attempt.is_err(),
"writes must be rejected on the read-only pool",
);
ro.close().await;
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[tokio::test]
async fn init_readonly_fails_when_db_missing() {
let path = std::env::temp_dir().join(format!(
"kon-storage-ro-missing-{}.db",
std::process::id()
));
let _ = std::fs::remove_file(&path);
assert!(
init_readonly(&path).await.is_err(),
"must fail when DB file does not exist (no create_if_missing)",
);
}
#[tokio::test]
async fn prune_error_log_removes_old_rows_only() {
let pool = test_pool().await;
// Three rows: one from now, one 30 days old, one 200 days old.
sqlx::query(
"INSERT INTO error_log (timestamp, context, error_code, message) VALUES \
(datetime('now'), 'recent', NULL, 'now'), \
(datetime('now', '-30 days'), 'older', NULL, '30d'), \
(datetime('now', '-200 days'), 'oldest', NULL, '200d')",
)
.execute(&pool)
.await
.unwrap();
// Default 90-day retention removes only the 200-day row.
let removed = prune_error_log(&pool, 90).await.unwrap();
assert_eq!(removed, 1, "only the 200-day row should be pruned");
let remaining: Vec<String> = sqlx::query_scalar(
"SELECT context FROM error_log ORDER BY id ASC",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]);
// A 14-day window prunes the 30-day row too.
let removed = prune_error_log(&pool, 14).await.unwrap();
assert_eq!(removed, 1);
let remaining: Vec<String> = sqlx::query_scalar(
"SELECT context FROM error_log",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(remaining, vec!["recent".to_string()]);
}
}

View File

@@ -10,11 +10,12 @@ pub use database::{
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task,
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
get_transcript, init, insert_implementation_rule, insert_subtask, insert_task,
get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting,
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
set_setting,
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,

View File

@@ -463,6 +463,20 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
ALTER TABLE transcripts ADD COLUMN llm_tags TEXT NOT NULL DEFAULT '';
"#,
),
(
15,
"transcripts: composite (profile_id, created_at DESC) index",
r#"
-- Performance index for the dominant transcripts query path:
-- WHERE profile_id = ? ORDER BY created_at DESC LIMIT ?
-- The standalone idx_transcripts_profile_id and idx_transcripts_created
-- forced SQLite to either filter by profile then sort, or scan the date
-- index and filter — fine at hundreds of rows, painful past a few thousand.
-- A composite index covers both predicates in one ordered seek.
CREATE INDEX IF NOT EXISTS idx_transcripts_profile_created
ON transcripts(profile_id, created_at DESC);
"#,
),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -612,7 +626,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 14);
assert_eq!(count, 15);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -631,7 +645,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 14);
assert_eq!(count, 15);
}
#[tokio::test]
@@ -1124,4 +1138,48 @@ mod tests {
.expect("query");
assert_eq!(auto, 0, "pre-existing completed rows must default to 0");
}
#[tokio::test]
async fn migration_v15_creates_profile_created_index() {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
run_migrations(&pool).await.expect("migrate");
// Index exists by name.
let names: Vec<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master \
WHERE type = 'index' AND tbl_name = 'transcripts'",
)
.fetch_all(&pool)
.await
.expect("read indexes");
assert!(
names.iter().any(|n| n == "idx_transcripts_profile_created"),
"expected composite (profile_id, created_at) index, got {names:?}",
);
// Query planner picks the composite index for the dominant
// profile-scoped, date-ordered list query. EXPLAIN QUERY PLAN
// returns (id, parent, notused, detail) — we want detail.
let plan_rows = sqlx::query(
"EXPLAIN QUERY PLAN \
SELECT id FROM transcripts \
WHERE profile_id = ? ORDER BY created_at DESC LIMIT 50",
)
.bind(crate::DEFAULT_PROFILE_ID)
.fetch_all(&pool)
.await
.expect("explain");
let plan: Vec<String> = plan_rows
.iter()
.map(|r| r.get::<String, _>("detail"))
.collect();
assert!(
plan.iter().any(|row| row.contains("idx_transcripts_profile_created")),
"planner should use the composite index, got plan: {plan:?}",
);
}
}

View File

@@ -318,17 +318,25 @@ impl VadChunker for RmsVadChunker {
// whatever is still open as the closing chunk.
if self.state == State::InSpeech && !self.active_chunk.is_empty() {
emitted.push(self.emit_active_chunk_and_close());
} else if self.state == State::InSpeech {
// hit_max emitted mid-flush and left state in InSpeech
// with active_chunk empty. Reset cleanly without emitting
// a zero-length closing chunk — the hit_max chunk already
// carried all the audio.
self.state = State::Idle;
self.silent_tail_samples = 0;
self.pending_onset_frames = 0;
self.onset_buffer.clear();
}
// Defence in depth: every flush exit-path must leave the chunker
// in the same clean state a freshly-constructed one is in,
// bar `next_sample_index` (the running total-samples counter,
// intentionally preserved across flush). Without this, a flush
// that emitted via `consume_frame`'s hit_max branch could leave
// `state == InSpeech` with stale `silent_tail_samples` or a
// populated `onset_buffer`, so the next feed() bleeds prior-
// session state into the first chunk of a fresh recording.
// The earlier branches already did most of this; the explicit
// clear here is a single source of truth.
self.state = State::Idle;
self.pending.clear();
self.active_chunk.clear();
self.silent_tail_samples = 0;
self.pending_onset_frames = 0;
self.onset_buffer.clear();
emitted
}
@@ -683,4 +691,45 @@ mod tests {
"start_sample must not skip past the onset frames"
);
}
#[test]
fn flush_is_idempotent_and_leaves_clean_state() {
// Drive the chunker through a full speech-then-silence cycle so
// most of the state-machine fields are exercised, flush once,
// then assert that flushing again is a no-op AND that feed-with-
// silence emits nothing (i.e. no stale onset / silent_tail
// bookkeeping leaks into the next feed).
let mut c = RmsVadChunker::with_thresholds(
0.01,
0.005,
DEFAULT_SPEECH_ONSET_FRAMES,
FRAME_SAMPLES * 4,
FRAME_SAMPLES * 50,
);
let speech = constant_signal(FRAME_SAMPLES * 6, 0.02);
let _ = c.push(&speech);
// Force a partial pending tail so flush exercises the padded-
// final-frame branch.
let partial = constant_signal(FRAME_SAMPLES / 3, 0.02);
let _ = c.push(&partial);
let _first = c.flush();
let second = c.flush();
assert!(
second.is_empty(),
"second flush must be a no-op; got {} chunk(s)",
second.len()
);
// A subsequent silent feed must emit nothing — proves nothing
// about prior speech leaked into the new session's bookkeeping.
let silence = constant_signal(FRAME_SAMPLES * 4, 0.0);
let chunks = c.push(&silence);
assert!(
chunks.is_empty(),
"post-flush silence must not emit any chunk; got {chunks:?}"
);
}
}

View File

@@ -300,6 +300,7 @@ impl LiveSessionRuntime {
&self.dictionary_terms,
&self.result_channel,
&self.status_channel,
&self.stop_flag,
)?;
Ok(())
}
@@ -859,6 +860,7 @@ fn maybe_dispatch_chunk(
})
}
#[allow(clippy::too_many_arguments)]
fn poll_inference(
inflight: &mut Option<InferenceTask>,
result_listener_lost: &mut bool,
@@ -868,6 +870,7 @@ fn poll_inference(
dictionary_terms: &[String],
result_channel: &Channel<LiveResultMessage>,
status_channel: &Channel<LiveStatusMessage>,
stop_flag: &Arc<AtomicBool>,
) -> Result<Option<bool>, String> {
let Some(task) = inflight else {
return Ok(None);
@@ -918,6 +921,7 @@ fn poll_inference(
result_channel,
status_channel,
result_listener_lost,
stop_flag,
&result_message,
);
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
@@ -968,6 +972,7 @@ fn emit_live_result(
result_channel: &Channel<LiveResultMessage>,
status_channel: &Channel<LiveStatusMessage>,
result_listener_lost: &mut bool,
stop_flag: &Arc<AtomicBool>,
result_message: &LiveResultMessage,
) -> bool {
if *result_listener_lost {
@@ -982,10 +987,25 @@ fn emit_live_result(
"[live] session {}: result listener unavailable on chunk {}: {}; continuing without live updates",
result_message.session_id, result_message.chunk_id, err
);
let _ = status_channel.send(LiveStatusMessage::Warning {
// If the warning send also fails, the entire frontend channel
// pair is dead — almost certainly the user closed the app
// window without calling stop_live_transcription_session.
// Self-assert stop_flag so the inference worker drains and
// exits cleanly instead of polling every 10 ms forever, which
// otherwise would burn CPU + GPU memory and keep the WAV
// writer file handle open until the process dies.
let warn_send = status_channel.send(LiveStatusMessage::Warning {
session_id: result_message.session_id,
message: "Live preview disconnected; transcription will continue in the background until you stop the session.".into(),
});
if warn_send.is_err() {
eprintln!(
"[live] session {}: status channel also unavailable; \
self-asserting stop_flag so the worker exits",
result_message.session_id
);
stop_flag.store(true, Ordering::Relaxed);
}
false
}
}
@@ -1531,6 +1551,7 @@ mod tests {
};
let mut recent_segments = Vec::new();
let mut result_listener_lost = false;
let stop_flag = Arc::new(AtomicBool::new(false));
let (tx1, rx1) = std::sync::mpsc::channel();
tx1.send(Ok(kon_transcription::TimedTranscript {
@@ -1559,6 +1580,7 @@ mod tests {
&[],
&result_channel,
&status_channel,
&stop_flag,
)
.unwrap();
@@ -1600,12 +1622,21 @@ mod tests {
&[],
&result_channel,
&status_channel,
&stop_flag,
)
.unwrap();
assert_eq!(second, Some(true));
assert!(inflight.is_none());
assert_eq!(recent_segments.len(), 2);
// Status channel still alive in this scenario, so stop_flag must
// NOT have been auto-asserted — the worker keeps running so the
// user can still call stop_live_transcription_session and receive
// the Finished status.
assert!(
!stop_flag.load(Ordering::Relaxed),
"stop_flag should stay false when the status channel is alive"
);
assert_eq!(
statuses.lock().unwrap().len(),
warning_count_after_first,
@@ -1613,6 +1644,47 @@ mod tests {
);
}
#[test]
fn dead_result_and_status_channels_self_assert_stop_flag() {
// Both channels error on every send: the frontend has gone away
// entirely (e.g., the user closed the main window without a
// graceful stop). The worker must self-stop via stop_flag so it
// doesn't burn CPU + GPU for an indefinitely-long session that
// nobody is observing.
let result_channel: Channel<LiveResultMessage> =
Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage));
let status_channel: Channel<LiveStatusMessage> =
Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage));
let stop_flag = Arc::new(AtomicBool::new(false));
let mut result_listener_lost = false;
let message = LiveResultMessage {
session_id: 99,
chunk_id: 1,
chunk_start_secs: 0.0,
duration: 0.5,
language: "en".into(),
inference_ms: 10,
segments: vec![],
raw_text: String::new(),
};
let delivered = emit_live_result(
&result_channel,
&status_channel,
&mut result_listener_lost,
&stop_flag,
&message,
);
assert!(!delivered, "send must report not delivered when result_channel errors");
assert!(result_listener_lost, "result_listener_lost must be set");
assert!(
stop_flag.load(Ordering::Relaxed),
"stop_flag must self-assert when both channels are dead so the worker exits"
);
}
#[tokio::test]
async fn concurrent_starts_allow_only_one_session_to_claim_the_slot() {
let live_state = Arc::new(LiveTranscriptionState::default());

View File

@@ -5,13 +5,46 @@
//! that reminds the user to start recording with their hotkey. We do not
//! start recording from this signal — the user decides.
use kon_core::process_watch;
use std::sync::Mutex;
use kon_core::process_watch::{self, ProcessLister};
/// Tauri-managed state for the meeting poller. Holds a long-lived
/// `ProcessLister` so each poll refreshes the existing `sysinfo::System`
/// in place instead of allocating a fresh one — the previous implementation
/// rebuilt the process table from scratch every 15 s.
pub struct MeetingState {
lister: Mutex<ProcessLister>,
}
impl MeetingState {
pub fn new() -> Self {
Self {
lister: Mutex::new(ProcessLister::new()),
}
}
}
impl Default for MeetingState {
fn default() -> Self {
Self::new()
}
}
#[tauri::command]
pub fn detect_meeting_processes(patterns: Vec<String>) -> Result<Vec<String>, String> {
pub fn detect_meeting_processes(
state: tauri::State<'_, MeetingState>,
patterns: Vec<String>,
) -> Result<Vec<String>, String> {
if patterns.is_empty() {
return Ok(Vec::new());
}
let processes = process_watch::list_running_process_names();
let processes = {
let mut lister = state
.lister
.lock()
.map_err(|e| format!("MeetingState lister poisoned: {e}"))?;
lister.snapshot()
};
Ok(process_watch::match_meeting_patterns(&processes, &patterns))
}

View File

@@ -9,7 +9,14 @@ use tauri::Manager;
use kon_core::types::EngineName;
use kon_llm::LlmEngine;
use kon_storage::{database_path, get_setting, init as init_db, set_setting};
use kon_storage::{
database_path, get_setting, init as init_db, prune_error_log, set_setting,
};
/// How long to retain `error_log` rows. Pruned once on startup.
/// 90 days is long enough to triage "this happened a few weeks ago"
/// reports and short enough that the table stays kilobyte-scale.
const ERROR_LOG_RETENTION_DAYS: i64 = 90;
use kon_transcription::LocalEngine;
/// Shared app state holding the transcription engines and database pool.
@@ -158,6 +165,22 @@ pub fn run() {
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
eprintln!("[startup] DB init: {:?}", t0.elapsed());
// Prune old `error_log` rows so the table doesn't grow unbounded
// across months of dogfooding. Best-effort — a prune failure is
// not worth blocking startup over.
let t_prune = Instant::now();
let pruned = tauri::async_runtime::block_on(async {
prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await
});
match pruned {
Ok(n) if n > 0 => eprintln!(
"[startup] Error log prune: {n} rows removed (>{ERROR_LOG_RETENTION_DAYS}d) in {:?}",
t_prune.elapsed()
),
Ok(_) => {}
Err(e) => eprintln!("[startup] Error log prune failed: {e}"),
}
// Load saved preferences for webview injection
let t1 = Instant::now();
let prefs_json = tauri::async_runtime::block_on(async {
@@ -250,6 +273,7 @@ pub fn run() {
app.manage(commands::audio::NativeCaptureState::new());
app.manage(commands::live::LiveTranscriptionState::default());
app.manage(commands::tts::TtsState::new());
app.manage(commands::meeting::MeetingState::new());
app.manage(AppState {
whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))),

View File

@@ -4,9 +4,9 @@
import { invoke } from "@tauri-apps/api/core";
import {
history,
saveHistory,
saveTranscriptMeta,
deleteFromHistory,
deleteFromHistoryById,
renameHistoryEntry,
} from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.js";
@@ -228,12 +228,27 @@
return items;
});
function clearAll() {
async function clearAll() {
if (!confirm("Delete all history? This can't be undone.")) return;
const ids = history.map((entry) => entry.id);
history.splice(0);
saveHistory();
expandedId = null;
stopPlayback();
let failed = 0;
for (const id of ids) {
try {
await invoke("delete_transcript", { id: String(id) });
} catch (err) {
failed++;
console.warn("clearAll: SQLite delete failed", id, err);
}
}
if (failed > 0) {
toasts.error(
"Some history entries could not be deleted",
`${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`,
);
}
}
function toggleExpand(id) {
@@ -320,12 +335,18 @@
}
async function openViewer(item) {
// Hand off the transcript ID only — the viewer window fetches the full
// row from SQLite via `get_transcript`. Previously the entire DTO
// (text + segments + tags) was stuffed into localStorage, putting
// potentially MB-scale PII in a place readable by any same-origin
// script.
const handoff = JSON.stringify({ id: String(item.id) });
try {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_item", handoff);
localStorage.setItem("kon_viewer_mode", "view");
await invoke("open_viewer_window");
} catch {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_item", handoff);
localStorage.setItem("kon_viewer_mode", "view");
window.open("/viewer", "_blank", "width=600,height=700");
}
@@ -453,7 +474,7 @@
`Delete ${selected.size} transcript${selected.size === 1 ? "" : "s"}?`,
);
if (!yes) return;
for (const id of selected) deleteFromHistory(id);
for (const id of selected) deleteFromHistoryById(id);
clearSelection();
}
@@ -484,12 +505,15 @@
});
async function openEditor(item) {
// Hand off the transcript ID only; the viewer hydrates from SQLite
// (see openViewer above for the rationale).
const handoff = JSON.stringify({ id: String(item.id) });
try {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_item", handoff);
localStorage.setItem("kon_viewer_mode", "edit");
await invoke("open_viewer_window");
} catch {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_item", handoff);
localStorage.setItem("kon_viewer_mode", "edit");
window.open("/viewer", "_blank", "width=600,height=700");
}

View File

@@ -140,7 +140,7 @@ export function saveProfiles() {
export const history = $state<TranscriptEntry[]>([]);
function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
export function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
const text = row.text ?? "";
const rawTags = row.manualTags ?? "";
const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
@@ -223,8 +223,6 @@ if (typeof window !== "undefined" && hasTauriRuntime()) {
loadHistory().catch(() => {});
}
export function saveHistory() {}
export async function addToHistory(entry: TranscriptWriteEntry) {
const normalised = normaliseTranscriptEntry(entry);
history.unshift(normalised);
@@ -323,6 +321,12 @@ export function deleteFromHistory(index: number) {
.catch((err) => console.warn("deleteFromHistory: SQLite delete failed", err));
}
export function deleteFromHistoryById(id: string) {
const idx = history.findIndex((entry) => String(entry.id) === String(id));
if (idx === -1) return;
deleteFromHistory(idx);
}
export const tasks = $state<TaskEntry[]>([]);
function mapTaskRow(row: TaskDto): TaskEntry {

View File

@@ -18,6 +18,12 @@ import { errorMessage } from "$lib/utils/errors.js";
let nextId = 1;
// Soft cap on how many toasts can be queued at once. Error toasts are sticky
// (duration: 0) so a misbehaving command that fires errors in a loop can pile
// up indefinitely without this cap. When exceeded, the oldest toast is
// evicted to make room.
const MAX_TOASTS = 50;
function defaultDuration(severity: ToastSeverity): number {
switch (severity) {
case "success": return 3000;
@@ -38,6 +44,9 @@ function createToastsStore() {
const duration = defaultDuration(severity);
const toast = { id, severity, title, body: body ?? '', duration };
items.push(toast);
if (items.length > MAX_TOASTS) {
items.splice(0, items.length - MAX_TOASTS);
}
if (duration > 0) {
setTimeout(() => dismiss(id), duration);
}

View File

@@ -90,11 +90,24 @@ export async function exportTranscriptsToDir(
console.error("exportTranscriptsToDir write failed", path, err);
}
}
toasts.success(
written === items.length
? `Exported ${written} transcript${written === 1 ? "" : "s"} to ${basename(dir)}`
: `Exported ${written} of ${items.length} transcripts to ${basename(dir)}`,
);
if (written === 0) {
// All writes failed — surface as an error instead of "Exported 0
// transcripts" success toast which reads like the user just bulk-
// exported nothing on purpose.
toasts.error(
"Couldn't export transcripts",
`0 of ${items.length} written to ${basename(dir)}. Check the console for details.`,
);
} else if (written === items.length) {
toasts.success(
`Exported ${written} transcript${written === 1 ? "" : "s"} to ${basename(dir)}`,
);
} else {
toasts.warn(
`Partial export to ${basename(dir)}`,
`${written} of ${items.length} transcripts written. Check the console for the failures.`,
);
}
return written;
}

View File

@@ -340,43 +340,52 @@
invoke("prewarm_default_model_cmd").catch(() => {});
}
// Meeting auto-capture: poll the process list and toast when a match
// appears (edge-triggered — no repeat toasts until the app goes away
// and comes back). We never start recording from this signal; the
// user decides whether to hit the hotkey.
if (tauriRuntimeAvailable) {
let previous: Set<string> = new Set();
meetingCapturePoller = window.setInterval(async () => {
if (!settings.meetingAutoCapture) { previous = new Set(); return; }
const patterns = settings.meetingAutoCaptureApps;
if (!Array.isArray(patterns) || patterns.length === 0) return;
try {
const matches: string[] = await invoke("detect_meeting_processes", { patterns });
const current = new Set(matches);
for (const match of matches) {
if (!previous.has(match)) {
toasts.info(
`${match[0].toUpperCase()}${match.slice(1)} detected`,
`Press ${settings.globalHotkey} to start recording.`,
);
}
}
previous = current;
} catch { /* ignore — backend may be mid-restart */ }
}, 15000);
}
// Meeting auto-capture is wired up below as a `$effect` so the
// 15-second poller only exists while the setting is enabled —
// toggling off in Settings stops the wakeups, not just the work.
});
let meetingCapturePoller: number | null = null;
// Meeting auto-capture poller. Edge-triggered: the first time a matching
// process appears we surface a non-modal toast; subsequent ticks where
// the same match is still present stay quiet until the app exits and
// returns. We never start recording from this signal — the user decides.
//
// The effect's only tracked dependencies are `tauriRuntimeAvailable`
// (constant after mount) and `settings.meetingAutoCapture`. Reads of
// `settings.meetingAutoCaptureApps` and `settings.globalHotkey` happen
// inside the interval callback, which runs after the synchronous setup
// and therefore is not tracked — editing the apps list doesn't tear
// down the poller.
$effect(() => {
if (!tauriRuntimeAvailable) return;
if (!settings.meetingAutoCapture) return;
let previous: Set<string> = new Set();
const id = window.setInterval(async () => {
const patterns = settings.meetingAutoCaptureApps;
if (!Array.isArray(patterns) || patterns.length === 0) return;
try {
const matches: string[] = await invoke("detect_meeting_processes", { patterns });
const current = new Set(matches);
for (const match of matches) {
if (!previous.has(match)) {
toasts.info(
`${match[0].toUpperCase()}${match.slice(1)} detected`,
`Press ${settings.globalHotkey} to start recording.`,
);
}
}
previous = current;
} catch { /* ignore — backend may be mid-restart */ }
}, 15000);
return () => { window.clearInterval(id); };
});
onDestroy(() => {
window.removeEventListener("resize", handleResize);
if (onWindowError) window.removeEventListener("error", onWindowError);
if (onUnhandledRejection) window.removeEventListener("unhandledrejection", onUnhandledRejection);
if (meetingCapturePoller !== null) {
window.clearInterval(meetingCapturePoller);
meetingCapturePoller = null;
}
if (!tauriRuntimeAvailable) {
return;
}

View File

@@ -2,13 +2,13 @@
import { onMount, onDestroy, tick } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import type { TranscriptEntry, ViewerSegment } from "$lib/types/app";
import type { TranscriptDto, TranscriptEntry, ViewerSegment } from "$lib/types/app";
import SpeakerButton from "$lib/components/SpeakerButton.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { errorMessage } from "$lib/utils/errors.js";
import { parseStoredJson } from "$lib/utils/storage.js";
import { saveTranscriptMeta } from "$lib/stores/page.svelte.js";
import { mapTranscriptRow, saveTranscriptMeta } from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.ts";
import { DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
@@ -71,8 +71,28 @@
buildAudio(nextItem?.audioPath ?? null);
}
// Hydrate the viewer from a transcript ID handed off via localStorage.
// The full row is fetched from SQLite via `get_transcript` so transcript
// text never lives in localStorage where any same-origin script could
// read it.
async function loadFromHandoff(raw: string | null) {
const handoff = parseStoredJson<{ id?: unknown }>(raw);
const id = handoff?.id != null ? String(handoff.id) : "";
if (!id) {
loadViewerItem(null);
return;
}
try {
const dto = await invoke<TranscriptDto | null>("get_transcript", { id });
loadViewerItem(dto ? mapTranscriptRow(dto) : null);
} catch (err) {
console.warn("viewer get_transcript failed", errorMessage(err));
loadViewerItem(null);
}
}
onMount(() => {
loadViewerItem(parseStoredJson<TranscriptEntry>(localStorage.getItem("kon_viewer_item")));
loadFromHandoff(localStorage.getItem("kon_viewer_item"));
const mode = localStorage.getItem("kon_viewer_mode");
if (mode === "edit" || mode === "view") viewerMode = mode;
@@ -120,7 +140,7 @@
function handleStorageChange(e: StorageEvent) {
if (e.key === "kon_viewer_item" && e.newValue) {
loadViewerItem(parseStoredJson<TranscriptEntry>(e.newValue));
void loadFromHandoff(e.newValue);
}
}
@@ -289,14 +309,17 @@
// store's `saveTranscriptMeta` helper — callers in this file wire those
// up alongside the plain-text update below.
//
// The cross-window `kon_viewer_item` localStorage payload stays in sync so
// a second viewer window hydrating from it reflects the latest edit.
// We re-stamp the localStorage handoff with the same ID so a sibling
// viewer window receives a `storage` event and re-fetches from SQLite.
// The handoff intentionally contains no transcript content — see
// `loadFromHandoff` for the rationale.
function saveItemToHistory() {
if (!item) return;
// Update the cross-window payload only. The old localStorage history
// cache was removed as part of the SQLite-canonical refactor.
try {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem(
"kon_viewer_item",
JSON.stringify({ id: String(item.id), stamp: Date.now() }),
);
} catch {}
// Best-effort SQLite persistence. Silently ignored in browser preview
// (no Tauri runtime) — matches the main-window store's behaviour.