Compare commits
14 Commits
cc77befda8
...
150059e174
| Author | SHA1 | Date | |
|---|---|---|---|
| 150059e174 | |||
|
|
73f8c45f86 | ||
|
|
d5d751c9ad | ||
|
|
a7fdc96e7b | ||
|
|
581a098508 | ||
|
|
c04c719d48 | ||
|
|
38da407942 | ||
|
|
fd48f55edb | ||
|
|
41be27b410 | ||
|
|
ab3bb9370c | ||
|
|
3d568148b8 | ||
|
|
f3fd86185e | ||
|
|
90f4d9b0fb | ||
|
|
dfa6457f1f |
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 ~50–100 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
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"))),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user