chore(hardening): tighten security and footprint defaults

This commit is contained in:
2026-04-24 19:03:57 +01:00
parent 55b34d8ffc
commit b333c6229e
36 changed files with 8702 additions and 254 deletions

View File

@@ -169,3 +169,13 @@ jobs:
path: ${{ matrix.artifact_glob }} path: ${{ matrix.artifact_glob }}
retention-days: 30 retention-days: 30
if-no-files-found: warn if-no-files-found: warn
- name: Report artifact sizes
if: always()
shell: bash
run: |
if [ -d src-tauri/target/release/bundle ]; then
find src-tauri/target/release/bundle -type f \
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.msi' -o -name '*.exe' -o -name '*.dmg' -o -name '*.app' \) \
-exec du -h {} + | sort -h
fi

View File

@@ -111,6 +111,8 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
# Cache the Cargo target dir + registry per OS so the heavy # Cache the Cargo target dir + registry per OS so the heavy
# whisper-rs-sys C++ build only happens on a clean cache. # whisper-rs-sys C++ build only happens on a clean cache.
@@ -128,12 +130,24 @@ jobs:
- name: cargo check (workspace) - name: cargo check (workspace)
run: cargo check --workspace --all-targets run: cargo check --workspace --all-targets
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
# Library tests only — no runtime/GPU deps. Linux-gated to keep # Library tests only — no runtime/GPU deps. Linux-gated to keep
# the macOS + Windows legs focused on compile coverage. # the macOS + Windows legs focused on compile coverage.
- name: cargo test (workspace, libs) - name: cargo test (workspace, libs)
if: matrix.os == 'ubuntu-22.04' if: matrix.os == 'ubuntu-22.04'
run: cargo test --workspace --lib run: cargo test --workspace --lib
- name: cargo audit
if: matrix.os == 'ubuntu-22.04'
run: |
cargo install cargo-audit --locked
cargo audit
frontend: frontend:
name: svelte build + lint name: svelte build + lint
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -150,6 +164,9 @@ jobs:
- name: Install JS deps - name: Install JS deps
run: npm ci run: npm ci
- name: npm audit
run: npm audit --audit-level=high
# `tauri build` inside check.yml would trigger the full Rust build # `tauri build` inside check.yml would trigger the full Rust build
# which is owned by the rust job. Here we only validate that the # which is owned by the rust job. Here we only validate that the
# Svelte/Vite frontend compiles cleanly. # Svelte/Vite frontend compiles cleanly.

1
.gitignore vendored
View File

@@ -3,7 +3,6 @@ target/
build/ build/
dist/ dist/
.svelte-kit/ .svelte-kit/
Cargo.lock
.firecrawl/ .firecrawl/
.worktrees/ .worktrees/
.cargo/ .cargo/

7828
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,10 @@
[workspace] [workspace]
members = ["src-tauri", "crates/*"] members = ["src-tauri", "crates/*"]
resolver = "2" resolver = "2"
[profile.release]
codegen-units = 1
lto = "thin"
opt-level = 3
panic = "abort"
strip = "symbols"

View File

@@ -21,6 +21,13 @@ use kon_core::types::AudioSamples;
/// input silently returned `Ok` with whatever had decoded before the /// input silently returned `Ok` with whatever had decoded before the
/// failure — flagged by the 2026-04-22 review (RB-09). /// failure — flagged by the 2026-04-22 review (RB-09).
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> { pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
decode_audio_file_limited(path, None)
}
pub fn decode_audio_file_limited(
path: &Path,
max_duration_secs: Option<f64>,
) -> Result<AudioSamples> {
let file = File::open(path) let file = File::open(path)
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?; .map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mss = MediaSourceStream::new(Box::new(file), Default::default());
@@ -30,13 +37,48 @@ pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
hint.with_extension(ext); hint.with_extension(ext);
} }
decode_media_stream(mss, &hint) decode_media_stream(mss, &hint, max_duration_secs)
}
pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> {
let file = File::open(path)
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
let track = probed
.format
.default_track()
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
let sample_rate = track
.codec_params
.sample_rate
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
Ok(track
.codec_params
.n_frames
.map(|frames| frames as f64 / sample_rate as f64))
} }
/// Decode from an already-constructed `MediaSourceStream`. Split out so /// Decode from an already-constructed `MediaSourceStream`. Split out so
/// tests can inject a custom `MediaSource` (for example, one that /// tests can inject a custom `MediaSource` (for example, one that
/// returns a mid-stream I/O error) to verify error propagation. /// returns a mid-stream I/O error) to verify error propagation.
fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSamples> { fn decode_media_stream(
mss: MediaSourceStream,
hint: &Hint,
max_duration_secs: Option<f64>,
) -> Result<AudioSamples> {
let probed = symphonia::default::get_probe() let probed = symphonia::default::get_probe()
.format( .format(
hint, hint,
@@ -61,6 +103,7 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
} }
let track_id = track.id; let track_id = track.id;
let max_samples = max_duration_secs.map(|secs| (secs * sample_rate as f64).ceil() as usize);
let mut decoder = symphonia::default::get_codecs() let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default()) .make(&track.codec_params, &DecoderOptions::default())
@@ -111,6 +154,15 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
samples.push(sum / channels as f32); samples.push(sum / channels as f32);
} }
} }
if max_samples
.map(|limit| samples.len() > limit)
.unwrap_or(false)
{
return Err(KonError::AudioDecodeFailed(format!(
"Audio is longer than the {:.0} minute import limit",
max_duration_secs.unwrap_or(0.0) / 60.0
)));
}
} }
if samples.is_empty() { if samples.is_empty() {
@@ -222,7 +274,7 @@ mod tests {
let mut hint = Hint::new(); let mut hint = Hint::new();
hint.with_extension("wav"); hint.with_extension("wav");
let result = decode_media_stream(mss, &hint); let result = decode_media_stream(mss, &hint, None);
assert!( assert!(
result.is_err(), result.is_err(),
"mid-stream I/O error must surface, got: {result:?}" "mid-stream I/O error must surface, got: {result:?}"

View File

@@ -8,7 +8,7 @@ pub mod wav;
pub use capture::{AudioChunk, CaptureRuntimeError, DeviceInfo, MicrophoneCapture}; pub use capture::{AudioChunk, CaptureRuntimeError, DeviceInfo, MicrophoneCapture};
pub use concurrency::decode_and_resample; pub use concurrency::decode_and_resample;
pub use decode::decode_audio_file; pub use decode::{decode_audio_file, decode_audio_file_limited, probe_audio_duration_secs};
pub use resample::resample_to_16khz; pub use resample::resample_to_16khz;
pub use streaming_resample::StreamingResampler; pub use streaming_resample::StreamingResampler;
pub use vad::SpeechDetector; pub use vad::SpeechDetector;

View File

@@ -2,6 +2,7 @@ pub mod constants;
pub mod error; pub mod error;
pub mod hardware; pub mod hardware;
pub mod model_registry; pub mod model_registry;
pub mod paths;
pub mod process_watch; pub mod process_watch;
pub mod recommendation; pub mod recommendation;
pub mod types; pub mod types;

View File

@@ -40,8 +40,8 @@ pub struct ModelFile {
pub filename: &'static str, pub filename: &'static str,
pub url: &'static str, pub url: &'static str,
pub size: Megabytes, pub size: Megabytes,
/// SHA256 hex digest for integrity verification. None to skip check. /// SHA256 hex digest for integrity verification.
pub sha256: Option<&'static str>, pub sha256: &'static str,
} }
/// All metadata for a single downloadable model. /// All metadata for a single downloadable model.
@@ -74,27 +74,27 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
files: vec![ files: vec![
ModelFile { ModelFile {
filename: "encoder-model.int8.onnx", filename: "encoder-model.int8.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/encoder-model.int8.onnx", url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/encoder-model.int8.onnx",
size: Megabytes(620), size: Megabytes(620),
sha256: None, sha256: "3e0581fda6ab843888b51e56d7ee78b6d5bc3237ec113af1f732d1d5286aa155",
}, },
ModelFile { ModelFile {
filename: "decoder_joint-model.int8.onnx", filename: "decoder_joint-model.int8.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/decoder_joint-model.int8.onnx", url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/decoder_joint-model.int8.onnx",
size: Megabytes(3), size: Megabytes(3),
sha256: None, sha256: "a449f49acd68979d418651dd2dcb737cc0f1bf0225e009e29ee326354edbf7d3",
}, },
ModelFile { ModelFile {
filename: "nemo128.onnx", filename: "nemo128.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/nemo128.onnx", url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/nemo128.onnx",
size: Megabytes(1), size: Megabytes(1),
sha256: None, sha256: "a9fde1486ebfcc08f328d75ad4610c67835fea58c73ba57e3209a6f6cf019e9f",
}, },
ModelFile { ModelFile {
filename: "vocab.txt", filename: "vocab.txt",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/vocab.txt", url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/vocab.txt",
size: Megabytes(1), size: Megabytes(1),
sha256: None, sha256: "ec182b70dd42113aff6c5372c75cac58c952443eb22322f57bbd7f53977d497d",
}, },
], ],
description: "Fastest local model — near-instant transcription", description: "Fastest local model — near-instant transcription",
@@ -110,9 +110,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly, languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile { files: vec![ModelFile {
filename: "ggml-tiny.en.bin", filename: "ggml-tiny.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-tiny.en.bin",
size: Megabytes(75), size: Megabytes(75),
sha256: None, sha256: "921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f",
}], }],
description: "Bundled with app — works instantly", description: "Bundled with app — works instantly",
}, },
@@ -127,9 +127,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly, languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile { files: vec![ModelFile {
filename: "ggml-base.en.bin", filename: "ggml-base.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.en.bin",
size: Megabytes(142), size: Megabytes(142),
sha256: None, sha256: "a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002",
}], }],
description: "Good balance of speed and accuracy", description: "Good balance of speed and accuracy",
}, },
@@ -144,9 +144,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly, languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile { files: vec![ModelFile {
filename: "ggml-small.en.bin", filename: "ggml-small.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-small.en.bin",
size: Megabytes(466), size: Megabytes(466),
sha256: None, sha256: "c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d",
}], }],
description: "Accuracy-first English transcription", description: "Accuracy-first English transcription",
}, },
@@ -161,9 +161,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly, languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile { files: vec![ModelFile {
filename: "ggml-distil-small.en.bin", filename: "ggml-distil-small.en.bin",
url: "https://huggingface.co/distil-whisper/distil-small.en/resolve/main/ggml-distil-small.en.bin", url: "https://huggingface.co/distil-whisper/distil-small.en/resolve/9e4a67ca4569c30be43a3fe7fba1621e504f0093/ggml-distil-small.en.bin",
size: Megabytes(336), size: Megabytes(336),
sha256: None, sha256: "7691eb11167ab7aaf6b3e05d8266f2fd9ad89c550e433f86ac266ebdee6c970a",
}], }],
description: "Small accuracy, ~6\u{00d7} faster — distilled variant", description: "Small accuracy, ~6\u{00d7} faster — distilled variant",
}, },
@@ -178,9 +178,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly, languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile { files: vec![ModelFile {
filename: "ggml-medium.en.bin", filename: "ggml-medium.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-medium.en.bin",
size: Megabytes(1500), size: Megabytes(1500),
sha256: None, sha256: "cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356",
}], }],
description: "Best Whisper accuracy — needs 4+ GB RAM", description: "Best Whisper accuracy — needs 4+ GB RAM",
}, },
@@ -195,9 +195,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly, languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile { files: vec![ModelFile {
filename: "ggml-distil-large-v3.bin", filename: "ggml-distil-large-v3.bin",
url: "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/main/ggml-distil-large-v3.bin", url: "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/0d78dd96ed9fc152325f63b53788fec3b43de031/ggml-distil-large-v3.bin",
size: Megabytes(1550), size: Megabytes(1550),
sha256: None, sha256: "2883a11b90fb10ed592d826edeaee7d2929bf1ab985109fe9e1e7b4d2b69a298",
}], }],
description: "Near large-v3 accuracy at ~6\u{00d7} the speed", description: "Near large-v3 accuracy at ~6\u{00d7} the speed",
}, },
@@ -213,3 +213,35 @@ pub fn all_models() -> &'static [ModelEntry] {
pub fn find_model(id: &ModelId) -> Option<&'static ModelEntry> { pub fn find_model(id: &ModelId) -> Option<&'static ModelEntry> {
ALL_MODELS.iter().find(|m| &m.id == id) ALL_MODELS.iter().find(|m| &m.id == id)
} }
#[cfg(test)]
mod tests {
use super::all_models;
#[test]
fn every_model_file_has_sha256_and_pinned_url() {
for model in all_models() {
for file in &model.files {
assert_eq!(
file.sha256.len(),
64,
"{} / {} must carry a SHA256 digest",
model.id,
file.filename
);
assert!(
file.sha256.chars().all(|c| c.is_ascii_hexdigit()),
"{} / {} SHA256 must be hex",
model.id,
file.filename
);
assert!(
!file.url.contains("/resolve/main/"),
"{} / {} must pin a Hugging Face revision",
model.id,
file.filename
);
}
}
}
}

