Land release blocker fixes and workspace cleanup
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

This commit is contained in:
2026-04-23 00:16:09 +01:00
parent d7363cc913
commit 9b0067b4c0
36 changed files with 1529 additions and 418 deletions

View File

@@ -225,13 +225,19 @@ mod tests {
assert_eq!(LlmPromptPreset::parse("EMAIL"), LlmPromptPreset::Email);
assert_eq!(LlmPromptPreset::parse("notes"), LlmPromptPreset::Notes);
assert_eq!(LlmPromptPreset::parse("meeting"), LlmPromptPreset::Notes);
assert_eq!(LlmPromptPreset::parse("meeting-notes"), LlmPromptPreset::Notes);
assert_eq!(
LlmPromptPreset::parse("meeting-notes"),
LlmPromptPreset::Notes
);
assert_eq!(LlmPromptPreset::parse("code"), LlmPromptPreset::Code);
assert_eq!(LlmPromptPreset::parse("software"), LlmPromptPreset::Code);
// Unknown values and explicit default fall back safely.
assert_eq!(LlmPromptPreset::parse("default"), LlmPromptPreset::Default);
assert_eq!(LlmPromptPreset::parse(""), LlmPromptPreset::Default);
assert_eq!(LlmPromptPreset::parse("random-unknown"), LlmPromptPreset::Default);
assert_eq!(
LlmPromptPreset::parse("random-unknown"),
LlmPromptPreset::Default
);
}
#[test]
@@ -240,7 +246,10 @@ mod tests {
// be empty so it composes cleanly with dictionary suffix.
assert!(LlmPromptPreset::Default.suffix().is_empty());
assert!(LlmPromptPreset::Email.suffix().contains("email"));
assert!(LlmPromptPreset::Notes.suffix().to_lowercase().contains("bullet"));
assert!(LlmPromptPreset::Notes
.suffix()
.to_lowercase()
.contains("bullet"));
assert!(LlmPromptPreset::Code.suffix().contains("technical"));
}
}

View File

@@ -540,8 +540,12 @@ mod tests {
fn is_hallucination_allows_dialogue_containing_thanks_mid_sentence() {
// Exact-match on trail phrases means legitimate dialogue that
// mentions "thanks" or "subscribe" is never dropped.
assert!(!is_hallucination("Thanks for the heads up on the migration"));
assert!(!is_hallucination("Please subscribe to the RSS feed and tell me when it updates"));
assert!(!is_hallucination(
"Thanks for the heads up on the migration"
));
assert!(!is_hallucination(
"Please subscribe to the RSS feed and tell me when it updates"
));
}
#[test]

View File

@@ -168,7 +168,10 @@ mod tests {
];
for (input, expected) in cases {
let out = to_plain_text(&[seg(input)]);
assert_eq!(out, expected, "input {input:?} should strip to {expected:?}");
assert_eq!(
out, expected,
"input {input:?} should strip to {expected:?}"
);
}
}

View File

@@ -42,9 +42,8 @@ impl WavWriter {
};
let file = std::fs::File::create(path).map_err(KonError::Io)?;
let buffered = BufWriter::new(file);
let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
KonError::Io(std::io::Error::other(format!("WAV create failed: {e}")))
})?;
let inner = hound::WavWriter::new(buffered, spec)
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
Ok(Self {
inner,
samples_since_flush: 0,
@@ -77,9 +76,9 @@ impl WavWriter {
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
/// boundaries (end-of-utterance, UI events) for tighter recovery.
pub fn flush(&mut self) -> Result<()> {
self.inner.flush().map_err(|e| {
KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}")))
})?;
self.inner
.flush()
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))))?;
self.samples_since_flush = 0;
Ok(())
}
@@ -244,8 +243,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
// Write 100 samples (200 bytes at 16-bit).
let original =
AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
let original = AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
write_wav(&path, &original).unwrap();
// Drop the last 10 bytes — 5 samples' worth. hound's iterator

View File