125
crates/core/src/paths.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::path::PathBuf;
use crate::types::ModelId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppPaths {
app_data_dir: PathBuf,
}
impl AppPaths {
pub fn current() -> Self {
Self {
app_data_dir: resolve_app_data_dir(),
}
}
pub fn app_data_dir(&self) -> PathBuf {
self.app_data_dir.clone()
}
pub fn database_path(&self) -> PathBuf {
self.app_data_dir.join("kon.db")
}
pub fn recordings_dir(&self) -> PathBuf {
self.app_data_dir.join("recordings")
}
pub fn crashes_dir(&self) -> PathBuf {
self.app_data_dir.join("crashes")
}
pub fn logs_dir(&self) -> PathBuf {
self.app_data_dir.join("logs")
}
pub fn diagnostic_reports_dir(&self) -> PathBuf {
self.app_data_dir.join("diagnostic-reports")
}
pub fn models_dir(&self) -> PathBuf {
self.app_data_dir.join("models")
}
pub fn speech_model_dir(&self, id: &ModelId) -> PathBuf {
self.models_dir().join(id.as_str())
}
pub fn llm_models_dir(&self) -> PathBuf {
self.models_dir().join("llm")
}
pub fn migration_sentinel(&self, name: &str) -> PathBuf {
self.app_data_dir.join(format!(".{name}.sentinel"))
}
}
pub fn app_paths() -> AppPaths {
AppPaths::current()
}
pub fn app_data_dir() -> PathBuf {
app_paths().app_data_dir()
}
fn resolve_app_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
return PathBuf::from(local_app_data).join("kon");
}
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("Kon");
}
#[cfg(target_os = "linux")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let legacy = PathBuf::from(&home).join(".kon");
if legacy.exists() {
return legacy;
}
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("kon");
}
}
PathBuf::from(home).join(".local").join("share").join("kon")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".kon")
}
}
#[cfg(test)]
mod tests {
use super::AppPaths;
use crate::types::ModelId;
use std::path::PathBuf;
#[test]
fn derives_all_paths_from_one_base() {
let paths = AppPaths {
app_data_dir: PathBuf::from("/tmp/kon-test"),
};
assert_eq!(paths.database_path(), PathBuf::from("/tmp/kon-test/kon.db"));
assert_eq!(
paths.speech_model_dir(&ModelId::new("whisper-base-en")),
PathBuf::from("/tmp/kon-test/models/whisper-base-en")
);
assert_eq!(
paths.llm_models_dir(),
PathBuf::from("/tmp/kon-test/models/llm")
);
}
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
dirs = "6" kon-core = { path = "../core" }
encoding_rs = "0.8" encoding_rs = "0.8"
futures-util = "0.3" futures-util = "0.3"
llama-cpp-2 = { version = "0.1.144", default-features = false, features = ["openmp", "vulkan"] } llama-cpp-2 = { version = "0.1.144", default-features = false, features = ["openmp", "vulkan"] }

View File

@@ -2,6 +2,7 @@ use std::fmt;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use std::sync::{LazyLock, Mutex};
use futures_util::StreamExt; use futures_util::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -158,6 +159,36 @@ const ALL_MODELS: &[LlmModelId] = &[
LlmModelId::Qwen3_14BQ5, LlmModelId::Qwen3_14BQ5,
]; ];
static ACTIVE_DOWNLOADS: LazyLock<Mutex<std::collections::HashSet<LlmModelId>>> =
LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
struct DownloadReservation {
id: LlmModelId,
}
impl DownloadReservation {
fn acquire(id: LlmModelId) -> Result<Self, DownloadError> {
let mut active = ACTIVE_DOWNLOADS
.lock()
.map_err(|_| DownloadError::Http("download lock poisoned".into()))?;
if !active.insert(id) {
return Err(DownloadError::Http(format!(
"download already in progress for {}",
id.as_str()
)));
}
Ok(Self { id })
}
}
impl Drop for DownloadReservation {
fn drop(&mut self) {
if let Ok(mut active) = ACTIVE_DOWNLOADS.lock() {
active.remove(&self.id);
}
}
}
pub fn all_models() -> &'static [LlmModelId] { pub fn all_models() -> &'static [LlmModelId] {
ALL_MODELS ALL_MODELS
} }
@@ -189,20 +220,7 @@ pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option<u64>) -> Ll
} }
pub fn model_dir() -> PathBuf { pub fn model_dir() -> PathBuf {
if cfg!(target_os = "windows") { kon_core::paths::app_paths().llm_models_dir()
std::env::var("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join("kon")
.join("models")
.join("llm")
} else {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".kon")
.join("models")
.join("llm")
}
} }
pub fn model_path(id: LlmModelId) -> PathBuf { pub fn model_path(id: LlmModelId) -> PathBuf {
@@ -235,6 +253,7 @@ pub async fn download_model<F>(id: LlmModelId, on_progress: F) -> Result<(), Dow
where where
F: FnMut(u64, u64) + Send + 'static, F: FnMut(u64, u64) + Send + 'static,
{ {
let _reservation = DownloadReservation::acquire(id)?;
let dest = model_path(id); let dest = model_path(id);
tokio::fs::create_dir_all(model_dir()).await?; tokio::fs::create_dir_all(model_dir()).await?;

View File

@@ -1,79 +1,28 @@
use std::path::PathBuf; use std::path::PathBuf;
/// Resolve the per-user app data directory, following each OS's convention:
///
/// - Windows: `%LOCALAPPDATA%\kon\` — e.g. `C:\Users\Jake\AppData\Local\kon`
/// - macOS: `~/Library/Application Support/Kon/`
/// - Linux: `$XDG_DATA_HOME/kon` or `~/.local/share/kon` (XDG Base Directory),
/// with a fallback to the legacy `~/.kon/` if it already exists, so
/// existing installs keep working.
/// - Other Unix: `~/.kon/`
///
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
/// into a shared helper in `crates/core/` to avoid duplicating platform-specific
/// path logic across crates.
pub fn app_data_dir() -> PathBuf { pub fn app_data_dir() -> PathBuf {
#[cfg(target_os = "windows")] kon_core::paths::app_paths().app_data_dir()
{
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
return PathBuf::from(local_app_data).join("kon");
}
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("Kon");
}
#[cfg(target_os = "linux")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
// Honour the legacy ~/.kon/ if it exists on disk so existing
// installs are not orphaned. New installs follow XDG.
let legacy = PathBuf::from(&home).join(".kon");
if legacy.exists() {
return legacy;
}
// XDG Base Directory: $XDG_DATA_HOME/kon or default ~/.local/share/kon
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("kon");
}
}
PathBuf::from(home).join(".local").join("share").join("kon")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".kon")
}
} }
/// Path to the SQLite database file. /// Path to the SQLite database file.
pub fn database_path() -> PathBuf { pub fn database_path() -> PathBuf {
app_data_dir().join("kon.db") kon_core::paths::app_paths().database_path()
} }
/// Directory for saved audio recordings. /// Directory for saved audio recordings.
pub fn recordings_dir() -> PathBuf { pub fn recordings_dir() -> PathBuf {
app_data_dir().join("recordings") kon_core::paths::app_paths().recordings_dir()
} }
/// Directory for crash dumps written by the Rust panic hook. /// Directory for crash dumps written by the Rust panic hook.
/// Each crash is a single text file: `<unix-ts>-<short-id>.crash`. /// Each crash is a single text file: `<unix-ts>-<short-id>.crash`.
/// Used by the diagnostic-report bundler in Settings → About. /// Used by the diagnostic-report bundler in Settings → About.
pub fn crashes_dir() -> PathBuf { pub fn crashes_dir() -> PathBuf {
app_data_dir().join("crashes") kon_core::paths::app_paths().crashes_dir()
} }
/// Directory for the rolling Rust log file (kon.log + rotated kon.log.1, etc). /// Directory for the rolling Rust log file (kon.log + rotated kon.log.1, etc).
/// Subscribers configured in src-tauri/src/lib.rs at startup. /// Subscribers configured in src-tauri/src/lib.rs at startup.
pub fn logs_dir() -> PathBuf { pub fn logs_dir() -> PathBuf {
app_data_dir().join("logs") kon_core::paths::app_paths().logs_dir()
} }

View File

@@ -24,7 +24,7 @@ transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"]
tokio = { version = "1", features = ["rt", "sync"] } tokio = { version = "1", features = ["rt", "sync"] }
# Model downloads # Model downloads
reqwest = { version = "0.12", features = ["stream"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
futures-util = "0.3" futures-util = "0.3"
# Download integrity verification # Download integrity verification

View File

@@ -1,34 +1,51 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use kon_core::error::{KonError, Result}; use kon_core::error::{KonError, Result};
use kon_core::model_registry::{find_model, ModelFile}; use kon_core::model_registry::{find_model, ModelFile};
use kon_core::types::{DownloadProgress, ModelId}; use kon_core::types::{DownloadProgress, ModelId};
static ACTIVE_DOWNLOADS: LazyLock<Mutex<HashSet<String>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
struct DownloadReservation {
id: String,
}
impl DownloadReservation {
fn acquire(id: &ModelId) -> Result<Self> {
let id = id.as_str().to_string();
let mut active = ACTIVE_DOWNLOADS
.lock()
.map_err(|_| KonError::DownloadFailed("download lock poisoned".into()))?;
if !active.insert(id.clone()) {
return Err(KonError::DownloadFailed(format!(
"download already in progress for {id}"
)));
}
Ok(Self { id })
}
}
impl Drop for DownloadReservation {
fn drop(&mut self) {
if let Ok(mut active) = ACTIVE_DOWNLOADS.lock() {
active.remove(&self.id);
}
}
}
/// Resolve the models storage directory. /// Resolve the models storage directory.
/// Windows: %LOCALAPPDATA%/kon/models /// Windows: %LOCALAPPDATA%/kon/models
/// Unix: ~/.kon/models /// Unix: ~/.kon/models
pub fn models_dir() -> PathBuf { pub fn models_dir() -> PathBuf {
if cfg!(target_os = "windows") { kon_core::paths::app_paths().models_dir()
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
PathBuf::from(local_app_data).join("kon").join("models")
} else {
dirs_path().join("models")
}
}
fn dirs_path() -> PathBuf {
if cfg!(target_os = "windows") {
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
PathBuf::from(local_app_data).join("kon")
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".kon")
}
} }
/// Get the directory path where a specific model's files are stored. /// Get the directory path where a specific model's files are stored.
pub fn model_dir(id: &ModelId) -> PathBuf { pub fn model_dir(id: &ModelId) -> PathBuf {
models_dir().join(id.as_str()) kon_core::paths::app_paths().speech_model_dir(id)
} }
/// Check whether all files for a model have been downloaded. /// Check whether all files for a model have been downloaded.
@@ -39,6 +56,7 @@ pub fn is_downloaded(id: &ModelId) -> bool {
}; };
let dir = model_dir(id); let dir = model_dir(id);
entry.files.iter().all(|f| dir.join(f.filename).exists()) entry.files.iter().all(|f| dir.join(f.filename).exists())
&& verified_manifest_matches(entry, &dir)
} }
/// List all downloaded model IDs. /// List all downloaded model IDs.
@@ -61,6 +79,7 @@ pub async fn download(
id: &ModelId, id: &ModelId,
progress: impl Fn(DownloadProgress) + Send + 'static, progress: impl Fn(DownloadProgress) + Send + 'static,
) -> Result<()> { ) -> Result<()> {
let _reservation = DownloadReservation::acquire(id)?;
let entry = find_model(id).ok_or_else(|| KonError::ModelNotFound(id.clone()))?; let entry = find_model(id).ok_or_else(|| KonError::ModelNotFound(id.clone()))?;
let dir = model_dir(id); let dir = model_dir(id);
@@ -69,15 +88,13 @@ pub async fn download(
for file in &entry.files { for file in &entry.files {
let dest = dir.join(file.filename); let dest = dir.join(file.filename);
if dest.exists() { if dest.exists() {
if let Some(expected_sha) = file.sha256 {
// Validate the existing file. If the hash doesn't match, // Validate the existing file. If the hash doesn't match,
// the file is corrupt (partial download, tampering, bit // the file is corrupt (partial download, tampering, bit
// rot) and we must re-fetch it to avoid crashing on // rot) and we must re-fetch it to avoid crashing on
// model load later. // model load later.
match sha256_of_file(&dest) { match sha256_of_file(&dest) {
Ok(actual) if actual.eq_ignore_ascii_case(expected_sha) => continue, Ok(actual) if actual.eq_ignore_ascii_case(file.sha256) => continue,
Ok(_actual) => { Ok(_actual) => {
// Corrupt — remove + fall through to re-download.
let _ = std::fs::remove_file(&dest); let _ = std::fs::remove_file(&dest);
} }
Err(e) => { Err(e) => {
@@ -87,18 +104,54 @@ pub async fn download(
))); )));
} }
} }
} else {
// No checksum — honour the existing file as-is; the
// engine will barf on load if it's broken.
continue;
}
} }
download_file(file, &dest, id, &progress).await?; download_file(file, &dest, id, &progress).await?;
} }
write_verified_manifest(entry, &dir)?;
Ok(()) Ok(())
} }
fn verified_manifest_path(dir: &Path) -> PathBuf {
dir.join(".kon-verified")
}
fn verified_manifest_matches(entry: &kon_core::model_registry::ModelEntry, dir: &Path) -> bool {
let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
Ok(contents) => contents,
Err(_) => return false,
};
for file in &entry.files {
let path = dir.join(file.filename);
let size = match std::fs::metadata(&path) {
Ok(metadata) => metadata.len(),
Err(_) => return false,
};
let expected_line = format!("{}\t{}\t{}", file.filename, file.sha256, size);
if !manifest.lines().any(|line| line == expected_line) {
return false;
}
}
true
}
fn write_verified_manifest(
entry: &kon_core::model_registry::ModelEntry,
dir: &Path,
) -> std::io::Result<()> {
let mut lines = Vec::with_capacity(entry.files.len() + 1);
lines.push("version\t1".to_string());
for file in &entry.files {
let size = std::fs::metadata(dir.join(file.filename))?.len();
lines.push(format!("{}\t{}\t{}", file.filename, file.sha256, size));
}
std::fs::write(
verified_manifest_path(dir),
format!("{}\n", lines.join("\n")),
)
}
/// Non-streaming SHA256 of a file on disk. Used by `download()` to /// Non-streaming SHA256 of a file on disk. Used by `download()` to
/// validate an existing complete file before trusting it. /// validate an existing complete file before trusting it.
fn sha256_of_file(path: &Path) -> std::io::Result<String> { fn sha256_of_file(path: &Path) -> std::io::Result<String> {
@@ -151,9 +204,7 @@ async fn download_file(
let mut request = client.get(file.url); let mut request = client.get(file.url);
// If we have a partial file and no SHA256 to verify (can't verify partial), let resuming = existing_bytes > 0;
// request a range resume. If SHA256 is set, we restart to ensure integrity.
let resuming = existing_bytes > 0 && file.sha256.is_none();
if resuming { if resuming {
request = request.header("Range", format!("bytes={existing_bytes}-")); request = request.header("Range", format!("bytes={existing_bytes}-"));
} }
@@ -223,19 +274,23 @@ async fn download_file(
std::fs::File::create(&part_path)? std::fs::File::create(&part_path)?
}; };
// Incremental SHA256 — only when a checksum is provided let mut hasher = Sha256::new();
let mut hasher = file.sha256.map(|_| Sha256::new()); if actually_resuming {
let mut partial = std::fs::File::open(&part_path)?;
// If resuming without SHA256, we can't hash the already-downloaded portion, let mut buffer = [0u8; 8192];
// but we also don't need to — we only hash when sha256 is set, and we loop {
// restart from scratch in that case. let n = std::io::Read::read(&mut partial, &mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
}
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| KonError::DownloadFailed(e.to_string()))?; let chunk = chunk.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
std::io::Write::write_all(&mut out, &chunk)?; std::io::Write::write_all(&mut out, &chunk)?;
if let Some(ref mut h) = hasher { hasher.update(&chunk);
h.update(&chunk);
}
downloaded += chunk.len() as u64; downloaded += chunk.len() as u64;
let percent = if total_bytes > 0 { let percent = if total_bytes > 0 {
@@ -258,18 +313,14 @@ async fn download_file(
drop(out); drop(out);
// Verify SHA256 if provided
if let (Some(expected), Some(hasher)) = (file.sha256, hasher) {
let actual = format!("{:x}", hasher.finalize()); let actual = format!("{:x}", hasher.finalize());
if actual != expected { if actual != file.sha256 {
// Delete corrupt file so next attempt starts fresh
let _ = std::fs::remove_file(&part_path); let _ = std::fs::remove_file(&part_path);
return Err(KonError::DownloadFailed(format!( return Err(KonError::DownloadFailed(format!(
"SHA256 mismatch for {}: expected {}, got {}", "SHA256 mismatch for {}: expected {}, got {}",
file.filename, expected, actual file.filename, file.sha256, actual
))); )));
} }
}
// Atomic rename — file is complete and verified // Atomic rename — file is complete and verified
std::fs::rename(&part_path, dest)?; std::fs::rename(&part_path, dest)?;
@@ -428,7 +479,7 @@ mod tests {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")), url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0), size: kon_core::types::Megabytes(0),
sha256: None, // resume path only kicks in when sha256 is absent sha256: leak(expected_sha.clone()),
}; };
let id = ModelId::new("test-fixture"); let id = ModelId::new("test-fixture");
@@ -437,9 +488,6 @@ mod tests {
let bytes = std::fs::read(&dest).unwrap(); let bytes = std::fs::read(&dest).unwrap();
assert_eq!(bytes, body); assert_eq!(bytes, body);
assert!(!part.exists()); assert!(!part.exists());
// Confirm the full file hash matches what we would have got via
// a clean download — gives the resume path indirect integrity
// coverage even when the ModelFile has no sha256 set.
assert_eq!(sha256_of_file(&dest).unwrap(), expected_sha); assert_eq!(sha256_of_file(&dest).unwrap(), expected_sha);
} }
@@ -451,6 +499,7 @@ mod tests {
// partial bytes and write the fresh body from offset zero rather // partial bytes and write the fresh body from offset zero rather
// than appending on top. // than appending on top.
let body = b"fresh transcription payload that replaces any stale partial".to_vec(); let body = b"fresh transcription payload that replaces any stale partial".to_vec();
let expected_sha = format!("{:x}", sha2::Sha256::digest(&body));
let addr = spawn_no_range_server(body.clone()).await; let addr = spawn_no_range_server(body.clone()).await;
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
@@ -465,7 +514,7 @@ mod tests {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")), url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0), size: kon_core::types::Megabytes(0),
sha256: None, sha256: leak(expected_sha),
}; };
let id = ModelId::new("test-fixture"); let id = ModelId::new("test-fixture");
@@ -520,7 +569,7 @@ mod tests {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")), url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0), size: kon_core::types::Megabytes(0),
sha256: None, sha256: leak("0".repeat(64)),
}; };
let id = ModelId::new("test-fixture"); let id = ModelId::new("test-fixture");
@@ -548,7 +597,7 @@ mod tests {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")), url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0), size: kon_core::types::Megabytes(0),
sha256: Some(leak("deadbeef".repeat(8))), sha256: leak("deadbeef".repeat(8)),
}; };
let id = ModelId::new("test-fixture"); let id = ModelId::new("test-fixture");