@@ -1,29 +1,77 @@
/// Store an API key in the OS keychain.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
/// Store an API key in Kon's process-local keystore.
///
/// Stub implementation using environment variables until the `keyring` crate is
/// added. Keys are only held in-process and lost on exit.
/// Keys are held in memory for the lifetime of the process and are lost on
/// exit. This avoids the undefined behaviour of mutating process environment
/// variables from arbitrary threads while keeping the public API safe.
///
/// # Safety note
/// `std::env::set_var` is deprecated in Rust 2024 edition and is **not**
/// thread-safe — mutating the environment while other threads read it is
/// undefined behaviour. This is acceptable during single-threaded app init
/// but must not be called from async/multi-threaded contexts.
/// `retrieve_api_key` still falls back to `KON_API_KEY_<PROVIDER>` environment
/// variables so externally injected secrets continue to work.
///
/// TODO: Replace with the `keyring` crate (or platform-native credential
/// storage) so keys persist across sessions and are accessed safely.
#[allow(deprecated)] // set_var deprecated in Rust 2024 edition
pub fn store_api_key(provider: &str, key: &str) {
// SAFETY: Only safe when called from a single-threaded context (e.g. app
// initialisation). See doc comment above.
std::env::set_var(format!("KON_API_KEY_{}", provider.to_uppercase()), key);
api_key_store()
.lock()
.unwrap()
.insert(provider_env_key(provider), key.to_string());
}
/// Retrieve an API key from the OS keychain.
/// Retrieve an API key from Kon's process-local keystore.
///
/// Stub implementation using environment variables until the `keyring` crate is
/// added. Returns `None` if no key has been stored this session.
///
/// TODO: Replace with the `keyring` crate alongside `store_api_key`.
/// Returns a previously stored in-memory key when present, otherwise falls
/// back to the read-only `KON_API_KEY_<PROVIDER>` environment variable so
/// operator-supplied secrets still work.
pub fn retrieve_api_key(provider: &str) -> Option<String> {
std::env::var(format!("KON_API_KEY_{}", provider.to_uppercase())).ok()
let env_key = provider_env_key(provider);
api_key_store()
.lock()
.unwrap()
.get(&env_key)
.cloned()
.or_else(|| std::env::var(env_key).ok())
}
fn api_key_store() -> &'static Mutex<HashMap<String, String>> {
static STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn provider_env_key(provider: &str) -> String {
format!("KON_API_KEY_{}", provider.to_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn unique_provider(prefix: &str) -> String {
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
format!("{prefix}_{}", NEXT_ID.fetch_add(1, Ordering::Relaxed))
}
#[test]
fn stored_key_is_retrievable_without_env_mutation() {
let provider = unique_provider("provider");
store_api_key(&provider, "secret-token");
assert_eq!(
retrieve_api_key(&provider),
Some("secret-token".to_string())
);
}
#[test]
fn providers_do_not_overlap() {
let first = unique_provider("first");
let second = unique_provider("second");
store_api_key(&first, "alpha");
store_api_key(&second, "beta");
assert_eq!(retrieve_api_key(&first), Some("alpha".to_string()));
assert_eq!(retrieve_api_key(&second), Some("beta".to_string()));
}
}

View File

@@ -349,10 +349,7 @@ fn is_event_device(path: &Path) -> bool {
/// Return true when the device's reported key set includes the combo's
/// configured trigger key. A device that reports no keys at all (for
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
fn device_supports_combo(
supported: Option<&AttributeSetRef<Key>>,
combo: &HotkeyCombo,
) -> bool {
fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
}

View File

@@ -18,6 +18,8 @@ pub mod prompts;
pub use model_manager::{recommend_tier, LlmModelId, LlmModelInfo};
const DEFAULT_CONTEXT_TOKENS: u32 = 4096;
const MAX_CONTEXT_TOKENS: u32 = 8192;
const CONTEXT_RESERVE_TOKENS: u32 = 64;
const GENERATION_SEED: u32 = 0;
#[derive(Debug, thiserror::Error)]
@@ -26,6 +28,15 @@ pub enum EngineError {
NotLoaded,
#[error("LLM load failed: {0}")]
LoadFailed(String),
#[error(
"prompt too long: {prompt_tokens} prompt tokens exceed the {available_prompt_tokens}-token prompt budget for an {context_window}-token context with {max_tokens} reserved response tokens"
)]
PromptTooLong {
prompt_tokens: usize,
max_tokens: u32,
available_prompt_tokens: u32,
context_window: u32,
},
#[error("inference failed: {0}")]
Inference(String),
#[error("model output not valid JSON: {0}")]
@@ -149,7 +160,7 @@ impl LlmEngine {
return Ok(String::new());
}
let n_ctx = context_window_size(prompt_tokens.len(), config.max_tokens);
let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?;
let thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4);
let ctx_params = LlamaContextParams::default()
.with_n_ctx(Some(
@@ -317,8 +328,26 @@ impl LlmEngine {
fn context_window_size(prompt_tokens: usize, max_tokens: u32) -> u32 {
let required = prompt_tokens
.saturating_add(max_tokens as usize)
.saturating_add(64);
DEFAULT_CONTEXT_TOKENS.max(required.min(8192) as u32)
.saturating_add(CONTEXT_RESERVE_TOKENS as usize);
DEFAULT_CONTEXT_TOKENS.max(required.min(MAX_CONTEXT_TOKENS as usize) as u32)
}
fn preflight_context_window(prompt_tokens: usize, max_tokens: u32) -> Result<u32, EngineError> {
let required = prompt_tokens
.saturating_add(max_tokens as usize)
.saturating_add(CONTEXT_RESERVE_TOKENS as usize);
if required > MAX_CONTEXT_TOKENS as usize {
let available_prompt_tokens =
MAX_CONTEXT_TOKENS.saturating_sub(max_tokens.saturating_add(CONTEXT_RESERVE_TOKENS));
return Err(EngineError::PromptTooLong {
prompt_tokens,
max_tokens,
available_prompt_tokens,
context_window: MAX_CONTEXT_TOKENS,
});
}
Ok(context_window_size(prompt_tokens, max_tokens))
}
fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> {
@@ -417,4 +446,24 @@ mod tests {
let index = first_stop_index(text, &["<|im_end|>".into(), "zzz".into()]);
assert_eq!(index, Some(5));
}
#[test]
fn prompt_preflight_rejects_oversized_prompt_tokens() {
let err = preflight_context_window(7_105, 1_024).unwrap_err();
assert!(matches!(
err,
EngineError::PromptTooLong {
prompt_tokens: 7_105,
max_tokens: 1_024,
available_prompt_tokens: 7_104,
context_window: MAX_CONTEXT_TOKENS,
}
));
}
#[test]
fn prompt_preflight_keeps_prompts_within_budget() {
let n_ctx = preflight_context_window(7_104, 1_024).unwrap();
assert_eq!(n_ctx, MAX_CONTEXT_TOKENS);
}
}

View File

@@ -226,7 +226,9 @@ async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value,
})
.collect();
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
Ok(text_content(
serde_json::to_string_pretty(&summaries).unwrap(),
))
}
async fn get_transcript_tool(pool: &SqlitePool, args: Value) -> Result<Value, JsonRpcError> {
@@ -288,7 +290,9 @@ async fn search_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value
})
.collect();
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
Ok(text_content(
serde_json::to_string_pretty(&summaries).unwrap(),
))
}
async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
@@ -311,7 +315,9 @@ async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
})
.collect();
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
Ok(text_content(
serde_json::to_string_pretty(&summaries).unwrap(),
))
}
fn text_content(text: String) -> Value {
@@ -404,7 +410,10 @@ mod tests {
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
let response = handle_message(&pool, request).await.expect("has response");
let tools = response.result.expect("ok")["tools"].as_array().unwrap().clone();
let tools = response.result.expect("ok")["tools"]
.as_array()
.unwrap()
.clone();
let names: Vec<String> = tools
.iter()
.map(|tool| tool["name"].as_str().unwrap().to_string())
@@ -426,7 +435,9 @@ mod tests {
assert_eq!(resp.jsonrpc, "2.0");
assert_eq!(resp.id, Value::Null);
assert!(resp.result.is_none());
let err = resp.error.expect("parse_error_response must carry an error");
let err = resp
.error
.expect("parse_error_response must carry an error");
assert_eq!(err.code, -32700);
assert!(err.message.contains("Parse error"));
assert!(err.message.contains("expected value"));
@@ -449,7 +460,9 @@ mod tests {
});
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
kon_storage::migrations::run_migrations(&pool).await.unwrap();
kon_storage::migrations::run_migrations(&pool)
.await
.unwrap();
let response = handle_message(&pool, request).await.expect("has response");
assert!(

View File

@@ -7,10 +7,7 @@ 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()
);
eprintln!("[kon-mcp] opening Kon database at {}", db_path.display());
let pool = kon_storage::init(&db_path).await?;
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");

View File

@@ -62,6 +62,13 @@ pub async fn insert_transcript(
pool: &SqlitePool,
params: &InsertTranscriptParams<'_>,
) -> Result<()> {
if !profile_exists(pool, params.profile_id).await? {
return Err(KonError::StorageError(format!(
"Insert transcript failed: unknown profile id '{}'",
params.profile_id
)));
}
sqlx::query(
"INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -731,6 +738,12 @@ pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
"Default profile cannot be deleted".into(),
));
}
let transcript_count = transcript_count_for_profile(pool, id).await?;
if transcript_count > 0 {
return Err(KonError::StorageError(format!(
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
)));
}
sqlx::query("DELETE FROM profiles WHERE id = ?")
.bind(id)
.execute(pool)
@@ -789,6 +802,23 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
Ok(())
}
async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM profiles WHERE id = ? LIMIT 1")
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Profile existence check failed: {e}")))?;
Ok(exists.is_some())
}
async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64> {
sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE profile_id = ?")
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("Profile transcript count failed: {e}")))
}
// --- Error Logging ---
/// Log a structured error to the `error_log` table.
@@ -865,9 +895,14 @@ mod tests {
async fn test_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await
.unwrap();
run_migrations(&pool).await.unwrap();
pool
}
@@ -1047,11 +1082,19 @@ mod tests {
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
.await
.unwrap();
insert_subtask(&pool, "s1", "Final test", "p1").await.unwrap();
insert_subtask(&pool, "s2", "Tag release", "p1").await.unwrap();
insert_subtask(&pool, "s1", "Final test", "p1")
.await
.unwrap();
insert_subtask(&pool, "s2", "Tag release", "p1")
.await
.unwrap();
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
complete_subtask_and_check_parent(&pool, "s1")
.await
.unwrap();
complete_subtask_and_check_parent(&pool, "s2")
.await
.unwrap();
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
assert!(parent.done);
@@ -1353,6 +1396,72 @@ mod tests {
assert_eq!(profiles.len(), 1);
}
#[tokio::test]
async fn insert_transcript_rejects_unknown_profile_id() {
let pool = test_pool().await;
let err = insert_transcript(
&pool,
&InsertTranscriptParams {
id: "bad-profile",
text: "Hello",
source: "microphone",
profile_id: "profile-missing",
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
.expect_err("unknown profile id must be rejected");
let msg = err.to_string();
assert!(msg.contains("unknown profile id"), "got: {msg}");
}
#[tokio::test]
async fn delete_profile_rejects_when_transcripts_reference_it() {
let pool = test_pool().await;
let profile = create_profile(&pool, "Referenced", "").await.unwrap();
insert_transcript(
&pool,
&InsertTranscriptParams {
id: "referenced-transcript",
text: "Hello",
source: "microphone",
profile_id: &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();
let err = delete_profile(&pool, &profile.id)
.await
.expect_err("profile with transcript references must not delete");
let msg = err.to_string();
assert!(msg.contains("reassign transcripts first"), "got: {msg}");
}
#[tokio::test]
async fn list_profile_terms_happy_path() {
let pool = test_pool().await;

View File

@@ -215,6 +215,125 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
ON transcripts(profile_id);
"#,
),
(
9,
"transcript_profile_fk",
r#"
INSERT OR IGNORE INTO profiles (id, name, initial_prompt, created_at)
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', '', datetime('now'));
DROP TRIGGER IF EXISTS transcripts_ai;
DROP TRIGGER IF EXISTS transcripts_ad;
DROP TRIGGER IF EXISTS transcripts_au;
DROP TABLE IF EXISTS transcripts_fts;
DROP INDEX IF EXISTS idx_segments_transcript;
DROP INDEX IF EXISTS idx_transcripts_created;
DROP INDEX IF EXISTS idx_transcripts_profile_id;
ALTER TABLE segments RENAME TO segments_old;
ALTER TABLE transcripts RENAME TO transcripts_old;
CREATE TABLE transcripts (
id TEXT PRIMARY KEY,
text TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'microphone',
title TEXT,
audio_path TEXT,
duration REAL NOT NULL DEFAULT 0.0,
engine TEXT,
model_id TEXT,
inference_ms INTEGER,
sample_rate INTEGER,
audio_channels INTEGER,
format_mode TEXT,
remove_fillers INTEGER NOT NULL DEFAULT 0,
british_english INTEGER NOT NULL DEFAULT 0,
anti_hallucination INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
starred INTEGER NOT NULL DEFAULT 0,
manual_tags TEXT NOT NULL DEFAULT '',
template TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT '',
segments_json TEXT NOT NULL DEFAULT '',
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
REFERENCES profiles(id) ON DELETE RESTRICT
);
CREATE INDEX idx_transcripts_created
ON transcripts(created_at);
CREATE INDEX idx_transcripts_profile_id
ON transcripts(profile_id);
INSERT INTO transcripts (
id, text, source, title, audio_path, duration, engine, model_id,
inference_ms, sample_rate, audio_channels, format_mode,
remove_fillers, british_english, anti_hallucination, created_at,
starred, manual_tags, template, language, segments_json, profile_id
)
SELECT
id, text, source, title, audio_path, duration, engine, model_id,
inference_ms, sample_rate, audio_channels, format_mode,
remove_fillers, british_english, anti_hallucination, created_at,
starred, manual_tags, template, language, segments_json,
CASE
WHEN profile_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM profiles
WHERE id = transcripts_old.profile_id
)
THEN profile_id
ELSE '00000000-0000-0000-0000-000000000001'
END
FROM transcripts_old;
CREATE TABLE segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_segments_transcript
ON segments(transcript_id);
INSERT INTO segments (id, transcript_id, start_time, end_time, text)
SELECT id, transcript_id, start_time, end_time, text
FROM segments_old;
DROP TABLE segments_old;
DROP TABLE transcripts_old;
CREATE VIRTUAL TABLE transcripts_fts USING fts5(
text,
title,
content='transcripts',
content_rowid='rowid',
tokenize='porter unicode61 remove_diacritics 2'
);
CREATE TRIGGER transcripts_ai AFTER INSERT ON transcripts BEGIN
INSERT INTO transcripts_fts(rowid, text, title)
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
END;
CREATE TRIGGER transcripts_ad AFTER DELETE ON transcripts BEGIN
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
END;
CREATE TRIGGER transcripts_au AFTER UPDATE ON transcripts BEGIN
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
INSERT INTO transcripts_fts(rowid, text, title)
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
END;
INSERT INTO transcripts_fts(rowid, text, title)
SELECT rowid, text, COALESCE(title, '')
FROM transcripts;
"#,
),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -281,10 +400,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
/// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split
/// out into its own non-transactional migration — reviewer's job to
/// flag.
async fn run_migrations_slice(
pool: &SqlitePool,
migrations: &[(i64, &str, &str)],
) -> Result<()> {
async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)]) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
@@ -344,12 +460,22 @@ mod tests {
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::Row;
#[tokio::test]
async fn test_migrations_run_on_empty_db() {
async fn fk_test_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
.expect("pool");
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await
.expect("enable foreign keys");
pool
}
#[tokio::test]
async fn test_migrations_run_on_empty_db() {
let pool = fk_test_pool().await;
run_migrations(&pool).await.unwrap();
@@ -357,7 +483,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 8);
assert_eq!(count, 9);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -367,10 +493,7 @@ mod tests {
#[tokio::test]
async fn test_migrations_idempotent() {
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
let pool = fk_test_pool().await;
run_migrations(&pool).await.unwrap();
run_migrations(&pool).await.unwrap();
@@ -379,7 +502,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 8);
assert_eq!(count, 9);
}
#[tokio::test]
@@ -440,11 +563,7 @@ mod tests {
#[tokio::test]
async fn migration_transcript_profile_provenance_adds_profile_id() {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let pool = fk_test_pool().await;
run_migrations(&pool).await.expect("migrate");
let info = sqlx::query("PRAGMA table_info(transcripts)")
@@ -459,11 +578,108 @@ mod tests {
}
#[tokio::test]
async fn test_parent_task_id_cascade_delete() {
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
async fn migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk() {
let pool = fk_test_pool().await;
run_migrations_up_to(&pool, 8).await.expect("migrate to v8");
sqlx::query(
"INSERT INTO profiles (id, name, initial_prompt, created_at)
VALUES ('profile-valid', 'Valid', '', datetime('now'))",
)
.execute(&pool)
.await
.expect("seed valid profile");
sqlx::query(
"INSERT INTO transcripts (
id, text, source, title, audio_path, duration, engine, model_id,
inference_ms, sample_rate, audio_channels, format_mode,
remove_fillers, british_english, anti_hallucination, created_at,
starred, manual_tags, template, language, segments_json, profile_id
) VALUES (
't-orphan', 'orphan body', 'microphone', 'Orphan', NULL, 0.0, NULL, NULL,
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
0, '', '', '', '', 'profile-missing'
)",
)
.execute(&pool)
.await
.expect("seed orphan transcript");
sqlx::query(
"INSERT INTO transcripts (
id, text, source, title, audio_path, duration, engine, model_id,
inference_ms, sample_rate, audio_channels, format_mode,
remove_fillers, british_english, anti_hallucination, created_at,
starred, manual_tags, template, language, segments_json, profile_id
) VALUES (
't-valid', 'valid body', 'microphone', 'Valid', NULL, 0.0, NULL, NULL,
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
0, '', '', '', '', 'profile-valid'
)",
)
.execute(&pool)
.await
.expect("seed valid transcript");
sqlx::query(
"INSERT INTO segments (transcript_id, start_time, end_time, text)
VALUES ('t-orphan', 0.0, 1.0, 'segment')",
)
.execute(&pool)
.await
.expect("seed segment");
run_migrations(&pool).await.expect("migrate to v9");
let orphan_profile: String =
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-orphan'")
.fetch_one(&pool)
.await
.expect("read healed orphan");
assert_eq!(orphan_profile, crate::DEFAULT_PROFILE_ID);
let valid_profile: String =
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-valid'")
.fetch_one(&pool)
.await
.expect("read preserved profile");
assert_eq!(valid_profile, "profile-valid");
let segment_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM segments WHERE transcript_id = 't-orphan'")
.fetch_one(&pool)
.await
.expect("read migrated segments");
assert_eq!(segment_count, 1, "segments must survive transcript rebuild");
let fk_rows = sqlx::query("PRAGMA foreign_key_list(transcripts)")
.fetch_all(&pool)
.await
.unwrap();
.expect("read transcript foreign keys");
assert!(
fk_rows.iter().any(|row| {
row.get::<String, _>("table") == "profiles"
&& row.get::<String, _>("from") == "profile_id"
&& row.get::<String, _>("to") == "id"
&& row.get::<String, _>("on_delete") == "RESTRICT"
}),
"transcripts.profile_id must reference profiles(id) with ON DELETE RESTRICT"
);
let fts_hits: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM transcripts_fts WHERE transcripts_fts MATCH 'orphan'",
)
.fetch_one(&pool)
.await
.expect("query rebuilt fts");
assert_eq!(
fts_hits, 1,
"fts index must be rebuilt for existing transcripts"
);
}
#[tokio::test]
async fn test_parent_task_id_cascade_delete() {
let pool = fk_test_pool().await;
run_migrations(&pool).await.unwrap();
@@ -656,7 +872,7 @@ mod tests {
run_migrations(&pool).await.expect("baseline migrate");
const POISON: &[(i64, &str, &str)] = &[(
9,
10,
"rb-02 atomicity poison",
r#"
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
@@ -680,15 +896,14 @@ mod tests {
"poison_marker must not exist; got: {marker:?}"
);
// `schema_version` must not include v9 — version insert is part
// `schema_version` must not include v10 — version insert is part
// of the same transaction that rolled back.
let max: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(&pool)
.await
.expect("read schema_version");
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(&pool)
.await
.expect("read schema_version");
assert_eq!(
max, 8,
max, 9,
"schema_version must not advance past the failed migration"
);
}

View File

@@ -7,13 +7,13 @@ pub mod transcriber;
pub mod whisper_rs_backend;
pub use concurrency::run_inference;
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
#[cfg(feature = "whisper")]
pub use local_engine::load_whisper;
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
pub use streaming::{
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
};
pub use transcriber::{Transcriber, TranscriberCapabilities};
pub use transcribe_rs::SpeechModel;
pub use transcriber::{Transcriber, TranscriberCapabilities};

View File

@@ -557,7 +557,10 @@ mod tests {
.expect_err("mismatched sha must fail");
let msg = err.to_string();
assert!(msg.contains("SHA256 mismatch"), "unexpected error: {msg}");
assert!(!dest.exists(), ".part → dest rename must not run on mismatch");
assert!(
!dest.exists(),
".part → dest rename must not run on mismatch"
);
let part = dest.with_extension("bin.part");
assert!(!part.exists(), "failed hash must clean up the .part file");
}

View File

@@ -108,11 +108,7 @@ impl LocalAgreement {
// Can't commit anything until we have n passes in hand.
if self.history.len() < self.n {
let tentative = self
.history
.back()
.cloned()
.unwrap_or_default();
let tentative = self.history.back().cloned().unwrap_or_default();
return CommitDecision {
newly_committed: Vec::new(),
tentative,

View File

@@ -121,7 +121,7 @@ impl RmsVadChunker {
pending_onset_frames: 0,
onset_buffer: Vec::new(),
next_sample_index: 0,
active_chunk_start: 0,
active_chunk_start: 0,
}
}
@@ -552,10 +552,7 @@ mod tests {
let mut c = RmsVadChunker::new();
assert!(c.flush().is_empty());
let _ = c.push(&constant_signal(16_000, 0.0));
assert!(
c.flush().is_empty(),
"flushing pure silence emits nothing"
);
assert!(c.flush().is_empty(), "flushing pure silence emits nothing");
}
#[test]

View File

@@ -62,10 +62,9 @@ impl Transcriber for WhisperRsBackend {
"WhisperRsBackend::transcribe_sync entering"
);
let mut state = self
.ctx
.create_state()
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string()))?;
let mut state = self.ctx.create_state().map_err(|e| {
KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
})?;
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
if let Some(lang) = options.language.as_deref() {
@@ -83,9 +82,11 @@ impl Transcriber for WhisperRsBackend {
params.set_print_progress(false);
params.set_print_realtime(false);
state
.full(params, samples)
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?;
state.full(params, samples).map_err(|e| {
KonError::TranscriptionFailed(
WhisperBackendError::Transcribe(e.to_string()).to_string(),
)
})?;
let n = state.full_n_segments();
@@ -96,7 +97,11 @@ impl Transcriber for WhisperRsBackend {
};
let text = seg
.to_str()
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?
.map_err(|e| {
KonError::TranscriptionFailed(
WhisperBackendError::Transcribe(e.to_string()).to_string(),
)
})?
.to_string();
// whisper-rs timestamps are centiseconds (10ms units). Convert to seconds (f64).
let start = seg.start_timestamp() as f64 * 0.01;

View File

@@ -11,43 +11,36 @@ Issues here must land before Kon v0.1 ships. Each is sourced from
`docs/code-review-2026-04-22.md`. When `gh` CLI is available, these
should be mirrored as real GitHub issues on `jakejars/kon`.
## CRITICAL (2 open, 1 resolved)
## CRITICAL (0 open, 3 resolved)
No open CRITICAL blockers.
## MAJOR (1 open, 8 resolved)
| # | File | Area | Fix scope |
|---|---|---|---|
| RB-01 | [c1-live-session-race.md](c1-live-session-race.md) | `src-tauri/commands/live.rs` | large |
| RB-03 | [c4-transcript-profile-fk.md](c4-transcript-profile-fk.md) | `crates/storage/migrations.rs` + `database.rs` | large |
## MAJOR (5 open, 4 resolved)
| # | File | Area | Fix scope |
|---|---|---|---|
| RB-04 | [run-live-session-monolith.md](run-live-session-monolith.md) | `src-tauri/commands/live.rs` | large |
| RB-05 | [poll-inference-channel-fatality.md](poll-inference-channel-fatality.md) | `src-tauri/commands/live.rs` | medium |
| RB-08 | [power-assertion-macos-objc2.md](power-assertion-macos-objc2.md) | `src-tauri/commands/power.rs` | medium |
| RB-10 | [llm-prompt-preflight.md](llm-prompt-preflight.md) | `crates/llm/lib.rs` | medium |
| RB-11 | [keystore-thread-safety.md](keystore-thread-safety.md) | `crates/cloud-providers/keystore.rs` | medium |
## Resolved
| # | File | Area | Resolution |
|---|---|---|---|
| RB-01 | [c1-live-session-race.md](c1-live-session-race.md) | `src-tauri/commands/live.rs` | Added `LiveTranscriptionState.lifecycle: tokio::sync::Mutex<()>` and hold it across the async spans of both `start_live_transcription_session` and `stop_live_transcription_session`. The running-slot check/insert and stop/take/join sequence are now serialized, so concurrent starts can no longer both pass the empty-slot check and a start during stop blocks until the previous worker fully joins. Two async regression tests cover both races. |
| RB-02 | [c3-migrations-atomicity.md](c3-migrations-atomicity.md) | `crates/storage/migrations.rs` | Each migration now runs inside a `pool.begin()` / `tx.commit()` transaction alongside its `schema_version` insert. Regression test injects a poisoned v9 migration and asserts neither the partial schema change nor the version row persists. DRY'd `run_migrations_up_to` test helper onto the same code path. |
| RB-03 | [c4-transcript-profile-fk.md](c4-transcript-profile-fk.md) | `crates/storage/migrations.rs` + `database.rs` | Added a transactional v9 rebuild of `transcripts` that enforces `profile_id REFERENCES profiles(id) ON DELETE RESTRICT`, reassigns any orphaned transcript provenance to `DEFAULT_PROFILE_ID`, rebuilds dependent `segments` / FTS state, and preserves valid profile references. `insert_transcript` now rejects unknown profile ids up front, and `delete_profile` returns a clear reassign-first error when transcripts still reference the profile. Regression tests cover migration reconciliation, invalid inserts, and delete rejection. |
| RB-04 | [run-live-session-monolith.md](run-live-session-monolith.md) | `src-tauri/commands/live.rs` | Replaced the 200+ line `run_live_session` loop with an explicit `LiveSessionRuntime` + `LiveLoopState` structure. Capture startup, runtime mic-error draining, audio chunk processing, overflow handling, stop-tail flush, inference dispatch/drain, and WAV finalisation each live in focused helpers, preserving behaviour while making the lifecycle auditable enough for RB-01 follow-up. Existing live tests and the full `kon` lib suite stay green. |
| RB-05 | [poll-inference-channel-fatality.md](poll-inference-channel-fatality.md) | `src-tauri/commands/live.rs` | `poll_inference` now treats result-channel loss as a listener-lifecycle problem rather than a transcription failure. On the first `result_channel.send(...)` error it marks the live result listener as lost, emits a single warning that transcription will continue in the background, and keeps processing later chunks without retrying the dead channel. Regression test simulates a dead result listener and asserts chunk processing continues with only one warning. |
| RB-06 | [native-capture-worker-join.md](native-capture-worker-join.md) | `src-tauri/commands/audio.rs` | `NativeCaptureState.stop_tx` replaced by `worker: AsyncMutex<Option<CaptureWorker>>`. `CaptureWorker` bundles the stop sender and the spawned task's `JoinHandle`; `stop_worker(worker)` sends stop then `await`s termination. Both `start_native_capture` (prior-worker stop) and `stop_native_capture` use the helper. Removed the 50ms sleep — the join barrier is exact. Two regression tests cover the lifecycle guarantee and the already-exited case. |
| RB-07 | [runtime-capabilities-accelerators.md](runtime-capabilities-accelerators.md) | `src-tauri/commands/models.rs` | Introduced `compose_accelerators(whisper_enabled, loader_available, target)` as a pure helper; `supported_accelerators()` reads `cfg(feature = "whisper")`, `vulkan_loader_available()`, and target OS then delegates. `get_runtime_capabilities` uses it in place of the hard-coded `["cpu", "vulkan"]`. Whisper's `supports_gpu` now follows the feature flag. Five regression tests cover all permutations. |
| RB-09 | [decoder-partial-audio-on-error.md](decoder-partial-audio-on-error.md) | `crates/audio/decode.rs` | Packet-loop now propagates all non-EOF `SymphoniaError`s as `AudioDecodeFailed`; per-packet decode errors bubble via `?`. Mock-`MediaSource` regression test confirms mid-stream I/O errors surface instead of returning partial audio. |
| RB-10 | [llm-prompt-preflight.md](llm-prompt-preflight.md) | `crates/llm/lib.rs` | Added an explicit prompt-budget preflight before context creation. If `prompt_tokens + max_tokens + reserve` exceeds the 8192-token cap, `generate` now returns a typed `EngineError::PromptTooLong { ... }` instead of failing late inside inference. Regression tests cover both the over-budget and exact-budget boundaries. |
| RB-11 | [keystore-thread-safety.md](keystore-thread-safety.md) | `crates/cloud-providers/keystore.rs` | Replaced the `std::env::set_var` stub with a process-global `OnceLock<Mutex<HashMap<...>>>` keystore, keeping the API safe from any thread. Retrieval still falls back to read-only `KON_API_KEY_*` env vars for externally supplied secrets. Two regression tests cover store/retrieve and provider isolation. |
| RB-12 | [hotkey-linux-device-filter.md](hotkey-linux-device-filter.md) | `crates/hotkey/linux.rs` | Extracted `device_supports_combo` helper; `try_attach_device` now reads the configured `HotkeyCombo` from the watch channel and checks support for that trigger key. Four regression tests land in `linux::tests`. |
## Dependencies
## Remaining blocker
```
RB-01 (live session race)
└── blocked by RB-04 (run_live_session monolith refactor)
RB-03 (transcript-profile FK)
└── coupled with RB-02 (migrations atomicity)
— a v9 migration adding the FK constraint must be transactional
```
`RB-08` remains open pending manual runtime verification on a real macOS
machine (`pmset -g assertions`, background live-session sanity check).
## How to convert to GitHub issues

View File

@@ -4,6 +4,31 @@
**Path:** `src-tauri/src/commands/live.rs:193-338`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c1--racy-single-session-guard-in-livers)
**Labels:** release-blocker, critical, concurrency
**Status:** RESOLVED (2026-04-22)
## Resolution
`LiveTranscriptionState` now includes a dedicated
`tokio::sync::Mutex<()>` lifecycle gate. Both
`start_live_transcription_session` and
`stop_live_transcription_session` acquire that async mutex before
touching `running`, and they keep it held across the awaited setup /
join work that previously exposed the race windows.
That changes the two failing interleavings from the review:
- Two overlapping starts no longer race through the empty-slot check.
The second call waits for the first to finish setup, then observes
`running.is_some()` and returns the existing
`"A live transcription session is already running"` error.
- A start launched during stop can no longer sneak in after
`running.take()` but before the previous worker has fully joined.
It blocks on the lifecycle mutex until the join completes.
Regression tests in `commands::live::tests`:
- `concurrent_starts_allow_only_one_session_to_claim_the_slot`
- `start_waits_for_stop_to_finish_joining_before_reusing_slot`
## Problem
@@ -26,4 +51,4 @@ Large. Will likely require the `run_live_session` monolith refactor (RB-04) to l
## Dependencies
- **Blocked by:** RB-04 (`run_live_session` monolith refactor)
- Landed after RB-04 (`run_live_session` refactor) made the worker lifecycle explicit enough to guard safely.

View File

@@ -4,6 +4,33 @@
**Path:** `crates/storage/src/migrations.rs:208-216`, `crates/storage/src/database.rs:61-89`, `:697-708`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c4--transcript-provenance-can-reference-deleted-profiles)
**Labels:** release-blocker, critical, data-integrity, storage
**Status:** RESOLVED (2026-04-22)
## Resolution
Chose the strict provenance path:
- Migration v9 rebuilds `transcripts` with
`profile_id REFERENCES profiles(id) ON DELETE RESTRICT`.
- Existing orphaned transcript `profile_id` values are reconciled onto
`DEFAULT_PROFILE_ID` during the copy into the rebuilt table.
- Because SQLite table renames rewrite dependent references, the
migration also rebuilds `segments`, recreates the transcript FTS
virtual table/triggers, and repopulates FTS from the rebuilt
transcript rows inside the same transaction.
Application-layer behaviour now matches the schema:
- `insert_transcript` rejects unknown `profile_id` values with a clear
storage error before attempting the insert.
- `delete_profile` returns a human-readable reassign-first error when
transcripts still reference that profile.
Regression tests:
- `migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk`
- `insert_transcript_rejects_unknown_profile_id`
- `delete_profile_rejects_when_transcripts_reference_it`
## Problem

View File

@@ -4,6 +4,28 @@
**Path:** `crates/cloud-providers/src/keystore.rs:6-18`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, unsafe-api, cloud
**Status:** RESOLVED (2026-04-22)
## Resolution
Chose acceptance option 2. The environment-mutation stub is gone;
`store_api_key` now writes into a process-global
`OnceLock<Mutex<HashMap<String, String>>>`, so the safe signature matches
the actual safety properties.
Additional details:
- Stored keys now live in-memory only for the life of the process.
- `retrieve_api_key` checks the in-memory keystore first, then falls
back to read-only `KON_API_KEY_<PROVIDER>` environment variables so
externally injected secrets still work.
- Module docs now describe the real tradeoff clearly: safe from any
thread, but non-persistent until a proper OS keychain backend lands.
Regression tests:
- `stored_key_is_retrievable_without_env_mutation`
- `providers_do_not_overlap`
## Problem

View File

@@ -4,6 +4,25 @@
**Path:** `crates/llm/src/lib.rs:143-166`, `:317-321`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, llm
**Status:** RESOLVED (2026-04-22)
## Resolution
`LlmEngine::generate` still tokenises the whole prompt up front, but it
now runs a dedicated prompt-budget preflight before creating the llama
context. The chosen behaviour is an early typed failure rather than
silent truncation:
- If `prompt_tokens + max_tokens + 64 reserve tokens` exceeds the
8192-token cap, generation returns
`EngineError::PromptTooLong { prompt_tokens, max_tokens, available_prompt_tokens, context_window }`.
- Prompts that fit exactly within the available budget still proceed and
allocate an 8192-token context as before.
Regression tests:
- `prompt_preflight_rejects_oversized_prompt_tokens`
- `prompt_preflight_keeps_prompts_within_budget`
## Problem

View File

@@ -4,6 +4,31 @@
**Path:** `src-tauri/src/commands/live.rs:721-813`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, ipc-lifecycle
**Status:** RESOLVED (2026-04-22)
## Resolution
`poll_inference` no longer propagates `result_channel.send(...)` with `?`.
Instead, live-result delivery is routed through a small helper that
tracks whether the frontend listener has already been lost:
- First send failure: mark the result listener as unavailable, log a
warning, and best-effort send a `LiveStatusMessage::Warning`
explaining that transcription will continue in the background until
the user stops the session.
- Subsequent chunks: skip re-sending to the dead result channel and
keep the worker running.
Crucially, this path is now separate from actual transcription failure:
inference errors still emit `LiveStatusMessage::Error` and stop the
session, while listener-loss just stops live preview delivery.
Regression test:
- `result_listener_loss_is_warned_once_and_not_treated_as_inference_failure`
simulates a dead result channel, confirms the first processed chunk
downgrades to a warning, and confirms a second chunk still processes
successfully without a second warning.
## Problem

View File

@@ -5,6 +5,18 @@
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md), originally deferred during A.1 #9
**Labels:** release-blocker, major, macos, platform
**Current state (2026-04-23):** `objc2`/`objc2-foundation` have been
added behind `cfg(target_os = "macos")`, and the `NSProcessInfo`
bridge now calls `beginActivityWithOptions:reason:` / `endActivity:`
with the retained activity handle. Isolated `cargo check` validation
passes for both `x86_64-apple-darwin` and `aarch64-apple-darwin`.
Remaining acceptance gap: manual runtime verification on a real macOS
machine (`pmset -g assertions`, background live session). Diagnostic
reports now also include a `## Power assertions` section that lists any
currently active Kon assertion guards (`reason`, `backend`, `acquired`)
at report time, which gives the tester an in-app breadcrumb alongside
`pmset`.
## Problem
`begin_activity` always returns `Err` on macOS, so `PowerAssertion::begin` converts to `None` and the guard never acquires an `NSProcessInfo beginActivityWithOptions:reason:` assertion. Live recording and LLM cleanup therefore run without App Nap protection on the one platform where it matters.
@@ -18,6 +30,21 @@ The stub was deliberate (A.1 #9 acceptance concession — untestable on Linux wi
- `end_activity` calls `endActivity:` on the retained handle.
- Manual-test on a real macOS box: 10-minute background live session completes without throttling; `pmset -g assertions` shows Kon's activity during capture.
## Manual verification checklist
1. Launch Kon on a real macOS machine and start a live transcription session.
2. While capture is running, background the app for at least several minutes.
3. In Terminal, run `pmset -g assertions` and confirm Kon appears with a
user-initiated / no-idle-style assertion while the session is active.
4. While the session is still running, generate a Kon diagnostic report
and confirm the `## Power assertions` section lists an active entry
such as `reason=kon live dictation session`, `backend=macos`,
`acquired=true`.
5. Stop the session and rerun `pmset -g assertions` or regenerate the
diagnostic report to confirm the assertion disappears.
6. Repeat once for the LLM cleanup path if desired
(`reason=kon LLM cleanup`).
## Fix scope
Medium. Dep addition + FFI glue + manual verification. Can be done from Linux with `cargo check --target=aarch64-apple-darwin` for compile validation, but runtime behaviour needs a macOS machine.

View File

@@ -4,6 +4,33 @@
**Path:** `src-tauri/src/commands/live.rs:349-579`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, refactor, concurrency
**Status:** RESOLVED (2026-04-22)
## Resolution
`run_live_session` was split around two explicit state holders:
- `ActiveCapture` owns the cpal stream handle, audio receiver, and
optional runtime-error channel.
- `LiveLoopState` owns the mutable per-session loop state: resampler,
capture buffer, WAV writer, buffer offsets, dropped-audio accounting,
in-flight inference task, and duplicate-history buffer.
The top-level worker is now `LiveSessionRuntime`, with focused methods
for:
- polling in-flight inference
- draining microphone runtime warnings
- receiving + resampling an audio chunk
- dropping pending-buffer overflow
- flushing the resampler tail when stop is requested
- dispatching inference when enough audio is buffered
- draining the last in-flight task
- finalising the progressive WAV writer
This keeps behaviour intact but removes the "everything in one mutable
loop" shape that made concurrency review hard. The refactor also made
RB-01 straightforward enough to land immediately afterward.
## Problem

View File

@@ -74,3 +74,7 @@ webkit2gtk = "2.0"
# transitively depends on (GTK 3).
gtk = "0.18"
gdk = "0.18"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] }

View File

@@ -33,8 +33,8 @@ fn assert_localhost_llm_csp() {
let raw = std::fs::read_to_string(conf_path)
.expect("build.rs: failed to read tauri.conf.json for CSP regression guard");
let conf: serde_json::Value = serde_json::from_str(&raw)
.expect("build.rs: tauri.conf.json is not valid JSON");
let conf: serde_json::Value =
serde_json::from_str(&raw).expect("build.rs: tauri.conf.json is not valid JSON");
let csp = conf
.pointer("/app/security/csp")

File diff suppressed because one or more lines are too long

View File

@@ -2313,22 +2313,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2349,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",

View File

@@ -2313,22 +2313,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2349,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",

View File

@@ -308,8 +308,7 @@ fn recording_filename() -> String {
/// restarts, so cross-launch collisions are already impossible — the
/// counter is the last-mile guarantee against within-launch same-tick
/// collisions.
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(test)]
mod tests {
@@ -353,10 +352,20 @@ mod tests {
3,
"expected three '-' separated parts, got {parts:?}"
);
assert!(parts[0].chars().all(|c| c.is_ascii_digit()), "secs is digits");
assert_eq!(parts[1].len(), 9, "nanos component is zero-padded to 9 digits");
assert!(
parts[0].chars().all(|c| c.is_ascii_digit()),
"secs is digits"
);
assert_eq!(
parts[1].len(),
9,
"nanos component is zero-padded to 9 digits"
);
assert!(parts[1].chars().all(|c| c.is_ascii_digit()));
assert!(parts[2].len() >= 4, "counter component is zero-padded to >=4 digits");
assert!(
parts[2].len() >= 4,
"counter component is zero-padded to >=4 digits"
);
assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
}

View File

@@ -19,6 +19,7 @@ use kon_storage::{
app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow,
};
use crate::commands::power::active_assertions_snapshot;
use crate::AppState;
const DEFAULT_RECENT_ERRORS: i64 = 50;
@@ -290,6 +291,20 @@ pub async fn generate_diagnostic_report(
}
}
out.push_str("## Power assertions\n\n");
let power_assertions = active_assertions_snapshot();
if power_assertions.is_empty() {
out.push_str("_(no active power assertions at report time)_\n\n");
} else {
for assertion in power_assertions {
out.push_str(&format!(
"- `#{}` reason=`{}` backend=`{}` acquired=`{}`\n",
assertion.id, assertion.reason, assertion.backend, assertion.acquired
));
}
out.push('\n');
}
if opts.include_crashes {
out.push_str("## Crash dumps\n\n");
let crashes = list_crash_files().await.unwrap_or_default();

View File

@@ -11,6 +11,7 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::ipc::Channel;
use tokio::sync::Mutex as AsyncMutex;
use crate::commands::audio::resolve_recording_path;
use crate::commands::build_initial_prompt;
@@ -18,7 +19,9 @@ use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
use crate::commands::power::PowerAssertion;
use crate::AppState;
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use kon_audio::{MicrophoneCapture, StreamingResampler, WavWriter};
use kon_audio::{
AudioChunk, CaptureRuntimeError, MicrophoneCapture, StreamingResampler, WavWriter,
};
use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::types::{AudioSamples, Segment, TranscriptionOptions};
use kon_transcription::LocalEngine;
@@ -57,6 +60,7 @@ const LOW_SIGNAL_TOKENS: &[&str] = &[
#[derive(Default)]
pub struct LiveTranscriptionState {
next_session_id: AtomicU64,
lifecycle: AsyncMutex<()>,
running: Mutex<Option<RunningLiveSession>>,
}
@@ -153,6 +157,292 @@ struct LiveSessionSummary {
audio_path: Option<String>,
}
/// Session worker state is thread-confined to the single blocking task spawned
/// by `start_live_transcription_session`. Cross-thread coordination happens via
/// the stop flag and mpsc channels only; RB-01 will tighten the outer
/// `live_state.running` lock discipline now that this worker lifecycle is
/// explicit and locally structured.
struct ActiveCapture {
/// Keeping the capture handle alive keeps the underlying cpal stream alive.
_capture: MicrophoneCapture,
rx: std::sync::mpsc::Receiver<AudioChunk>,
mic_error_rx: Option<std::sync::mpsc::Receiver<CaptureRuntimeError>>,
}
impl ActiveCapture {
fn start(config: &StartLiveTranscriptionConfig) -> Result<Self, String> {
let (mut capture, rx) = match config.microphone_device.as_deref() {
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
_ => MicrophoneCapture::start(),
}
.map_err(|e| e.to_string())?;
let mic_error_rx = capture.take_error_rx();
Ok(Self {
_capture: capture,
rx,
mic_error_rx,
})
}
fn drain_runtime_errors(
&mut self,
session_id: u64,
status_channel: &Channel<LiveStatusMessage>,
) {
let Some(err_rx) = &self.mic_error_rx else {
return;
};
while let Ok(err) = err_rx.try_recv() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Microphone '{}' reported an error: {}",
err.device_name, err.message
),
});
}
}
}
#[derive(Default)]
struct LiveLoopState {
resampler: Option<StreamingResampler>,
capture_buffer: Vec<f32>,
wav_writer: Option<WavWriter>,
buffer_start_sample: u64,
dropped_audio_ms: u64,
chunk_id: u32,
inflight: Option<InferenceTask>,
resampler_flushed: bool,
result_listener_lost: bool,
recent_segments: Vec<RecentTranscriptSegment>,
}
impl LiveLoopState {
fn new(wav_writer: Option<WavWriter>) -> Self {
Self {
wav_writer,
..Self::default()
}
}
}
struct LiveSessionRuntime {
session_id: u64,
engine: Arc<LocalEngine>,
config: StartLiveTranscriptionConfig,
audio_path: Option<PathBuf>,
dictionary_terms: Vec<String>,
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
stop_flag: Arc<AtomicBool>,
capture: ActiveCapture,
state: LiveLoopState,
}
impl LiveSessionRuntime {
fn new(
session_id: u64,
engine: Arc<LocalEngine>,
config: StartLiveTranscriptionConfig,
audio_path: Option<PathBuf>,
dictionary_terms: Vec<String>,
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
stop_flag: Arc<AtomicBool>,
) -> Result<Self, String> {
let capture = ActiveCapture::start(&config)?;
let wav_writer = open_wav_writer(&engine, audio_path.as_ref(), session_id, &status_channel);
Ok(Self {
session_id,
engine,
config,
audio_path,
dictionary_terms,
result_channel,
status_channel,
stop_flag,
capture,
state: LiveLoopState::new(wav_writer),
})
}
fn run(mut self) -> Result<LiveSessionSummary, String> {
loop {
self.poll_inference()?;
self.capture
.drain_runtime_errors(self.session_id, &self.status_channel);
if let Some(chunk) = self.recv_audio()? {
self.process_audio_chunk(chunk)?;
}
self.drop_pending_overflow();
self.flush_tail_if_stopping()?;
if self.dispatch_inference_if_ready() {
continue;
}
if self.should_exit_loop() {
break;
}
}
self.drain_inference()?;
self.finish()
}
fn poll_inference(&mut self) -> Result<(), String> {
let _ = poll_inference(
&mut self.state.inflight,
&mut self.state.result_listener_lost,
self.session_id,
&self.config,
&mut self.state.recent_segments,
&self.dictionary_terms,
&self.result_channel,
&self.status_channel,
)?;
Ok(())
}
fn recv_audio(&mut self) -> Result<Option<AudioChunk>, String> {
match self.capture.rx.recv_timeout(Duration::from_millis(25)) {
Ok(chunk) => Ok(Some(chunk)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok(None),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let message = "Microphone capture disconnected unexpectedly".to_string();
let _ = self.status_channel.send(LiveStatusMessage::Error {
session_id: self.session_id,
message: message.clone(),
});
Err(message)
}
}
}
fn process_audio_chunk(&mut self, chunk: AudioChunk) -> Result<(), String> {
let mono = downmix_chunk(chunk.samples, chunk.channels as usize);
let resampler = match &mut self.state.resampler {
Some(resampler) => resampler,
None => {
self.state.resampler =
Some(StreamingResampler::new(chunk.sample_rate).map_err(|e| e.to_string())?);
self.state.resampler.as_mut().expect("resampler just set")
}
};
let resampled = resampler.push_samples(&mono).map_err(|e| e.to_string())?;
append_resampled_audio(
&mut self.state.capture_buffer,
&mut self.state.wav_writer,
&resampled,
self.session_id,
&self.status_channel,
);
Ok(())
}
fn drop_pending_overflow(&mut self) {
if self.state.inflight.is_none() || self.state.capture_buffer.len() <= MAX_PENDING_SAMPLES {
return;
}
let overflow = self.state.capture_buffer.len() - MAX_PENDING_SAMPLES;
self.state.capture_buffer.drain(..overflow);
self.state.buffer_start_sample = self
.state
.buffer_start_sample
.saturating_add(overflow as u64);
self.state.dropped_audio_ms = self
.state
.dropped_audio_ms
.saturating_add((overflow as u64 * 1000) / WHISPER_SAMPLE_RATE as u64);
let _ = self.status_channel.send(LiveStatusMessage::Overload {
session_id: self.session_id,
dropped_audio_ms: self.state.dropped_audio_ms,
message: "Kon dropped older audio to keep live dictation responsive".into(),
});
}
fn flush_tail_if_stopping(&mut self) -> Result<(), String> {
if !self.stopping() || self.state.resampler_flushed {
return Ok(());
}
if let Some(resampler) = &mut self.state.resampler {
let tail = resampler.flush().map_err(|e| e.to_string())?;
append_resampled_audio(
&mut self.state.capture_buffer,
&mut self.state.wav_writer,
&tail,
self.session_id,
&self.status_channel,
);
}
self.flush_wav_header();
self.state.resampler_flushed = true;
Ok(())
}
fn flush_wav_header(&mut self) {
let Some(writer) = self.state.wav_writer.as_mut() else {
return;
};
if let Err(e) = writer.flush() {
let _ = self.status_channel.send(LiveStatusMessage::Warning {
session_id: self.session_id,
message: format!("WAV flush failed near session end: {e}"),
});
}
}
fn dispatch_inference_if_ready(&mut self) -> bool {
if self.state.inflight.is_some() {
return false;
}
let stopping = self.stopping();
if let Some(task) = maybe_dispatch_chunk(
&self.engine,
&self.config,
&mut self.state.capture_buffer,
&mut self.state.buffer_start_sample,
&mut self.state.chunk_id,
stopping,
&self.status_channel,
self.session_id,
) {
self.state.inflight = Some(task);
return true;
}
false
}
fn stopping(&self) -> bool {
self.stop_flag.load(Ordering::Relaxed)
}
fn should_exit_loop(&self) -> bool {
self.stopping() && self.state.resampler_flushed && self.state.inflight.is_none()
}
fn drain_inference(&mut self) -> Result<(), String> {
while self.state.inflight.is_some() {
self.poll_inference()?;
thread::sleep(Duration::from_millis(10));
}
Ok(())
}
fn finish(mut self) -> Result<LiveSessionSummary, String> {
let audio_path = finalize_wav_writer(
self.state.wav_writer.take(),
self.audio_path.as_ref(),
self.session_id,
&self.status_channel,
);
Ok(LiveSessionSummary {
session_id: self.session_id,
dropped_audio_ms: self.state.dropped_audio_ms,
audio_path,
})
}
}
struct InferenceTask {
chunk_id: u32,
chunk_start_sample: u64,
@@ -198,6 +488,7 @@ pub async fn start_live_transcription_session(
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
) -> Result<StartLiveTranscriptionResponse, String> {
let _lifecycle = live_state.lifecycle.lock().await;
{
let running = live_state.running.lock().unwrap();
if running.is_some() {
@@ -227,11 +518,8 @@ pub async fn start_live_transcription_session(
// `TranscriptionOptions` construction (see `maybe_dispatch_chunk`) picks
// up profile fallback + vocabulary injection without further plumbing.
let request_prompt = config.initial_prompt.clone().unwrap_or_default();
config.initial_prompt = build_initial_prompt(
&request_prompt,
&profile.initial_prompt,
&profile_terms,
);
config.initial_prompt =
build_initial_prompt(&request_prompt, &profile.initial_prompt, &profile_terms);
let model_id = config
.model_id
@@ -258,7 +546,10 @@ pub async fn start_live_transcription_session(
// for save_audio=true and silently dropping the recording would
// surprise them worse.
let audio_path = if config.save_audio {
Some(resolve_recording_path(&app, config.output_folder.as_deref())?)
Some(resolve_recording_path(
&app,
config.output_folder.as_deref(),
)?)
} else {
None
};
@@ -299,6 +590,7 @@ pub async fn stop_live_transcription_session(
live_state: tauri::State<'_, LiveTranscriptionState>,
session_id: u64,
) -> Result<StopLiveTranscriptionResponse, String> {
let _lifecycle = live_state.lifecycle.lock().await;
let running = live_state.running.lock().unwrap().take();
let Some(running) = running else {
return Err("No live transcription session is running".into());
@@ -360,203 +652,54 @@ fn run_live_session(
// lifetime to the session — when the function returns, the Drop
// impl lifts it. Item #9 in docs/whisper-ecosystem/brief.md.
let _power_guard = PowerAssertion::begin("kon live dictation session");
LiveSessionRuntime::new(
session_id,
engine,
config,
audio_path,
dictionary_terms,
result_channel,
status_channel,
stop_flag,
)?
.run()
}
let (mut capture, rx) = match config.microphone_device.as_deref() {
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
_ => MicrophoneCapture::start(),
}
.map_err(|e| e.to_string())?;
// Drain runtime stream errors into the status channel so the user
// gets a toast when the device disconnects mid-recording instead of
// silently producing empty transcripts. The `_capture` binding keeps
// the cpal stream alive for the duration of the session.
let mic_error_rx = capture.take_error_rx();
let _capture = capture;
let mut resampler: Option<StreamingResampler> = None;
let mut capture_buffer: Vec<f32> = Vec::new();
// Progressive WAV writer (brief item #19). Sample rate comes from
// the loaded backend's capabilities (#13 wiring) so a future
// non-16kHz backend records at its native rate without further
// plumbing. The writer flushes its header every ~500 ms, so the
// file on disk is a playable WAV even if the process is killed.
fn open_wav_writer(
engine: &Arc<LocalEngine>,
audio_path: Option<&PathBuf>,
session_id: u64,
status_channel: &Channel<LiveStatusMessage>,
) -> Option<WavWriter> {
let sample_rate = engine
.capabilities()
.map(|c| c.sample_rate)
.unwrap_or(WHISPER_SAMPLE_RATE);
let mut wav_writer: Option<WavWriter> = match audio_path.as_ref() {
Some(path) => match WavWriter::create(path, sample_rate, 1) {
Ok(w) => Some(w),
Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Failed to open audio recording file ({}); transcription will continue without saving audio.",
e
),
});
None
}
},
None => None,
};
// `reported_audio_path` is decided at end-of-session based on
// whether the writer finalised successfully, not at open time.
// This way a writer that dies mid-session (append error clearing
// wav_writer, or finalise returning Err) does not leak a stale
// path back to the frontend that might point to a file whose
// header is out of sync with its data chunk.
let mut buffer_start_sample: u64 = 0;
let mut dropped_audio_ms: u64 = 0;
let mut chunk_id: u32 = 0;
let mut inflight: Option<InferenceTask> = None;
let mut resampler_flushed = false;
let mut recent_segments: Vec<RecentTranscriptSegment> = Vec::new();
loop {
if let Some(_done) = poll_inference(
&mut inflight,
session_id,
&config,
&mut recent_segments,
&dictionary_terms,
&result_channel,
&status_channel,
)? {}
// Surface any cpal runtime errors as warnings. Non-fatal: a hard
// disconnect will also drop the audio sender and be caught by
// the `Disconnected` arm below. This lets the user see a toast
// even when cpal recovers without tearing the stream down.
if let Some(err_rx) = &mic_error_rx {
while let Ok(err) = err_rx.try_recv() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Microphone '{}' reported an error: {}",
err.device_name, err.message
),
});
}
}
match rx.recv_timeout(Duration::from_millis(25)) {
Ok(chunk) => {
let mono = downmix_chunk(chunk.samples, chunk.channels as usize);
let resampler = match &mut resampler {
Some(resampler) => resampler,
None => {
resampler = Some(
StreamingResampler::new(chunk.sample_rate)
.map_err(|e| e.to_string())?,
);
resampler.as_mut().expect("resampler just set")
}
};
let resampled = resampler.push_samples(&mono).map_err(|e| e.to_string())?;
append_resampled_audio(
&mut capture_buffer,
&mut wav_writer,
&resampled,
session_id,
&status_channel,
);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let message = "Microphone capture disconnected unexpectedly".to_string();
let _ = status_channel.send(LiveStatusMessage::Error {
session_id,
message: message.clone(),
});
return Err(message);
}
}
if inflight.is_some() && capture_buffer.len() > MAX_PENDING_SAMPLES {
let overflow = capture_buffer.len() - MAX_PENDING_SAMPLES;
capture_buffer.drain(..overflow);
buffer_start_sample = buffer_start_sample.saturating_add(overflow as u64);
dropped_audio_ms = dropped_audio_ms
.saturating_add((overflow as u64 * 1000) / WHISPER_SAMPLE_RATE as u64);
let _ = status_channel.send(LiveStatusMessage::Overload {
let path = audio_path?;
match WavWriter::create(path, sample_rate, 1) {
Ok(writer) => Some(writer),
Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
dropped_audio_ms,
message: "Kon dropped older audio to keep live dictation responsive".into(),
message: format!(
"Failed to open audio recording file ({}); transcription will continue without saving audio.",
e
),
});
}
let stopping = stop_flag.load(Ordering::Relaxed);
if stopping && !resampler_flushed {
if let Some(resampler) = &mut resampler {
let tail = resampler.flush().map_err(|e| e.to_string())?;
append_resampled_audio(
&mut capture_buffer,
&mut wav_writer,
&tail,
session_id,
&status_channel,
);
}
resampler_flushed = true;
// Final flush for the WAV header so the last chunk's header
// update is on disk before we drop into the inference drain.
if let Some(writer) = wav_writer.as_mut() {
if let Err(e) = writer.flush() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!("WAV flush failed near session end: {e}"),
});
}
}
}
if inflight.is_none() {
if let Some(task) = maybe_dispatch_chunk(
&engine,
&config,
&mut capture_buffer,
&mut buffer_start_sample,
&mut chunk_id,
stopping,
&status_channel,
session_id,
) {
inflight = Some(task);
continue;
}
if stopping && resampler_flushed {
break;
}
None
}
}
}
while inflight.is_some() {
poll_inference(
&mut inflight,
session_id,
&config,
&mut recent_segments,
&dictionary_terms,
&result_channel,
&status_channel,
)?;
thread::sleep(Duration::from_millis(10));
}
// Finalise the progressive WAV writer and decide whether to
// report a path to the frontend. Only a clean finalise produces a
// reported path: a writer that died mid-session (wav_writer was
// already None) or a finalise that itself errored both yield
// `None`, so `StopLiveTranscriptionResponse.audio_path` reflects
// "recording is known-good" rather than "recording was attempted".
let audio_path = match wav_writer.take() {
fn finalize_wav_writer(
wav_writer: Option<WavWriter>,
audio_path: Option<&PathBuf>,
session_id: u64,
status_channel: &Channel<LiveStatusMessage>,
) -> Option<String> {
match wav_writer {
Some(writer) => match writer.finalize() {
Ok(()) => audio_path.as_ref().map(|p| p.to_string_lossy().to_string()),
Ok(()) => audio_path.map(|path| path.to_string_lossy().to_string()),
Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
@@ -568,13 +711,7 @@ fn run_live_session(
}
},
None => None,
};
Ok(LiveSessionSummary {
session_id,
dropped_audio_ms,
audio_path,
})
}
}
fn append_resampled_audio(
@@ -719,6 +856,7 @@ fn maybe_dispatch_chunk(
fn poll_inference(
inflight: &mut Option<InferenceTask>,
result_listener_lost: &mut bool,
session_id: u64,
config: &StartLiveTranscriptionConfig,
recent_segments: &mut Vec<RecentTranscriptSegment>,
@@ -761,22 +899,30 @@ fn poll_inference(
);
let segment_count = segments.len();
let delivered_segments = segments.clone();
result_channel
.send(LiveResultMessage {
session_id,
chunk_id: task.chunk_id,
chunk_start_secs,
duration: task.duration_secs,
language: timed.transcript.language().to_string(),
inference_ms: timed.inference_ms,
segments,
raw_text,
})
.map_err(|e| e.to_string())?;
let result_message = LiveResultMessage {
session_id,
chunk_id: task.chunk_id,
chunk_start_secs,
duration: task.duration_secs,
language: timed.transcript.language().to_string(),
inference_ms: timed.inference_ms,
segments,
raw_text,
};
let delivered = emit_live_result(
result_channel,
status_channel,
result_listener_lost,
&result_message,
);
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
eprintln!(
"[live] session {session_id}: delivered chunk {} with {} segments in {}ms{}",
"[live] session {session_id}: {} chunk {} with {} segments in {}ms{}",
if delivered {
"delivered"
} else {
"processed without listener for"
},
task.chunk_id,
segment_count,
timed.inference_ms,
@@ -813,6 +959,33 @@ fn poll_inference(
}
}
fn emit_live_result(
result_channel: &Channel<LiveResultMessage>,
status_channel: &Channel<LiveStatusMessage>,
result_listener_lost: &mut bool,
result_message: &LiveResultMessage,
) -> bool {
if *result_listener_lost {
return false;
}
match result_channel.send(result_message.clone()) {
Ok(()) => true,
Err(err) => {
*result_listener_lost = true;
eprintln!(
"[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 {
session_id: result_message.session_id,
message: "Live preview disconnected; transcription will continue in the background until you stop the session.".into(),
});
false
}
}
}
fn trim_overlap_segments(segments: &mut Vec<Segment>, trim_before_secs: f64) {
if trim_before_secs <= 0.0 {
return;
@@ -1150,6 +1323,82 @@ fn downmix_chunk(samples: Vec<f32>, channels: usize) -> Vec<f32> {
#[cfg(test)]
mod tests {
use super::*;
use tauri::ipc::InvokeResponseBody;
fn noop_status_channel() -> Channel<LiveStatusMessage> {
Channel::new(|_| Ok(()))
}
fn collecting_status_channel(payloads: Arc<Mutex<Vec<String>>>) -> Channel<LiveStatusMessage> {
Channel::new(move |body| {
if let InvokeResponseBody::Json(json) = body {
payloads.lock().unwrap().push(json);
}
Ok(())
})
}
fn dummy_running_session(
id: u64,
release_join: Option<Arc<tokio::sync::Notify>>,
) -> RunningLiveSession {
let stop_flag = Arc::new(AtomicBool::new(false));
let handle = tokio::spawn(async move {
if let Some(notify) = release_join {
notify.notified().await;
}
Ok(LiveSessionSummary {
session_id: id,
dropped_audio_ms: 0,
audio_path: None,
})
});
RunningLiveSession {
id,
stop_flag,
handle,
status_channel: noop_status_channel(),
}
}
async fn test_begin_session_start(
live_state: Arc<LiveTranscriptionState>,
session_id: u64,
release_setup: Option<Arc<tokio::sync::Notify>>,
) -> Result<u64, String> {
let _lifecycle = live_state.lifecycle.lock().await;
{
let running = live_state.running.lock().unwrap();
if running.is_some() {
return Err("A live transcription session is already running".into());
}
}
if let Some(notify) = release_setup {
notify.notified().await;
}
*live_state.running.lock().unwrap() = Some(dummy_running_session(session_id, None));
Ok(session_id)
}
async fn test_stop_session(
live_state: Arc<LiveTranscriptionState>,
session_id: u64,
) -> Result<LiveSessionSummary, String> {
let _lifecycle = live_state.lifecycle.lock().await;
let running = live_state.running.lock().unwrap().take();
let Some(running) = running else {
return Err("No live transcription session is running".into());
};
if running.id != session_id {
*live_state.running.lock().unwrap() = Some(running);
return Err(format!("Session {session_id} is not active"));
}
running.stop_flag.store(true, Ordering::Relaxed);
running
.handle
.await
.map_err(|e| format!("Live session task failed: {e}"))?
}
fn segment(start: f64, end: f64, text: &str) -> Segment {
Segment {
@@ -1256,4 +1505,157 @@ mod tests {
assert_eq!(decision.speech_window_count, 3);
assert_eq!(decision.max_consecutive_speech_windows, 3);
}
#[test]
fn result_listener_loss_is_warned_once_and_not_treated_as_inference_failure() {
let statuses = Arc::new(Mutex::new(Vec::new()));
let status_channel = collecting_status_channel(statuses.clone());
let result_channel = Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage));
let config = StartLiveTranscriptionConfig {
engine: "whisper".into(),
model_id: None,
language: Some("en".into()),
initial_prompt: None,
save_audio: false,
output_folder: None,
remove_fillers: false,
british_english: false,
anti_hallucination: false,
format_mode: "Raw".into(),
microphone_device: None,
profile_id: None,
};
let mut recent_segments = Vec::new();
let mut result_listener_lost = false;
let (tx1, rx1) = std::sync::mpsc::channel();
tx1.send(Ok(kon_transcription::TimedTranscript {
transcript: kon_core::types::Transcript::new(
vec![segment(0.0, 0.8, "first chunk")],
"en".into(),
0.8,
),
inference_ms: 12,
}))
.unwrap();
let mut inflight = Some(InferenceTask {
chunk_id: 1,
chunk_start_sample: 0,
trim_before_secs: 0.0,
duration_secs: 0.8,
rx: rx1,
});
let first = poll_inference(
&mut inflight,
&mut result_listener_lost,
77,
&config,
&mut recent_segments,
&[],
&result_channel,
&status_channel,
)
.unwrap();
assert_eq!(first, Some(true));
assert!(result_listener_lost);
assert!(inflight.is_none());
assert_eq!(recent_segments.len(), 1);
let warning_count_after_first = statuses.lock().unwrap().len();
assert_eq!(warning_count_after_first, 1);
assert!(
statuses.lock().unwrap()[0].contains("Live preview disconnected"),
"expected a warning about background continuation after listener loss"
);
let (tx2, rx2) = std::sync::mpsc::channel();
tx2.send(Ok(kon_transcription::TimedTranscript {
transcript: kon_core::types::Transcript::new(
vec![segment(0.0, 0.9, "second chunk")],
"en".into(),
0.9,
),
inference_ms: 14,
}))
.unwrap();
inflight = Some(InferenceTask {
chunk_id: 2,
chunk_start_sample: 16_000,
trim_before_secs: 0.0,
duration_secs: 0.9,
rx: rx2,
});
let second = poll_inference(
&mut inflight,
&mut result_listener_lost,
77,
&config,
&mut recent_segments,
&[],
&result_channel,
&status_channel,
)
.unwrap();
assert_eq!(second, Some(true));
assert!(inflight.is_none());
assert_eq!(recent_segments.len(), 2);
assert_eq!(
statuses.lock().unwrap().len(),
warning_count_after_first,
"listener-loss warning should only be emitted once"
);
}
#[tokio::test]
async fn concurrent_starts_allow_only_one_session_to_claim_the_slot() {
let live_state = Arc::new(LiveTranscriptionState::default());
let release_setup = Arc::new(tokio::sync::Notify::new());
let first = tokio::spawn(test_begin_session_start(
live_state.clone(),
1,
Some(release_setup.clone()),
));
tokio::time::sleep(Duration::from_millis(20)).await;
let second = tokio::spawn(test_begin_session_start(live_state.clone(), 2, None));
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
!second.is_finished(),
"second start should wait on the lifecycle lock"
);
release_setup.notify_one();
assert_eq!(first.await.unwrap().unwrap(), 1);
let err = second.await.unwrap().unwrap_err();
assert_eq!(err, "A live transcription session is already running");
}
#[tokio::test]
async fn start_waits_for_stop_to_finish_joining_before_reusing_slot() {
let live_state = Arc::new(LiveTranscriptionState::default());
let release_join = Arc::new(tokio::sync::Notify::new());
*live_state.running.lock().unwrap() =
Some(dummy_running_session(7, Some(release_join.clone())));
let stop = tokio::spawn(test_stop_session(live_state.clone(), 7));
tokio::time::sleep(Duration::from_millis(20)).await;
let start = tokio::spawn(test_begin_session_start(live_state.clone(), 8, None));
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
!start.is_finished(),
"new start should block until stop finishes joining the old worker"
);
release_join.notify_one();
let summary = stop.await.unwrap().unwrap();
assert_eq!(summary.session_id, 7);
assert_eq!(start.await.unwrap().unwrap(), 8);
}
}

View File

@@ -8,10 +8,10 @@ use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::hardware::{self, CpuFeatures};
use kon_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
use kon_core::types::{AudioSamples, ModelId, TranscriptionOptions};
use kon_transcription::model_manager;
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
#[cfg(feature = "whisper")]
use kon_transcription::load_whisper;
use kon_transcription::model_manager;
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
/// Map legacy size strings to ModelId.
fn whisper_model_id(size: &str) -> ModelId {
@@ -73,9 +73,7 @@ fn model_capability(
}
}
pub fn load_model_from_disk(
model_id: &ModelId,
) -> Result<Box<dyn Transcriber + Send>, String> {
pub fn load_model_from_disk(model_id: &ModelId) -> Result<Box<dyn Transcriber + Send>, String> {
let entry =
model_registry::find_model(model_id).ok_or_else(|| format!("Unknown model: {model_id}"))?;
@@ -205,8 +203,7 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
// latency instead of the ~45s cold-start documented in
// ufal/whisper_streaming #96 and #135. Silence returns
// empty segments — the *work* is the context allocation.
let silence =
AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
let silence = AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
let options = TranscriptionOptions::default();
match whisper_engine.transcribe_sync(&silence, &options) {
Ok(_) => eprintln!("[startup] Whisper warm-up inference complete"),
@@ -379,11 +376,7 @@ fn supported_accelerators() -> Vec<String> {
} else {
AcceleratorTarget::Other
};
compose_accelerators(
cfg!(feature = "whisper"),
vulkan_loader_available(),
target,
)
compose_accelerators(cfg!(feature = "whisper"), vulkan_loader_available(), target)
}
/// Report which backend whisper.cpp was actually able to initialise
@@ -405,8 +398,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
kind: "cpu".into(),
label: "CPU (fallback)".into(),
reason: Some(
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime."
.into(),
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime.".into(),
),
};
}

View File

@@ -396,7 +396,11 @@ fn detect_focused_window_class_macos() -> Option<String> {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
if name.is_empty() {
None
} else {
Some(name)
}
}
#[cfg(target_os = "windows")]
@@ -421,7 +425,11 @@ fn detect_focused_window_class_windows() -> Option<String> {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
if name.is_empty() {
None
} else {
Some(name)
}
}
fn trigger_paste_keystroke() -> Result<String, String> {
@@ -473,7 +481,10 @@ fn trigger_undo_keystroke() -> Result<String, String> {
}
#[cfg(target_os = "linux")]
fn linux_paste(xdg_session_type: Option<&str>, wayland_display_set: bool) -> Result<String, String> {
fn linux_paste(
xdg_session_type: Option<&str>,
wayland_display_set: bool,
) -> Result<String, String> {
for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) {
match run_linux_tool(tool) {
Ok(()) => return Ok(tool.to_string()),

View File

@@ -21,7 +21,17 @@
//! may still decide to idle us. We log when that happens so the
//! diagnostics bundle has a breadcrumb.
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone)]
pub struct PowerAssertionSnapshot {
pub id: usize,
pub reason: &'static str,
pub backend: &'static str,
pub acquired: bool,
}
/// Handle for a single power assertion. Dropping it releases the
/// assertion. Holders are expected to keep it alive in a field for
@@ -32,12 +42,30 @@ pub struct PowerAssertion {
#[allow(dead_code)]
id: usize,
reason: &'static str,
backend: &'static str,
acquired: bool,
#[cfg(target_os = "macos")]
activity: Option<objc_bridge::ActivityHandle>,
}
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
fn assertion_registry() -> &'static Mutex<HashMap<usize, PowerAssertionSnapshot>> {
static REGISTRY: OnceLock<Mutex<HashMap<usize, PowerAssertionSnapshot>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn active_assertions_snapshot() -> Vec<PowerAssertionSnapshot> {
let mut snapshots = assertion_registry()
.lock()
.unwrap()
.values()
.cloned()
.collect::<Vec<_>>();
snapshots.sort_by_key(|snapshot| snapshot.id);
snapshots
}
impl PowerAssertion {
/// Begin a power assertion for the given reason. On macOS this
/// pins beginActivityWithOptions; on Linux/Windows it logs only
@@ -47,12 +75,16 @@ impl PowerAssertion {
#[cfg(target_os = "macos")]
let activity = objc_bridge::begin_activity(reason).ok();
#[cfg(target_os = "macos")]
let backend = "macos";
#[cfg(target_os = "macos")]
let acquired = activity.is_some();
#[cfg(target_os = "macos")]
if activity.is_none() {
eprintln!(
"[power] macOS App Nap guard could not begin activity for reason '{reason}'"
);
if acquired {
eprintln!("[power] began macOS App Nap guard #{id} for reason '{reason}'");
} else {
eprintln!("[power] macOS App Nap guard could not begin activity for reason '{reason}'");
}
#[cfg(not(target_os = "macos"))]
@@ -63,9 +95,26 @@ impl PowerAssertion {
let _ = reason;
}
#[cfg(not(target_os = "macos"))]
let backend = "noop";
#[cfg(not(target_os = "macos"))]
let acquired = false;
assertion_registry().lock().unwrap().insert(
id,
PowerAssertionSnapshot {
id,
reason,
backend,
acquired,
},
);
Self {
id,
reason,
backend,
acquired,
#[cfg(target_os = "macos")]
activity,
}
@@ -77,64 +126,83 @@ impl Drop for PowerAssertion {
#[cfg(target_os = "macos")]
if let Some(handle) = self.activity.take() {
objc_bridge::end_activity(handle);
eprintln!(
"[power] ended macOS App Nap guard #{} for reason '{}'",
self.id, self.reason
);
}
assertion_registry().lock().unwrap().remove(&self.id);
let _ = (self.reason, self.id);
let _ = (self.backend, self.acquired);
}
}
#[cfg(target_os = "macos")]
mod objc_bridge {
//! Placeholder for the NSProcessInfo App-Nap bridge.
//!
//! A proper implementation calls:
//! `NSProcessInfo *info = [NSProcessInfo processInfo];`
//! `id activity = [info beginActivityWithOptions:
//! (NSActivityUserInitiated | NSActivityLatencyCritical)
//! reason:reasonNSString];`
//! and retains the returned object until `end_activity`.
//!
//! This workstream ships the PowerAssertion RAII guard + wiring
//! so `commands/live.rs` and `commands/llm.rs` can adopt it today
//! (matters on macOS, no-op elsewhere). The actual `objc2` bridge
//! lands in a follow-up commit that can introduce `objc2` +
//! `objc2-foundation` without touching the rest of the workspace
//! in the same change.
//!
//! Until then, `begin_activity` returns Err; callers (`begin()`)
//! log a warning but keep running, so recording continues to work
//! as today — the gap is just the App-Nap protection, not the
//! recording itself.
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString};
pub struct ActivityHandle {
#[allow(dead_code)]
retained: *mut std::ffi::c_void,
activity: Retained<ProtocolObject<dyn NSObjectProtocol>>,
}
// SAFETY: The pointer is opaque to Rust; Foundation manages its
// lifetime via retain/release. We never dereference it directly.
unsafe impl Send for ActivityHandle {}
pub fn begin_activity(_reason: &str) -> Result<ActivityHandle, String> {
Err("macOS App Nap bridge not yet wired — objc2 integration tracked for a follow-up".into())
pub fn begin_activity(reason: &str) -> Result<ActivityHandle, String> {
let process_info = NSProcessInfo::processInfo();
let reason = NSString::from_str(reason);
let options = NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical;
let activity = process_info.beginActivityWithOptions_reason(options, &reason);
Ok(ActivityHandle { activity })
}
pub fn end_activity(_handle: ActivityHandle) {}
pub fn end_activity(handle: ActivityHandle) {
let process_info = NSProcessInfo::processInfo();
unsafe {
process_info.endActivity(&handle.activity);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn power_test_guard() -> MutexGuard<'static, ()> {
static TEST_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
TEST_GUARD.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn clear_assertion_registry() {
assertion_registry().lock().unwrap().clear();
}
#[test]
fn power_assertion_is_a_no_op_drop() {
let _guard = power_test_guard();
clear_assertion_registry();
let guard = PowerAssertion::begin("test-reason");
let snapshots = active_assertions_snapshot();
assert!(snapshots.iter().any(|snapshot| snapshot.id == guard.id));
drop(guard);
assert!(active_assertions_snapshot().is_empty());
}
#[test]
fn multiple_assertions_get_unique_ids() {
let _guard = power_test_guard();
clear_assertion_registry();
let a = PowerAssertion::begin("a");
let b = PowerAssertion::begin("b");
assert_ne!(a.id, b.id);
let snapshots = active_assertions_snapshot();
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0].reason, "a");
assert_eq!(snapshots[1].reason, "b");
drop(a);
drop(b);
assert!(active_assertions_snapshot().is_empty());
}
}

View File

@@ -74,8 +74,10 @@ async fn save_preferences(
/// known crashes that the HANDOVER documents working around with a manual
/// env-var prefix:
///
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
/// ```sh
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
/// ```
///
/// Detect the Wayland session at startup and apply the env vars before
/// anything else loads, so users do not need to remember the prefix and