View File

@@ -42,7 +42,6 @@ tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
# Phase 5 rituals: register Corbie as a login-time autostart entry. # Phase 5 rituals: register Corbie as a login-time autostart entry.
# Handles platform differences (.desktop file on Linux, LaunchAgents plist # Handles platform differences (.desktop file on Linux, LaunchAgents plist

View File

@@ -9,12 +9,12 @@ fn main() {
println!("cargo:rustc-link-arg=-Wl,--allow-multiple-definition"); println!("cargo:rustc-link-arg=-Wl,--allow-multiple-definition");
} }
assert_localhost_llm_csp(); assert_loopback_llm_csp();
tauri_build::build() tauri_build::build()
} }
/// Regression guard for brief item #2 (pre-emptive localhost LLM scope). /// Regression guard for brief item #2 (pre-emptive loopback LLM scope).
/// ///
/// Kon's bundled llama.cpp server and any BYO Ollama install speak HTTP /// Kon's bundled llama.cpp server and any BYO Ollama install speak HTTP
/// on `127.0.0.1:*`. If the `connect-src` CSP ever drops those entries, /// on `127.0.0.1:*`. If the `connect-src` CSP ever drops those entries,
@@ -25,9 +25,9 @@ fn main() {
/// Parses `tauri.conf.json` properly and inspects the `connect-src` /// Parses `tauri.conf.json` properly and inspects the `connect-src`
/// directive of the CSP, rather than substring-searching the whole /// directive of the CSP, rather than substring-searching the whole
/// file — the latter would both false-pass on unrelated values /// file — the latter would both false-pass on unrelated values
/// containing a localhost URL and false-fail on JSON-escaped forward /// containing a loopback URL and false-fail on JSON-escaped forward
/// slashes. /// slashes.
fn assert_localhost_llm_csp() { fn assert_loopback_llm_csp() {
let conf_path = std::path::Path::new("tauri.conf.json"); let conf_path = std::path::Path::new("tauri.conf.json");
println!("cargo:rerun-if-changed=tauri.conf.json"); println!("cargo:rerun-if-changed=tauri.conf.json");
@@ -70,4 +70,12 @@ fn assert_localhost_llm_csp() {
{connect_src:?}" {connect_src:?}"
); );
} }
for forbidden in ["http://localhost:*", "ws://localhost:*"] {
assert!(
!tokens.contains(&forbidden),
"build.rs: tauri.conf.json CSP connect-src must not permit {forbidden}; \
normalize local LLM endpoints to 127.0.0.1 instead. Current connect-src: \
{connect_src:?}"
);
}
} }

View File

@@ -1,8 +1,8 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "main",
"description": "Capability for the main window", "description": "Main window capability for user-initiated app control.",
"windows": ["main", "tasks-float", "transcript-viewer", "transcription-preview"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",

View File

@@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "secondary-windows",
"description": "Narrow capability for passive secondary windows.",
"windows": ["tasks-float", "transcript-viewer", "transcription-preview"],
"permissions": [
"core:event:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus"
]
}

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]}} {"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}}

View File

@@ -1,19 +1,24 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use tokio::sync::{mpsc as tokio_mpsc, Mutex as AsyncMutex}; use tokio::sync::{mpsc as tokio_mpsc, Mutex as AsyncMutex};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use kon_audio::{DeviceInfo, MicrophoneCapture}; use crate::commands::security::ensure_main_window;
use kon_audio::{DeviceInfo, MicrophoneCapture, WavWriter};
use kon_core::constants::WHISPER_SAMPLE_RATE; use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::types::AudioSamples; use kon_core::types::AudioSamples;
const MAX_NATIVE_CAPTURE_RETURN_SAMPLES: usize = WHISPER_SAMPLE_RATE as usize * 60 * 10;
/// Enumerate every input device available to cpal, with metadata for the /// Enumerate every input device available to cpal, with metadata for the
/// Settings device-picker UI. Includes a flag for likely PulseAudio / /// Settings device-picker UI. Includes a flag for likely PulseAudio /
/// PipeWire monitor sources so the UI can warn the user. /// PipeWire monitor sources so the UI can warn the user.
#[tauri::command] #[tauri::command]
pub async fn list_audio_devices() -> Result<Vec<DeviceInfo>, String> { pub async fn list_audio_devices(window: tauri::WebviewWindow) -> Result<Vec<DeviceInfo>, String> {
ensure_main_window(&window)?;
tokio::task::spawn_blocking(MicrophoneCapture::list_devices) tokio::task::spawn_blocking(MicrophoneCapture::list_devices)
.await .await
.map_err(|e| format!("join error: {e}"))? .map_err(|e| format!("join error: {e}"))?
@@ -51,8 +56,12 @@ pub struct NativeCaptureState {
/// holding the lock — a `std::sync::Mutex` would have to be released /// holding the lock — a `std::sync::Mutex` would have to be released
/// and reacquired around each await point. /// and reacquired around each await point.
worker: AsyncMutex<Option<CaptureWorker>>, worker: AsyncMutex<Option<CaptureWorker>>,
/// All captured samples (16kHz mono) for save_audio. /// Compatibility buffer returned by stop_native_capture. Capped; the
/// authoritative capture is streamed to temp WAV on disk.
all_samples: Arc<Mutex<Vec<f32>>>, all_samples: Arc<Mutex<Vec<f32>>>,
wav_writer: Arc<Mutex<Option<WavWriter>>>,
temp_audio_path: Arc<Mutex<Option<PathBuf>>>,
capture_truncated: Arc<AtomicBool>,
} }
impl NativeCaptureState { impl NativeCaptureState {
@@ -60,6 +69,36 @@ impl NativeCaptureState {
Self { Self {
worker: AsyncMutex::new(None), worker: AsyncMutex::new(None),
all_samples: Arc::new(Mutex::new(Vec::new())), all_samples: Arc::new(Mutex::new(Vec::new())),
wav_writer: Arc::new(Mutex::new(None)),
temp_audio_path: Arc::new(Mutex::new(None)),
capture_truncated: Arc::new(AtomicBool::new(false)),
}
}
}
fn append_recorded_chunk(
all_samples: &Arc<Mutex<Vec<f32>>>,
wav_writer: &Arc<Mutex<Option<WavWriter>>>,
truncated: &Arc<AtomicBool>,
chunk: &[f32],
) {
if let Ok(mut writer) = wav_writer.lock() {
if let Some(writer) = writer.as_mut() {
if let Err(e) = writer.append(chunk) {
eprintln!("[native-capture] temp WAV append failed: {e}");
}
}
}
if let Ok(mut all) = all_samples.lock() {
let remaining = MAX_NATIVE_CAPTURE_RETURN_SAMPLES.saturating_sub(all.len());
if remaining >= chunk.len() {
all.extend_from_slice(chunk);
} else {
if remaining > 0 {
all.extend_from_slice(&chunk[..remaining]);
}
truncated.store(true, Ordering::Relaxed);
} }
} }
} }
@@ -72,10 +111,12 @@ impl NativeCaptureState {
/// user's pick from Settings → Audio → Microphone takes effect. /// user's pick from Settings → Audio → Microphone takes effect.
#[tauri::command] #[tauri::command]
pub async fn start_native_capture( pub async fn start_native_capture(
window: tauri::WebviewWindow,
app: tauri::AppHandle, app: tauri::AppHandle,
state: tauri::State<'_, NativeCaptureState>, state: tauri::State<'_, NativeCaptureState>,
device_name: Option<String>, device_name: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
ensure_main_window(&window)?;
eprintln!( eprintln!(
"[native-capture] start_native_capture called (device='{}')", "[native-capture] start_native_capture called (device='{}')",
device_name.as_deref().unwrap_or("<auto>") device_name.as_deref().unwrap_or("<auto>")
@@ -116,10 +157,19 @@ pub async fn start_native_capture(
let all_samples = state.all_samples.clone(); let all_samples = state.all_samples.clone();
all_samples.lock().unwrap().clear(); all_samples.lock().unwrap().clear();
state.capture_truncated.store(false, Ordering::Relaxed);
let temp_path = std::env::temp_dir().join(recording_filename());
let writer = WavWriter::create(&temp_path, WHISPER_SAMPLE_RATE, 1)
.map_err(|e| format!("Failed to create temp capture WAV: {e}"))?;
*state.wav_writer.lock().unwrap() = Some(writer);
*state.temp_audio_path.lock().unwrap() = Some(temp_path);
let (stop_tx, mut stop_rx) = tokio_mpsc::channel::<()>(1); let (stop_tx, mut stop_rx) = tokio_mpsc::channel::<()>(1);
let all_samples_clone = all_samples.clone(); let all_samples_clone = all_samples.clone();
let wav_writer_clone = state.wav_writer.clone();
let truncated_clone = state.capture_truncated.clone();
// Spawn a task that reads cpal chunks, downsamples to 16kHz mono, // Spawn a task that reads cpal chunks, downsamples to 16kHz mono,
// and emits events to the frontend. The JoinHandle is retained in // and emits events to the frontend. The JoinHandle is retained in
@@ -189,10 +239,12 @@ pub async fn start_native_capture(
while pcm_buffer.len() >= chunk_size { while pcm_buffer.len() >= chunk_size {
let chunk: Vec<f32> = pcm_buffer.drain(..chunk_size).collect(); let chunk: Vec<f32> = pcm_buffer.drain(..chunk_size).collect();
// Store for save_audio append_recorded_chunk(
if let Ok(mut all) = all_samples_clone.lock() { &all_samples_clone,
all.extend_from_slice(&chunk); &wav_writer_clone,
} &truncated_clone,
&chunk,
);
let _ = app.emit( let _ = app.emit(
"native-pcm", "native-pcm",
@@ -213,9 +265,12 @@ pub async fn start_native_capture(
// Emit any remaining samples // Emit any remaining samples
if !pcm_buffer.is_empty() { if !pcm_buffer.is_empty() {
if let Ok(mut all) = all_samples_clone.lock() { append_recorded_chunk(
all.extend_from_slice(&pcm_buffer); &all_samples_clone,
} &wav_writer_clone,
&truncated_clone,
&pcm_buffer,
);
let _ = app.emit( let _ = app.emit(
"native-pcm", "native-pcm",
serde_json::json!({ serde_json::json!({
@@ -228,6 +283,13 @@ pub async fn start_native_capture(
if let Ok(mut cap) = capture_clone.lock() { if let Ok(mut cap) = capture_clone.lock() {
cap.take(); cap.take();
} }
if let Ok(mut writer) = wav_writer_clone.lock() {
if let Some(writer) = writer.take() {
if let Err(e) = writer.finalize() {
eprintln!("[native-capture] temp WAV finalize failed: {e}");
}
}
}
}); });
*state.worker.lock().await = Some(CaptureWorker { stop_tx, join }); *state.worker.lock().await = Some(CaptureWorker { stop_tx, join });
@@ -242,8 +304,10 @@ pub async fn start_native_capture(
/// nothing from a worker that technically outlived the call (RB-06). /// nothing from a worker that technically outlived the call (RB-06).
#[tauri::command] #[tauri::command]
pub async fn stop_native_capture( pub async fn stop_native_capture(
window: tauri::WebviewWindow,
state: tauri::State<'_, NativeCaptureState>, state: tauri::State<'_, NativeCaptureState>,
) -> Result<Vec<f32>, String> { ) -> Result<Vec<f32>, String> {
ensure_main_window(&window)?;
if let Some(worker) = state.worker.lock().await.take() { if let Some(worker) = state.worker.lock().await.take() {
stop_worker(worker).await; stop_worker(worker).await;
} }
@@ -252,6 +316,11 @@ pub async fn stop_native_capture(
let mut all = state.all_samples.lock().unwrap(); let mut all = state.all_samples.lock().unwrap();
std::mem::take(&mut *all) std::mem::take(&mut *all)
}; };
if state.capture_truncated.load(Ordering::Relaxed) {
eprintln!(
"[native-capture] stop_native_capture returned a capped compatibility buffer; temp WAV contains the full capture"
);
}
Ok(samples) Ok(samples)
} }
@@ -461,9 +530,11 @@ pub async fn persist_audio_samples(
/// Save PCM f32 samples as a WAV file. Returns the file path. /// Save PCM f32 samples as a WAV file. Returns the file path.
#[tauri::command] #[tauri::command]
pub async fn save_audio( pub async fn save_audio(
window: tauri::WebviewWindow,
app: tauri::AppHandle, app: tauri::AppHandle,
samples: Vec<f32>, samples: Vec<f32>,
output_folder: Option<String>, output_folder: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
ensure_main_window(&window)?;
persist_audio_samples(&app, samples, output_folder).await persist_audio_samples(&app, samples, output_folder).await
} }

View File

@@ -20,6 +20,7 @@ use kon_storage::{
}; };
use crate::commands::power::active_assertions_snapshot; use crate::commands::power::active_assertions_snapshot;
use crate::commands::security::ensure_main_window;
use crate::AppState; use crate::AppState;
const DEFAULT_RECENT_ERRORS: i64 = 50; const DEFAULT_RECENT_ERRORS: i64 = 50;
@@ -136,9 +137,11 @@ impl From<ErrorLogRow> for ErrorLogDto {
#[tauri::command] #[tauri::command]
pub async fn list_recent_errors_command( pub async fn list_recent_errors_command(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
limit: Option<i64>, limit: Option<i64>,
) -> Result<Vec<ErrorLogDto>, String> { ) -> Result<Vec<ErrorLogDto>, String> {
ensure_main_window(&window)?;
let n = limit.unwrap_or(DEFAULT_RECENT_ERRORS).clamp(1, 1000); let n = limit.unwrap_or(DEFAULT_RECENT_ERRORS).clamp(1, 1000);
list_recent_errors(&state.db, n) list_recent_errors(&state.db, n)
.await .await
@@ -157,7 +160,12 @@ pub struct CrashFile {
} }
#[tauri::command] #[tauri::command]
pub async fn list_crash_files() -> Result<Vec<CrashFile>, String> { pub async fn list_crash_files(window: tauri::WebviewWindow) -> Result<Vec<CrashFile>, String> {
ensure_main_window(&window)?;
list_crash_files_inner().await
}
async fn list_crash_files_inner() -> Result<Vec<CrashFile>, String> {
let dir = crashes_dir(); let dir = crashes_dir();
let entries = match fs::read_dir(&dir) { let entries = match fs::read_dir(&dir) {
Ok(e) => e, Ok(e) => e,
@@ -215,12 +223,72 @@ impl Default for ReportOptions {
Self { Self {
include_recent_errors: true, include_recent_errors: true,
include_crashes: true, include_crashes: true,
include_settings: true, include_settings: false,
include_log_tail: true, include_log_tail: false,
} }
} }
} }
fn redact_home(input: &str) -> String {
match std::env::var("HOME") {
Ok(home) if !home.is_empty() => input.replace(&home, "~"),
_ => input.to_string(),
}
}
fn looks_sensitive_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
lower.contains("path")
|| lower.contains("folder")
|| lower.contains("directory")
|| lower.contains("device")
|| lower.contains("microphone")
}
fn redact_json_value(value: &mut serde_json::Value, key_hint: Option<&str>) {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map.iter_mut() {
redact_json_value(child, Some(key));
}
}
serde_json::Value::Array(items) => {
for item in items {
redact_json_value(item, key_hint);
}
}
serde_json::Value::String(text) => {
if key_hint.map(looks_sensitive_key).unwrap_or(false)
|| text.starts_with('/')
|| text.contains(":\\")
|| text.contains("/home/")
|| text.contains("/Users/")
{
*text = redact_home(text);
}
}
_ => {}
}
}
fn sanitise_preferences_json(raw: &str) -> String {
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(mut value) => {
redact_json_value(&mut value, None);
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
}
Err(_) => "_preferences could not be parsed as JSON_".to_string(),
}
}
fn redact_line(input: &str) -> String {
redact_home(input)
.chars()
.take(400)
.collect::<String>()
.replace('\n', " ")
}
/// Build a single human-readable diagnostic-report string. The user inspects /// Build a single human-readable diagnostic-report string. The user inspects
/// this in Settings → About before deciding whether to copy/save/email it. /// this in Settings → About before deciding whether to copy/save/email it.
/// ///
@@ -228,8 +296,17 @@ impl Default for ReportOptions {
/// Discord thread, or a GitHub issue without further conversion. /// Discord thread, or a GitHub issue without further conversion.
#[tauri::command] #[tauri::command]
pub async fn generate_diagnostic_report( pub async fn generate_diagnostic_report(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
options: Option<ReportOptions>, options: Option<ReportOptions>,
) -> Result<String, String> {
ensure_main_window(&window)?;
generate_diagnostic_report_inner(&state, options).await
}
async fn generate_diagnostic_report_inner(
state: &tauri::State<'_, AppState>,
options: Option<ReportOptions>,
) -> Result<String, String> { ) -> Result<String, String> {
let opts = options.unwrap_or_default(); let opts = options.unwrap_or_default();
let mut out = String::new(); let mut out = String::new();
@@ -241,7 +318,10 @@ pub async fn generate_diagnostic_report(
std::env::consts::OS, std::env::consts::OS,
std::env::consts::ARCH std::env::consts::ARCH
)); ));
out.push_str(&format!("- App data dir: `{}`\n", app_data_dir().display())); out.push_str(&format!(
"- App data dir: `{}`\n",
redact_home(&app_data_dir().display().to_string())
));
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
@@ -258,7 +338,7 @@ pub async fn generate_diagnostic_report(
match kon_storage::get_setting(&state.db, "kon_preferences").await { match kon_storage::get_setting(&state.db, "kon_preferences").await {
Ok(Some(json)) => { Ok(Some(json)) => {
out.push_str("```json\n"); out.push_str("```json\n");
out.push_str(&json); out.push_str(&sanitise_preferences_json(&json));
out.push_str("\n```\n\n"); out.push_str("\n```\n\n");
} }
Ok(None) => out.push_str("_(no preferences saved)_\n\n"), Ok(None) => out.push_str("_(no preferences saved)_\n\n"),
@@ -278,11 +358,7 @@ pub async fn generate_diagnostic_report(
r.context, r.context,
r.error_code.as_deref().unwrap_or("?"), r.error_code.as_deref().unwrap_or("?"),
if r.metadata.is_some() { " (+meta)" } else { "" }, if r.metadata.is_some() { " (+meta)" } else { "" },
r.message redact_line(&r.message),
.chars()
.take(400)
.collect::<String>()
.replace('\n', " "),
)); ));
} }
out.push('\n'); out.push('\n');
@@ -307,7 +383,7 @@ pub async fn generate_diagnostic_report(
if opts.include_crashes { if opts.include_crashes {
out.push_str("## Crash dumps\n\n"); out.push_str("## Crash dumps\n\n");
let crashes = list_crash_files().await.unwrap_or_default(); let crashes = list_crash_files_inner().await.unwrap_or_default();
if crashes.is_empty() { if crashes.is_empty() {
out.push_str("_(no crash dumps on disk — nothing has crashed)_\n\n"); out.push_str("_(no crash dumps on disk — nothing has crashed)_\n\n");
} else { } else {
@@ -321,7 +397,7 @@ pub async fn generate_diagnostic_report(
out.push_str(&format!( out.push_str(&format!(
"_(plus {} older crash dumps in `{}`)_\n\n", "_(plus {} older crash dumps in `{}`)_\n\n",
crashes.len() - 5, crashes.len() - 5,
crashes_dir().display() redact_home(&crashes_dir().display().to_string())
)); ));
} }
} }
@@ -336,7 +412,7 @@ pub async fn generate_diagnostic_report(
} }
Ok(tail) => { Ok(tail) => {
out.push_str("```\n"); out.push_str("```\n");
out.push_str(&tail); out.push_str(&redact_home(&tail));
out.push_str("\n```\n\n"); out.push_str("\n```\n\n");
} }
Err(_) => out.push_str("_(no log file found)_\n\n"), Err(_) => out.push_str("_(no log file found)_\n\n"),
@@ -435,14 +511,16 @@ pub fn get_os_info() -> OsInfo {
/// somewhere they can attach to an email. /// somewhere they can attach to an email.
#[tauri::command] #[tauri::command]
pub async fn save_diagnostic_report( pub async fn save_diagnostic_report(
window: tauri::WebviewWindow,
app: tauri::AppHandle, app: tauri::AppHandle,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
options: Option<ReportOptions>, options: Option<ReportOptions>,
) -> Result<String, String> { ) -> Result<String, String> {
ensure_main_window(&window)?;
let _ = app; // reserved for future dialog integration let _ = app; // reserved for future dialog integration
let report = generate_diagnostic_report(state, options).await?; let report = generate_diagnostic_report_inner(&state, options).await?;
let dir = app_data_dir().join("diagnostic-reports"); let dir = kon_storage::app_data_dir().join("diagnostic-reports");
fs::create_dir_all(&dir).map_err(|e| format!("create dir: {e}"))?; fs::create_dir_all(&dir).map_err(|e| format!("create dir: {e}"))?;
let ts = SystemTime::now() let ts = SystemTime::now()

View File

@@ -17,6 +17,7 @@ use crate::commands::audio::resolve_recording_path;
use crate::commands::build_initial_prompt; use crate::commands::build_initial_prompt;
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded}; use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
use crate::commands::power::PowerAssertion; use crate::commands::power::PowerAssertion;
use crate::commands::security::ensure_main_window;
use crate::AppState; use crate::AppState;
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}; use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use kon_audio::{ use kon_audio::{
@@ -481,6 +482,7 @@ struct SpeechGateDecision {
#[tauri::command] #[tauri::command]
pub async fn start_live_transcription_session( pub async fn start_live_transcription_session(
window: tauri::WebviewWindow,
app: tauri::AppHandle, app: tauri::AppHandle,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
live_state: tauri::State<'_, LiveTranscriptionState>, live_state: tauri::State<'_, LiveTranscriptionState>,
@@ -488,6 +490,7 @@ pub async fn start_live_transcription_session(
result_channel: Channel<LiveResultMessage>, result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>, status_channel: Channel<LiveStatusMessage>,
) -> Result<StartLiveTranscriptionResponse, String> { ) -> Result<StartLiveTranscriptionResponse, String> {
ensure_main_window(&window)?;
let _lifecycle = live_state.lifecycle.lock().await; let _lifecycle = live_state.lifecycle.lock().await;
{ {
let running = live_state.running.lock().unwrap(); let running = live_state.running.lock().unwrap();
@@ -586,10 +589,12 @@ pub async fn start_live_transcription_session(
#[tauri::command] #[tauri::command]
pub async fn stop_live_transcription_session( pub async fn stop_live_transcription_session(
window: tauri::WebviewWindow,
app: tauri::AppHandle, app: tauri::AppHandle,
live_state: tauri::State<'_, LiveTranscriptionState>, live_state: tauri::State<'_, LiveTranscriptionState>,
session_id: u64, session_id: u64,
) -> Result<StopLiveTranscriptionResponse, String> { ) -> Result<StopLiveTranscriptionResponse, String> {
ensure_main_window(&window)?;
let _lifecycle = live_state.lifecycle.lock().await; let _lifecycle = live_state.lifecycle.lock().await;
let running = live_state.running.lock().unwrap().take(); let running = live_state.running.lock().unwrap().take();
let Some(running) = running else { let Some(running) = running else {

View File

@@ -1,6 +1,7 @@
use tauri::{Emitter, State}; use tauri::{Emitter, State};
use crate::commands::power::PowerAssertion; use crate::commands::power::PowerAssertion;
use crate::commands::security::ensure_main_window;
use crate::AppState; use crate::AppState;
use kon_ai_formatting::{llm_cleanup_text, LlmPromptPreset}; use kon_ai_formatting::{llm_cleanup_text, LlmPromptPreset};
use kon_core::hardware; use kon_core::hardware;
@@ -58,7 +59,12 @@ pub fn check_llm_model(
} }
#[tauri::command] #[tauri::command]
pub async fn download_llm_model(app: tauri::AppHandle, model_id: String) -> Result<(), String> { pub async fn download_llm_model(
window: tauri::WebviewWindow,
app: tauri::AppHandle,
model_id: String,
) -> Result<(), String> {
ensure_main_window(&window)?;
let id = parse_model_id(model_id)?; let id = parse_model_id(model_id)?;
let app_clone = app.clone(); let app_clone = app.clone();
model_manager::download_model(id, move |done, total| { model_manager::download_model(id, move |done, total| {
@@ -83,11 +89,13 @@ pub async fn download_llm_model(app: tauri::AppHandle, model_id: String) -> Resu
#[tauri::command] #[tauri::command]
pub async fn load_llm_model( pub async fn load_llm_model(
window: tauri::WebviewWindow,
state: State<'_, AppState>, state: State<'_, AppState>,
model_id: String, model_id: String,
use_gpu: Option<bool>, use_gpu: Option<bool>,
concurrent: Option<bool>, concurrent: Option<bool>,
) -> Result<(), String> { ) -> Result<(), String> {
ensure_main_window(&window)?;
let id = parse_model_id(model_id)?; let id = parse_model_id(model_id)?;
let path = model_manager::model_path(id); let path = model_manager::model_path(id);
if !path.exists() { if !path.exists() {
@@ -112,12 +120,21 @@ pub async fn load_llm_model(
} }
#[tauri::command] #[tauri::command]
pub fn unload_llm_model(state: State<'_, AppState>) -> Result<(), String> { pub fn unload_llm_model(
window: tauri::WebviewWindow,
state: State<'_, AppState>,
) -> Result<(), String> {
ensure_main_window(&window)?;
state.llm_engine.unload().map_err(|e| e.to_string()) state.llm_engine.unload().map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub fn delete_llm_model(state: State<'_, AppState>, model_id: String) -> Result<(), String> { pub fn delete_llm_model(
window: tauri::WebviewWindow,
state: State<'_, AppState>,
model_id: String,
) -> Result<(), String> {
ensure_main_window(&window)?;
let id = parse_model_id(model_id)?; let id = parse_model_id(model_id)?;
if state.llm_engine.loaded_model_id().as_deref() == Some(id.as_str()) { if state.llm_engine.loaded_model_id().as_deref() == Some(id.as_str()) {
state.llm_engine.unload().map_err(|e| e.to_string())?; state.llm_engine.unload().map_err(|e| e.to_string())?;
@@ -168,9 +185,11 @@ pub struct LlmTestResult {
/// LLMs, adapted to Kon's local stack. /// LLMs, adapted to Kon's local stack.
#[tauri::command] #[tauri::command]
pub async fn test_llm_model( pub async fn test_llm_model(
window: tauri::WebviewWindow,
state: State<'_, AppState>, state: State<'_, AppState>,
model_id: String, model_id: String,
) -> Result<LlmTestResult, String> { ) -> Result<LlmTestResult, String> {
ensure_main_window(&window)?;
let id = parse_model_id(model_id)?; let id = parse_model_id(model_id)?;
let info = model_info(id); let info = model_info(id);
let path = model_manager::model_path(id); let path = model_manager::model_path(id);
@@ -342,11 +361,13 @@ mod tests {
#[tauri::command] #[tauri::command]
pub async fn cleanup_transcript_text_cmd( pub async fn cleanup_transcript_text_cmd(
window: tauri::WebviewWindow,
state: State<'_, AppState>, state: State<'_, AppState>,
transcript: String, transcript: String,
profile_id: Option<String>, profile_id: Option<String>,
preset: Option<String>, preset: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
ensure_main_window(&window)?;
let resolved_profile_id = let resolved_profile_id =
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string()); profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
let profile_terms: Vec<String> = let profile_terms: Vec<String> =

View File

@@ -12,6 +12,7 @@ pub mod paste;
pub mod power; pub mod power;
pub mod profiles; pub mod profiles;
pub mod rituals; pub mod rituals;
pub mod security;
pub mod tasks; pub mod tasks;
pub mod transcription; pub mod transcription;
pub mod transcripts; pub mod transcripts;

View File

@@ -3,6 +3,7 @@ use std::sync::Arc;
use serde::Serialize; use serde::Serialize;
use tauri::Emitter; use tauri::Emitter;
use crate::commands::security::ensure_main_window;
use crate::AppState; use crate::AppState;
use kon_core::constants::WHISPER_SAMPLE_RATE; use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::hardware::{self, CpuFeatures}; use kon_core::hardware::{self, CpuFeatures};
@@ -221,6 +222,16 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
}); });
} }
#[tauri::command]
pub async fn prewarm_default_model_cmd(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
ensure_main_window(&window)?;
prewarm_default_model(state.whisper_engine.clone());
Ok(())
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RuntimeCapabilities { pub struct RuntimeCapabilities {
@@ -531,7 +542,12 @@ pub fn get_runtime_capabilities(
// --- Whisper model commands --- // --- Whisper model commands ---
#[tauri::command] #[tauri::command]
pub async fn download_model(app: tauri::AppHandle, size: String) -> Result<String, String> { pub async fn download_model(
window: tauri::WebviewWindow,
app: tauri::AppHandle,
size: String,
) -> Result<String, String> {
ensure_main_window(&window)?;
let id = whisper_model_id(&size); let id = whisper_model_id(&size);
let app_clone = app.clone(); let app_clone = app.clone();
model_manager::download(&id, move |progress| { model_manager::download(&id, move |progress| {
@@ -568,10 +584,12 @@ pub fn list_models() -> Result<Vec<String>, String> {
#[tauri::command] #[tauri::command]
pub async fn load_model( pub async fn load_model(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
size: String, size: String,
concurrent: Option<bool>, concurrent: Option<bool>,
) -> Result<String, String> { ) -> Result<String, String> {
ensure_main_window(&window)?;
let id = whisper_model_id(&size); let id = whisper_model_id(&size);
ensure_model_loaded(&state, "whisper", id.as_str(), concurrent).await?; ensure_model_loaded(&state, "whisper", id.as_str(), concurrent).await?;
Ok(format!("Model {} loaded", size)) Ok(format!("Model {} loaded", size))
@@ -586,9 +604,11 @@ pub fn check_engine(state: tauri::State<AppState>) -> Result<bool, String> {
#[tauri::command] #[tauri::command]
pub async fn download_parakeet_model( pub async fn download_parakeet_model(
window: tauri::WebviewWindow,
app: tauri::AppHandle, app: tauri::AppHandle,
name: String, name: String,
) -> Result<String, String> { ) -> Result<String, String> {
ensure_main_window(&window)?;
let id = parakeet_model_id(&name); let id = parakeet_model_id(&name);
let app_clone = app.clone(); let app_clone = app.clone();
model_manager::download(&id, move |progress| { model_manager::download(&id, move |progress| {
@@ -620,10 +640,12 @@ pub fn list_parakeet_models() -> Result<Vec<String>, String> {
#[tauri::command] #[tauri::command]
pub async fn load_parakeet_model( pub async fn load_parakeet_model(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
name: String, name: String,
concurrent: Option<bool>, concurrent: Option<bool>,
) -> Result<String, String> { ) -> Result<String, String> {
ensure_main_window(&window)?;
let id = parakeet_model_id(&name); let id = parakeet_model_id(&name);
ensure_model_loaded(&state, "parakeet", id.as_str(), concurrent).await?; ensure_model_loaded(&state, "parakeet", id.as_str(), concurrent).await?;
Ok(format!("Parakeet model {} loaded", name)) Ok(format!("Parakeet model {} loaded", name))

View File

@@ -0,0 +1,30 @@
pub fn ensure_main_window(window: &tauri::WebviewWindow) -> Result<(), String> {
ensure_main_window_label(window.label())
}
pub fn ensure_main_window_label(label: &str) -> Result<(), String> {
if label == "main" {
Ok(())
} else {
Err(format!(
"This command is only available from the main window (got {label})."
))
}
}
#[cfg(test)]
mod tests {
use super::ensure_main_window_label;
#[test]
fn accepts_main_window() {
assert!(ensure_main_window_label("main").is_ok());
}
#[test]
fn rejects_secondary_windows() {
for label in ["tasks-float", "transcript-viewer", "transcription-preview"] {
assert!(ensure_main_window_label(label).is_err());
}
}
}

View File

@@ -9,6 +9,7 @@ use tauri::Emitter;
use crate::commands::build_initial_prompt; use crate::commands::build_initial_prompt;
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded}; use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
use crate::commands::security::ensure_main_window;
use crate::AppState; use crate::AppState;
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}; use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use kon_core::constants::WHISPER_SAMPLE_RATE; use kon_core::constants::WHISPER_SAMPLE_RATE;
@@ -20,6 +21,7 @@ const PARAKEET_CHUNK_OVERLAP_SECS: usize = 1;
const FILE_CHUNK_THRESHOLD_SECS: usize = 8 * 60; const FILE_CHUNK_THRESHOLD_SECS: usize = 8 * 60;
const FILE_CHUNK_SECS: usize = 3 * 60; const FILE_CHUNK_SECS: usize = 3 * 60;
const FILE_CHUNK_OVERLAP_SECS: usize = 2; const FILE_CHUNK_OVERLAP_SECS: usize = 2;
const MAX_FILE_TRANSCRIPTION_SECS: f64 = 2.0 * 60.0 * 60.0;
struct ChunkingStrategy { struct ChunkingStrategy {
chunk_samples: usize, chunk_samples: usize,
@@ -138,6 +140,7 @@ fn transcribe_samples_sync(
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event. /// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
#[tauri::command] #[tauri::command]
pub async fn transcribe_pcm( pub async fn transcribe_pcm(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app: tauri::AppHandle, app: tauri::AppHandle,
samples: Vec<f32>, samples: Vec<f32>,
@@ -150,6 +153,7 @@ pub async fn transcribe_pcm(
format_mode: String, format_mode: String,
profile_id: Option<String>, profile_id: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
ensure_main_window(&window)?;
let resolved_profile_id = let resolved_profile_id =
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string()); profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
@@ -230,6 +234,7 @@ fn join_segment_text(segments: &[Segment]) -> String {
/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper. /// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper.
#[tauri::command] #[tauri::command]
pub async fn transcribe_file( pub async fn transcribe_file(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
path: String, path: String,
engine: Option<String>, engine: Option<String>,
@@ -242,6 +247,7 @@ pub async fn transcribe_file(
format_mode: String, format_mode: String,
profile_id: Option<String>, profile_id: Option<String>,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
ensure_main_window(&window)?;
let resolved_profile_id = let resolved_profile_id =
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string()); profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
@@ -275,9 +281,24 @@ pub async fn transcribe_file(
), ),
}; };
let engine_name_for_worker = engine_name.clone(); let engine_name_for_worker = engine_name.clone();
let path_for_probe = Path::new(&path);
if let Some(duration_secs) =
kon_audio::probe_audio_duration_secs(path_for_probe).map_err(|e| e.to_string())?
{
if duration_secs > MAX_FILE_TRANSCRIPTION_SECS {
return Err(format!(
"File is {:.1} hours long. Kon imports up to 2 hours at a time.",
duration_secs / 3600.0
));
}
}
let timed = tokio::task::spawn_blocking(move || { let timed = tokio::task::spawn_blocking(move || {
let audio = kon_audio::decode_audio_file(Path::new(&path)).map_err(|e| e.to_string())?; let audio = kon_audio::decode_audio_file_limited(
Path::new(&path),
Some(MAX_FILE_TRANSCRIPTION_SECS),
)
.map_err(|e| e.to_string())?;
let resampled = kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?; let resampled = kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?;
transcribe_samples_sync( transcribe_samples_sync(
engine, engine,
@@ -319,6 +340,7 @@ pub async fn transcribe_file(
/// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event. /// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event.
#[tauri::command] #[tauri::command]
pub async fn transcribe_pcm_parakeet( pub async fn transcribe_pcm_parakeet(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app: tauri::AppHandle, app: tauri::AppHandle,
samples: Vec<f32>, samples: Vec<f32>,
@@ -329,6 +351,7 @@ pub async fn transcribe_pcm_parakeet(
format_mode: String, format_mode: String,
profile_id: Option<String>, profile_id: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
ensure_main_window(&window)?;
let resolved_profile_id = let resolved_profile_id =
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string()); profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());

View File

@@ -1,38 +1,16 @@
use tauri::AppHandle; use crate::commands::security::ensure_main_window;
use tauri_plugin_updater::UpdaterExt;
/// Check for an available update. Returns Some(version_string) if one is /// Check for an available update. Returns Some(version_string) if one is
/// available, None if already up to date, and Err if the check fails. /// available, None if already up to date, and Err if the check fails.
#[tauri::command] #[tauri::command]
pub async fn check_for_update(app: AppHandle) -> Result<Option<String>, String> { pub async fn check_for_update(window: tauri::WebviewWindow) -> Result<Option<String>, String> {
let update = app ensure_main_window(&window)?;
.updater() Ok(None)
.map_err(|e| format!("Updater not available: {e}"))?
.check()
.await
.map_err(|e| format!("Update check failed: {e}"))?;
match update {
Some(u) => Ok(Some(u.version.to_string())),
None => Ok(None),
}
} }
/// Download and stage the update. The app will install it on next launch. /// Download and stage the update. The app will install it on next launch.
#[tauri::command] #[tauri::command]
pub async fn install_update(app: AppHandle) -> Result<(), String> { pub async fn install_update(window: tauri::WebviewWindow) -> Result<(), String> {
let update = app ensure_main_window(&window)?;
.updater() Err("Updates are disabled until release signing is configured.".to_string())
.map_err(|e| format!("Updater not available: {e}"))?
.check()
.await
.map_err(|e| format!("Update check failed: {e}"))?
.ok_or_else(|| "No update available".to_string())?;
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|e| format!("Install failed: {e}"))?;
Ok(())
} }

View File

@@ -129,7 +129,6 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
// Phase 5 rituals: autostart. The plugin registers JS-facing // Phase 5 rituals: autostart. The plugin registers JS-facing
// commands (isEnabled / enable / disable) that the Settings // commands (isEnabled / enable / disable) that the Settings
// toggle and first-run prompt invoke directly — no bespoke // toggle and first-run prompt invoke directly — no bespoke
@@ -177,8 +176,11 @@ pub fn run() {
{ {
main_window main_window
.with_webview(|webview| { .with_webview(|webview| {
use webkit2gtk::glib::prelude::Cast;
use webkit2gtk::{ use webkit2gtk::{
PermissionRequest, PermissionRequestExt, SettingsExt, WebViewExt, PermissionRequest, PermissionRequestExt, SettingsExt,
UserMediaPermissionRequest, UserMediaPermissionRequestExt,
WebViewExt,
}; };
let wv: webkit2gtk::WebView = webview.inner().clone(); let wv: webkit2gtk::WebView = webview.inner().clone();
@@ -189,11 +191,25 @@ pub fn run() {
settings.set_enable_media_capabilities(true); settings.set_enable_media_capabilities(true);
} }
// Auto-grant all permission requests (audio/video capture) // Auto-grant microphone capture only. Other WebKitGTK
// permission requests are denied so future surfaces do
// not inherit camera/geolocation/pointer-lock access.
WebViewExt::connect_permission_request( WebViewExt::connect_permission_request(
&wv, &wv,
|_wv, request: &PermissionRequest| { |_wv, request: &PermissionRequest| {
if let Ok(media) =
request.clone().downcast::<UserMediaPermissionRequest>()
{
if media.is_for_audio_device()
&& !media.is_for_video_device()
{
request.allow(); request.allow();
} else {
request.deny();
}
} else {
request.deny();
}
true true
}, },
); );
@@ -236,11 +252,6 @@ pub fn run() {
llm_engine: Arc::new(LlmEngine::new()), llm_engine: Arc::new(LlmEngine::new()),
}); });
{
let whisper = app.state::<AppState>().whisper_engine.clone();
crate::commands::models::prewarm_default_model(whisper);
}
// Runtime-warning banner: push CPU-feature + Vulkan-loader // Runtime-warning banner: push CPU-feature + Vulkan-loader
// fallbacks to the frontend so Settings can render a one-line // fallbacks to the frontend so Settings can render a one-line
// hint. No-ops on a fully-supported box. // hint. No-ops on a fully-supported box.
@@ -260,6 +271,7 @@ pub fn run() {
commands::models::check_model, commands::models::check_model,
commands::models::list_models, commands::models::list_models,
commands::models::load_model, commands::models::load_model,
commands::models::prewarm_default_model_cmd,
commands::models::check_engine, commands::models::check_engine,
commands::models::get_runtime_capabilities, commands::models::get_runtime_capabilities,
// Local LLM management // Local LLM management

View File

@@ -23,16 +23,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; connect-src ipc: http://ipc.localhost asset: https://asset.localhost http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; media-src 'self' asset: https://asset.localhost" "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; connect-src ipc: http://ipc.localhost asset: https://asset.localhost http://127.0.0.1:* ws://127.0.0.1:*; media-src 'self' asset: https://asset.localhost"
}
},
"plugins": {
"updater": {
"endpoints": [
"https://github.com/jakejars/kon/releases/latest/download/latest.json"
],
"dialog": false,
"pubkey": ""
} }
}, },
"bundle": { "bundle": {

View File

@@ -0,0 +1,93 @@
use std::fs;
use std::path::Path;
use serde_json::Value;
fn manifest_dir() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
}
fn read_json(path: impl AsRef<Path>) -> Value {
let raw = fs::read_to_string(path.as_ref()).expect("read json file");
serde_json::from_str(&raw).expect("valid json")
}
#[test]
fn csp_keeps_loopback_narrow() {
let conf = read_json(manifest_dir().join("tauri.conf.json"));
let csp = conf
.pointer("/app/security/csp")
.and_then(Value::as_str)
.expect("csp string");
let connect_src = csp
.split(';')
.map(str::trim)
.find(|directive| directive.starts_with("connect-src "))
.expect("connect-src directive");
assert!(connect_src.contains("http://127.0.0.1:*"));
assert!(connect_src.contains("ws://127.0.0.1:*"));
assert!(!connect_src.contains("http://localhost:*"));
assert!(!connect_src.contains("ws://localhost:*"));
}
#[test]
fn updater_is_signed_or_absent() {
let conf = read_json(manifest_dir().join("tauri.conf.json"));
if let Some(updater) = conf.pointer("/plugins/updater") {
let pubkey = updater
.get("pubkey")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
!pubkey.trim().is_empty(),
"updater config must not ship with an empty pubkey"
);
}
}
#[test]
fn secondary_windows_do_not_get_high_risk_plugin_permissions() {
let denied = [
"dialog:",
"autostart:",
"global-shortcut:",
"opener:",
"updater:",
];
let capability_dir = manifest_dir().join("capabilities");
for entry in fs::read_dir(capability_dir).expect("capabilities dir") {
let path = entry.expect("capability entry").path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let capability = read_json(&path);
let windows = capability
.get("windows")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let applies_to_secondary = windows.iter().any(|window| {
matches!(
window.as_str(),
Some("tasks-float" | "transcript-viewer" | "transcription-preview")
)
});
if !applies_to_secondary {
continue;
}
let permissions = capability
.get("permissions")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for permission in permissions.iter().filter_map(Value::as_str) {
assert!(
!denied.iter().any(|prefix| permission.starts_with(prefix)),
"{} grants high-risk permission {permission} to a secondary window",
path.display()
);
}
}
}

View File

@@ -1805,6 +1805,14 @@
{/if} {/if}
</p> </p>
</div> </div>
<div class="mt-5">
<Toggle
bind:checked={settings.prewarmModelOnStartup}
label="Prewarm transcription model on startup"
description="Loads Whisper after the app opens. Faster first capture, but higher idle memory use."
/>
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -64,6 +64,7 @@ const defaults: SettingsState = {
llmModelId: null, llmModelId: null,
llmPromptPreset: "default", llmPromptPreset: "default",
aiGpuConcurrency: "parallel", aiGpuConcurrency: "parallel",
prewarmModelOnStartup: false,
saveAudio: false, saveAudio: false,
outputFolder: "", outputFolder: "",
globalHotkey: "Ctrl+Shift+R", globalHotkey: "Ctrl+Shift+R",

View File

@@ -51,6 +51,7 @@ export interface SettingsState {
llmModelId: LlmModelIdStr | null; llmModelId: LlmModelIdStr | null;
llmPromptPreset: LlmPromptPreset; llmPromptPreset: LlmPromptPreset;
aiGpuConcurrency: AiGpuConcurrency; aiGpuConcurrency: AiGpuConcurrency;
prewarmModelOnStartup: boolean;
saveAudio: boolean; saveAudio: boolean;
outputFolder: string; outputFolder: string;
globalHotkey: string; globalHotkey: string;

View File

@@ -324,6 +324,10 @@
// calls from DictationPage around cleanup_transcript_text_cmd. // calls from DictationPage around cleanup_transcript_text_cmd.
refreshLlmStatus(settings.aiTier).catch(() => {}); refreshLlmStatus(settings.aiTier).catch(() => {});
if (settings.prewarmModelOnStartup) {
invoke("prewarm_default_model_cmd").catch(() => {});
}
// Meeting auto-capture: poll the process list and toast when a match // Meeting auto-capture: poll the process list and toast when a match
// appears (edge-triggered — no repeat toasts until the app goes away // appears (edge-triggered — no repeat toasts until the app goes away
// and comes back). We never start recording from this signal; the // and comes back). We never start recording from this signal; the