Compare commits
2 Commits
55b34d8ffc
...
eebea8cb9a
| Author | SHA1 | Date | |
|---|---|---|---|
| eebea8cb9a | |||
| b333c6229e |
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -169,3 +169,13 @@ jobs:
|
||||
path: ${{ matrix.artifact_glob }}
|
||||
retention-days: 30
|
||||
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
|
||||
|
||||
17
.github/workflows/check.yml
vendored
17
.github/workflows/check.yml
vendored
@@ -111,6 +111,8 @@ jobs:
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
# Cache the Cargo target dir + registry per OS so the heavy
|
||||
# whisper-rs-sys C++ build only happens on a clean cache.
|
||||
@@ -128,12 +130,24 @@ jobs:
|
||||
- name: cargo check (workspace)
|
||||
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
|
||||
# the macOS + Windows legs focused on compile coverage.
|
||||
- name: cargo test (workspace, libs)
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: cargo test --workspace --lib
|
||||
|
||||
- name: cargo audit
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: |
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit
|
||||
|
||||
frontend:
|
||||
name: svelte build + lint
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -150,6 +164,9 @@ jobs:
|
||||
- name: Install JS deps
|
||||
run: npm ci
|
||||
|
||||
- name: npm audit
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
# `tauri build` inside check.yml would trigger the full Rust build
|
||||
# which is owned by the rust job. Here we only validate that the
|
||||
# Svelte/Vite frontend compiles cleanly.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@ target/
|
||||
build/
|
||||
dist/
|
||||
.svelte-kit/
|
||||
Cargo.lock
|
||||
.firecrawl/
|
||||
.worktrees/
|
||||
.cargo/
|
||||
|
||||
7893
Cargo.lock
generated
Normal file
7893
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,10 @@
|
||||
[workspace]
|
||||
members = ["src-tauri", "crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
@@ -21,6 +21,13 @@ use kon_core::types::AudioSamples;
|
||||
/// input silently returned `Ok` with whatever had decoded before the
|
||||
/// failure — flagged by the 2026-04-22 review (RB-09).
|
||||
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)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
/// tests can inject a custom `MediaSource` (for example, one that
|
||||
/// 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()
|
||||
.format(
|
||||
hint,
|
||||
@@ -61,6 +103,7 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
|
||||
}
|
||||
|
||||
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()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
@@ -222,7 +274,7 @@ mod tests {
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("wav");
|
||||
|
||||
let result = decode_media_stream(mss, &hint);
|
||||
let result = decode_media_stream(mss, &hint, None);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"mid-stream I/O error must surface, got: {result:?}"
|
||||
|
||||
@@ -8,7 +8,7 @@ pub mod wav;
|
||||
|
||||
pub use capture::{AudioChunk, CaptureRuntimeError, DeviceInfo, MicrophoneCapture};
|
||||
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 streaming_resample::StreamingResampler;
|
||||
pub use vad::SpeechDetector;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod constants;
|
||||
pub mod error;
|
||||
pub mod hardware;
|
||||
pub mod model_registry;
|
||||
pub mod paths;
|
||||
pub mod process_watch;
|
||||
pub mod recommendation;
|
||||
pub mod types;
|
||||
|
||||
@@ -40,8 +40,8 @@ pub struct ModelFile {
|
||||
pub filename: &'static str,
|
||||
pub url: &'static str,
|
||||
pub size: Megabytes,
|
||||
/// SHA256 hex digest for integrity verification. None to skip check.
|
||||
pub sha256: Option<&'static str>,
|
||||
/// SHA256 hex digest for integrity verification.
|
||||
pub sha256: &'static str,
|
||||
}
|
||||
|
||||
/// All metadata for a single downloadable model.
|
||||
@@ -74,27 +74,27 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
files: vec![
|
||||
ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "3e0581fda6ab843888b51e56d7ee78b6d5bc3237ec113af1f732d1d5286aa155",
|
||||
},
|
||||
ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "a449f49acd68979d418651dd2dcb737cc0f1bf0225e009e29ee326354edbf7d3",
|
||||
},
|
||||
ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "a9fde1486ebfcc08f328d75ad4610c67835fea58c73ba57e3209a6f6cf019e9f",
|
||||
},
|
||||
ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "ec182b70dd42113aff6c5372c75cac58c952443eb22322f57bbd7f53977d497d",
|
||||
},
|
||||
],
|
||||
description: "Fastest local model — near-instant transcription",
|
||||
@@ -110,9 +110,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f",
|
||||
}],
|
||||
description: "Bundled with app — works instantly",
|
||||
},
|
||||
@@ -127,9 +127,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002",
|
||||
}],
|
||||
description: "Good balance of speed and accuracy",
|
||||
},
|
||||
@@ -144,9 +144,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d",
|
||||
}],
|
||||
description: "Accuracy-first English transcription",
|
||||
},
|
||||
@@ -161,9 +161,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "7691eb11167ab7aaf6b3e05d8266f2fd9ad89c550e433f86ac266ebdee6c970a",
|
||||
}],
|
||||
description: "Small accuracy, ~6\u{00d7} faster — distilled variant",
|
||||
},
|
||||
@@ -178,9 +178,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356",
|
||||
}],
|
||||
description: "Best Whisper accuracy — needs 4+ GB RAM",
|
||||
},
|
||||
@@ -195,9 +195,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
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),
|
||||
sha256: None,
|
||||
sha256: "2883a11b90fb10ed592d826edeaee7d2929bf1ab985109fe9e1e7b4d2b69a298",
|
||||
}],
|
||||
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> {
|
||||
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
125
crates/core/src/paths.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
dirs = "6"
|
||||
kon-core = { path = "../core" }
|
||||
encoding_rs = "0.8"
|
||||
futures-util = "0.3"
|
||||
llama-cpp-2 = { version = "0.1.144", default-features = false, features = ["openmp", "vulkan"] }
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -158,6 +159,36 @@ const ALL_MODELS: &[LlmModelId] = &[
|
||||
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] {
|
||||
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 {
|
||||
if cfg!(target_os = "windows") {
|
||||
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")
|
||||
}
|
||||
kon_core::paths::app_paths().llm_models_dir()
|
||||
}
|
||||
|
||||
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
|
||||
F: FnMut(u64, u64) + Send + 'static,
|
||||
{
|
||||
let _reservation = DownloadReservation::acquire(id)?;
|
||||
let dest = model_path(id);
|
||||
tokio::fs::create_dir_all(model_dir()).await?;
|
||||
|
||||
|
||||
@@ -1,79 +1,28 @@
|
||||
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 {
|
||||
#[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());
|
||||
|
||||
// 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")
|
||||
}
|
||||
kon_core::paths::app_paths().app_data_dir()
|
||||
}
|
||||
|
||||
/// Path to the SQLite database file.
|
||||
pub fn database_path() -> PathBuf {
|
||||
app_data_dir().join("kon.db")
|
||||
kon_core::paths::app_paths().database_path()
|
||||
}
|
||||
|
||||
/// Directory for saved audio recordings.
|
||||
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.
|
||||
/// Each crash is a single text file: `<unix-ts>-<short-id>.crash`.
|
||||
/// Used by the diagnostic-report bundler in Settings → About.
|
||||
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).
|
||||
/// Subscribers configured in src-tauri/src/lib.rs at startup.
|
||||
pub fn logs_dir() -> PathBuf {
|
||||
app_data_dir().join("logs")
|
||||
kon_core::paths::app_paths().logs_dir()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"]
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
|
||||
# Model downloads
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Download integrity verification
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::model_registry::{find_model, ModelFile};
|
||||
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.
|
||||
/// Windows: %LOCALAPPDATA%/kon/models
|
||||
/// Unix: ~/.kon/models
|
||||
pub fn models_dir() -> 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").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")
|
||||
}
|
||||
kon_core::paths::app_paths().models_dir()
|
||||
}
|
||||
|
||||
/// Get the directory path where a specific model's files are stored.
|
||||
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.
|
||||
@@ -39,6 +56,7 @@ pub fn is_downloaded(id: &ModelId) -> bool {
|
||||
};
|
||||
let dir = model_dir(id);
|
||||
entry.files.iter().all(|f| dir.join(f.filename).exists())
|
||||
&& verified_manifest_matches(entry, &dir)
|
||||
}
|
||||
|
||||
/// List all downloaded model IDs.
|
||||
@@ -61,6 +79,7 @@ pub async fn download(
|
||||
id: &ModelId,
|
||||
progress: impl Fn(DownloadProgress) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let _reservation = DownloadReservation::acquire(id)?;
|
||||
let entry = find_model(id).ok_or_else(|| KonError::ModelNotFound(id.clone()))?;
|
||||
|
||||
let dir = model_dir(id);
|
||||
@@ -69,36 +88,70 @@ pub async fn download(
|
||||
for file in &entry.files {
|
||||
let dest = dir.join(file.filename);
|
||||
if dest.exists() {
|
||||
if let Some(expected_sha) = file.sha256 {
|
||||
// Validate the existing file. If the hash doesn't match,
|
||||
// the file is corrupt (partial download, tampering, bit
|
||||
// rot) and we must re-fetch it to avoid crashing on
|
||||
// model load later.
|
||||
match sha256_of_file(&dest) {
|
||||
Ok(actual) if actual.eq_ignore_ascii_case(expected_sha) => continue,
|
||||
Ok(_actual) => {
|
||||
// Corrupt — remove + fall through to re-download.
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"failed to verify existing {}: {e}",
|
||||
file.filename
|
||||
)));
|
||||
}
|
||||
// Validate the existing file. If the hash doesn't match,
|
||||
// the file is corrupt (partial download, tampering, bit
|
||||
// rot) and we must re-fetch it to avoid crashing on
|
||||
// model load later.
|
||||
match sha256_of_file(&dest) {
|
||||
Ok(actual) if actual.eq_ignore_ascii_case(file.sha256) => continue,
|
||||
Ok(_actual) => {
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"failed to verify existing {}: {e}",
|
||||
file.filename
|
||||
)));
|
||||
}
|
||||
} 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?;
|
||||
}
|
||||
|
||||
write_verified_manifest(entry, &dir)?;
|
||||
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
|
||||
/// validate an existing complete file before trusting it.
|
||||
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);
|
||||
|
||||
// If we have a partial file and no SHA256 to verify (can't verify partial),
|
||||
// request a range resume. If SHA256 is set, we restart to ensure integrity.
|
||||
let resuming = existing_bytes > 0 && file.sha256.is_none();
|
||||
let resuming = existing_bytes > 0;
|
||||
if resuming {
|
||||
request = request.header("Range", format!("bytes={existing_bytes}-"));
|
||||
}
|
||||
@@ -223,19 +274,23 @@ async fn download_file(
|
||||
std::fs::File::create(&part_path)?
|
||||
};
|
||||
|
||||
// Incremental SHA256 — only when a checksum is provided
|
||||
let mut hasher = file.sha256.map(|_| Sha256::new());
|
||||
|
||||
// If resuming without SHA256, we can't hash the already-downloaded portion,
|
||||
// but we also don't need to — we only hash when sha256 is set, and we
|
||||
// restart from scratch in that case.
|
||||
let mut hasher = Sha256::new();
|
||||
if actually_resuming {
|
||||
let mut partial = std::fs::File::open(&part_path)?;
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
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 {
|
||||
let chunk = chunk.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut out, &chunk)?;
|
||||
if let Some(ref mut h) = hasher {
|
||||
h.update(&chunk);
|
||||
}
|
||||
hasher.update(&chunk);
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
let percent = if total_bytes > 0 {
|
||||
@@ -258,17 +313,13 @@ async fn download_file(
|
||||
|
||||
drop(out);
|
||||
|
||||
// Verify SHA256 if provided
|
||||
if let (Some(expected), Some(hasher)) = (file.sha256, hasher) {
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != expected {
|
||||
// Delete corrupt file so next attempt starts fresh
|
||||
let _ = std::fs::remove_file(&part_path);
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
file.filename, expected, actual
|
||||
)));
|
||||
}
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != file.sha256 {
|
||||
let _ = std::fs::remove_file(&part_path);
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
file.filename, file.sha256, actual
|
||||
)));
|
||||
}
|
||||
|
||||
// Atomic rename — file is complete and verified
|
||||
@@ -428,7 +479,7 @@ mod tests {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
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");
|
||||
|
||||
@@ -437,9 +488,6 @@ mod tests {
|
||||
let bytes = std::fs::read(&dest).unwrap();
|
||||
assert_eq!(bytes, body);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -451,6 +499,7 @@ mod tests {
|
||||
// partial bytes and write the fresh body from offset zero rather
|
||||
// than appending on top.
|
||||
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 dir = tempdir().unwrap();
|
||||
@@ -465,7 +514,7 @@ mod tests {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: None,
|
||||
sha256: leak(expected_sha),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
@@ -520,7 +569,7 @@ mod tests {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: None,
|
||||
sha256: leak("0".repeat(64)),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
@@ -548,7 +597,7 @@ mod tests {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: Some(leak("deadbeef".repeat(8))),
|
||||
sha256: leak("deadbeef".repeat(8)),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
@@ -1630,6 +1631,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
|
||||
@@ -42,12 +42,15 @@ tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
# Phase 5 rituals: register Corbie as a login-time autostart entry.
|
||||
# Handles platform differences (.desktop file on Linux, LaunchAgents plist
|
||||
# on macOS, registry Run key on Windows) behind a single API.
|
||||
tauri-plugin-autostart = "2"
|
||||
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge
|
||||
# bus. Permission-request flow happens on the JS side before the first
|
||||
# deliver_nudge call; Windows only delivers for installed apps.
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -9,12 +9,12 @@ fn main() {
|
||||
println!("cargo:rustc-link-arg=-Wl,--allow-multiple-definition");
|
||||
}
|
||||
|
||||
assert_localhost_llm_csp();
|
||||
assert_loopback_llm_csp();
|
||||
|
||||
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
|
||||
/// 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`
|
||||
/// directive of the CSP, rather than substring-searching the whole
|
||||
/// 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.
|
||||
fn assert_localhost_llm_csp() {
|
||||
fn assert_loopback_llm_csp() {
|
||||
let conf_path = std::path::Path::new("tauri.conf.json");
|
||||
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||
|
||||
@@ -70,4 +70,12 @@ fn assert_localhost_llm_csp() {
|
||||
{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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "tasks-float", "transcript-viewer", "transcription-preview"],
|
||||
"identifier": "main",
|
||||
"description": "Main window capability for user-initiated app control.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
@@ -20,6 +20,9 @@
|
||||
"global-shortcut:allow-unregister",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled"
|
||||
"autostart:allow-is-enabled",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify"
|
||||
]
|
||||
}
|
||||
14
src-tauri/capabilities/secondary-windows.json
Normal file
14
src-tauri/capabilities/secondary-windows.json
Normal 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"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"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"]}}
|
||||
@@ -2355,22 +2355,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-ask`\n- `allow-confirm`\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-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-ask`\n- `allow-confirm`\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-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"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)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm 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)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2391,16 +2391,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask 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)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm 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)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
@@ -2486,6 +2486,204 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
@@ -2534,60 +2732,6 @@
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2355,22 +2355,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-ask`\n- `allow-confirm`\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-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-ask`\n- `allow-confirm`\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-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"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)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm 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)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2391,16 +2391,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask 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)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm 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)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
@@ -2486,6 +2486,204 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
@@ -2534,60 +2732,6 @@
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::{mpsc as tokio_mpsc, Mutex as AsyncMutex};
|
||||
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::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
|
||||
/// Settings device-picker UI. Includes a flag for likely PulseAudio /
|
||||
/// PipeWire monitor sources so the UI can warn the user.
|
||||
#[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)
|
||||
.await
|
||||
.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
|
||||
/// and reacquired around each await point.
|
||||
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>>>,
|
||||
wav_writer: Arc<Mutex<Option<WavWriter>>>,
|
||||
temp_audio_path: Arc<Mutex<Option<PathBuf>>>,
|
||||
capture_truncated: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl NativeCaptureState {
|
||||
@@ -60,6 +69,36 @@ impl NativeCaptureState {
|
||||
Self {
|
||||
worker: AsyncMutex::new(None),
|
||||
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.
|
||||
#[tauri::command]
|
||||
pub async fn start_native_capture(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, NativeCaptureState>,
|
||||
device_name: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
eprintln!(
|
||||
"[native-capture] start_native_capture called (device='{}')",
|
||||
device_name.as_deref().unwrap_or("<auto>")
|
||||
@@ -116,10 +157,19 @@ pub async fn start_native_capture(
|
||||
|
||||
let all_samples = state.all_samples.clone();
|
||||
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 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,
|
||||
// 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 {
|
||||
let chunk: Vec<f32> = pcm_buffer.drain(..chunk_size).collect();
|
||||
|
||||
// Store for save_audio
|
||||
if let Ok(mut all) = all_samples_clone.lock() {
|
||||
all.extend_from_slice(&chunk);
|
||||
}
|
||||
append_recorded_chunk(
|
||||
&all_samples_clone,
|
||||
&wav_writer_clone,
|
||||
&truncated_clone,
|
||||
&chunk,
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
"native-pcm",
|
||||
@@ -213,9 +265,12 @@ pub async fn start_native_capture(
|
||||
|
||||
// Emit any remaining samples
|
||||
if !pcm_buffer.is_empty() {
|
||||
if let Ok(mut all) = all_samples_clone.lock() {
|
||||
all.extend_from_slice(&pcm_buffer);
|
||||
}
|
||||
append_recorded_chunk(
|
||||
&all_samples_clone,
|
||||
&wav_writer_clone,
|
||||
&truncated_clone,
|
||||
&pcm_buffer,
|
||||
);
|
||||
let _ = app.emit(
|
||||
"native-pcm",
|
||||
serde_json::json!({
|
||||
@@ -228,6 +283,13 @@ pub async fn start_native_capture(
|
||||
if let Ok(mut cap) = capture_clone.lock() {
|
||||
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 });
|
||||
@@ -242,8 +304,10 @@ pub async fn start_native_capture(
|
||||
/// nothing from a worker that technically outlived the call (RB-06).
|
||||
#[tauri::command]
|
||||
pub async fn stop_native_capture(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, NativeCaptureState>,
|
||||
) -> Result<Vec<f32>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
if let Some(worker) = state.worker.lock().await.take() {
|
||||
stop_worker(worker).await;
|
||||
}
|
||||
@@ -252,6 +316,11 @@ pub async fn stop_native_capture(
|
||||
let mut all = state.all_samples.lock().unwrap();
|
||||
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)
|
||||
}
|
||||
@@ -461,9 +530,11 @@ pub async fn persist_audio_samples(
|
||||
/// Save PCM f32 samples as a WAV file. Returns the file path.
|
||||
#[tauri::command]
|
||||
pub async fn save_audio(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
output_folder: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
persist_audio_samples(&app, samples, output_folder).await
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use kon_storage::{
|
||||
};
|
||||
|
||||
use crate::commands::power::active_assertions_snapshot;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
|
||||
const DEFAULT_RECENT_ERRORS: i64 = 50;
|
||||
@@ -136,9 +137,11 @@ impl From<ErrorLogRow> for ErrorLogDto {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_recent_errors_command(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<ErrorLogDto>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let n = limit.unwrap_or(DEFAULT_RECENT_ERRORS).clamp(1, 1000);
|
||||
list_recent_errors(&state.db, n)
|
||||
.await
|
||||
@@ -157,7 +160,12 @@ pub struct CrashFile {
|
||||
}
|
||||
|
||||
#[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 entries = match fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
@@ -215,12 +223,72 @@ impl Default for ReportOptions {
|
||||
Self {
|
||||
include_recent_errors: true,
|
||||
include_crashes: true,
|
||||
include_settings: true,
|
||||
include_log_tail: true,
|
||||
include_settings: false,
|
||||
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
|
||||
/// 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.
|
||||
#[tauri::command]
|
||||
pub async fn generate_diagnostic_report(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
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> {
|
||||
let opts = options.unwrap_or_default();
|
||||
let mut out = String::new();
|
||||
@@ -241,7 +318,10 @@ pub async fn generate_diagnostic_report(
|
||||
std::env::consts::OS,
|
||||
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()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.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 {
|
||||
Ok(Some(json)) => {
|
||||
out.push_str("```json\n");
|
||||
out.push_str(&json);
|
||||
out.push_str(&sanitise_preferences_json(&json));
|
||||
out.push_str("\n```\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.error_code.as_deref().unwrap_or("?"),
|
||||
if r.metadata.is_some() { " (+meta)" } else { "" },
|
||||
r.message
|
||||
.chars()
|
||||
.take(400)
|
||||
.collect::<String>()
|
||||
.replace('\n', " "),
|
||||
redact_line(&r.message),
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
@@ -307,7 +383,7 @@ pub async fn generate_diagnostic_report(
|
||||
|
||||
if opts.include_crashes {
|
||||
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() {
|
||||
out.push_str("_(no crash dumps on disk — nothing has crashed)_\n\n");
|
||||
} else {
|
||||
@@ -321,7 +397,7 @@ pub async fn generate_diagnostic_report(
|
||||
out.push_str(&format!(
|
||||
"_(plus {} older crash dumps in `{}`)_\n\n",
|
||||
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) => {
|
||||
out.push_str("```\n");
|
||||
out.push_str(&tail);
|
||||
out.push_str(&redact_home(&tail));
|
||||
out.push_str("\n```\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.
|
||||
#[tauri::command]
|
||||
pub async fn save_diagnostic_report(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
options: Option<ReportOptions>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
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}"))?;
|
||||
|
||||
let ts = SystemTime::now()
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::commands::audio::resolve_recording_path;
|
||||
use crate::commands::build_initial_prompt;
|
||||
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
|
||||
use crate::commands::power::PowerAssertion;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||
use kon_audio::{
|
||||
@@ -481,6 +482,7 @@ struct SpeechGateDecision {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_live_transcription_session(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
live_state: tauri::State<'_, LiveTranscriptionState>,
|
||||
@@ -488,6 +490,7 @@ pub async fn start_live_transcription_session(
|
||||
result_channel: Channel<LiveResultMessage>,
|
||||
status_channel: Channel<LiveStatusMessage>,
|
||||
) -> Result<StartLiveTranscriptionResponse, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let _lifecycle = live_state.lifecycle.lock().await;
|
||||
{
|
||||
let running = live_state.running.lock().unwrap();
|
||||
@@ -586,10 +589,12 @@ pub async fn start_live_transcription_session(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_live_transcription_session(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
live_state: tauri::State<'_, LiveTranscriptionState>,
|
||||
session_id: u64,
|
||||
) -> Result<StopLiveTranscriptionResponse, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let _lifecycle = live_state.lifecycle.lock().await;
|
||||
let running = live_state.running.lock().unwrap().take();
|
||||
let Some(running) = running else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::power::PowerAssertion;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
use kon_ai_formatting::{llm_cleanup_text, LlmPromptPreset};
|
||||
use kon_core::hardware;
|
||||
@@ -58,7 +59,12 @@ pub fn check_llm_model(
|
||||
}
|
||||
|
||||
#[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 app_clone = app.clone();
|
||||
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]
|
||||
pub async fn load_llm_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
use_gpu: Option<bool>,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parse_model_id(model_id)?;
|
||||
let path = model_manager::model_path(id);
|
||||
if !path.exists() {
|
||||
@@ -112,12 +120,21 @@ pub async fn load_llm_model(
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
#[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)?;
|
||||
if state.llm_engine.loaded_model_id().as_deref() == Some(id.as_str()) {
|
||||
state.llm_engine.unload().map_err(|e| e.to_string())?;
|
||||
@@ -168,9 +185,11 @@ pub struct LlmTestResult {
|
||||
/// LLMs, adapted to Kon's local stack.
|
||||
#[tauri::command]
|
||||
pub async fn test_llm_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
) -> Result<LlmTestResult, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parse_model_id(model_id)?;
|
||||
let info = model_info(id);
|
||||
let path = model_manager::model_path(id);
|
||||
@@ -342,11 +361,13 @@ mod tests {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_transcript_text_cmd(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
transcript: String,
|
||||
profile_id: Option<String>,
|
||||
preset: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
let profile_terms: Vec<String> =
|
||||
|
||||
@@ -8,10 +8,12 @@ pub mod live;
|
||||
pub mod llm;
|
||||
pub mod meeting;
|
||||
pub mod models;
|
||||
pub mod nudges;
|
||||
pub mod paste;
|
||||
pub mod power;
|
||||
pub mod profiles;
|
||||
pub mod rituals;
|
||||
pub mod security;
|
||||
pub mod tasks;
|
||||
pub mod transcription;
|
||||
pub mod transcripts;
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use serde::Serialize;
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeCapabilities {
|
||||
@@ -531,7 +542,12 @@ pub fn get_runtime_capabilities(
|
||||
// --- Whisper model commands ---
|
||||
|
||||
#[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 app_clone = app.clone();
|
||||
model_manager::download(&id, move |progress| {
|
||||
@@ -568,10 +584,12 @@ pub fn list_models() -> Result<Vec<String>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
size: String,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = whisper_model_id(&size);
|
||||
ensure_model_loaded(&state, "whisper", id.as_str(), concurrent).await?;
|
||||
Ok(format!("Model {} loaded", size))
|
||||
@@ -586,9 +604,11 @@ pub fn check_engine(state: tauri::State<AppState>) -> Result<bool, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_parakeet_model(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
name: String,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parakeet_model_id(&name);
|
||||
let app_clone = app.clone();
|
||||
model_manager::download(&id, move |progress| {
|
||||
@@ -620,10 +640,12 @@ pub fn list_parakeet_models() -> Result<Vec<String>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_parakeet_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
name: String,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parakeet_model_id(&name);
|
||||
ensure_model_loaded(&state, "parakeet", id.as_str(), concurrent).await?;
|
||||
Ok(format!("Parakeet model {} loaded", name))
|
||||
|
||||
63
src-tauri/src/commands/nudges.rs
Normal file
63
src-tauri/src/commands/nudges.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Phase 6 of the feature-complete roadmap: Margot soft-touch nudges.
|
||||
//!
|
||||
//! The nudge bus lives in the frontend (`nudgeBus.svelte.ts`) — it
|
||||
//! owns trigger subscription, suppression rules, and the hourly cap.
|
||||
//! This module is the thin Rust side that:
|
||||
//!
|
||||
//! - Guards delivery to the main window only (secondary windows like
|
||||
//! the task float can't fire notifications at the user — they're
|
||||
//! already beside the user's attention).
|
||||
//! - Delegates to `tauri-plugin-notification` for cross-platform OS
|
||||
//! delivery. The plugin handles the macOS Notification Center /
|
||||
//! Linux org.freedesktop.Notifications / Windows toast plumbing.
|
||||
//!
|
||||
//! Permission handling happens on the JS side via the plugin's
|
||||
//! `isPermissionGranted` / `requestPermission` API — before the first
|
||||
//! `deliver_nudge` call, the nudge bus prompts the user. If denied,
|
||||
//! subsequent calls return an error that the bus logs and swallows.
|
||||
//!
|
||||
//! No persistence for v1. Cooldown state is ephemeral; the roadmap's
|
||||
//! nudge-audit table is deferred until a real need emerges.
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
use crate::commands::security::ensure_main_window;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliverNudgeInput {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Deliver a single OS notification. Does not apply suppression
|
||||
/// logic — the frontend nudge bus is responsible for cadence and
|
||||
/// cooldown, so this command is a blunt "push it now" primitive.
|
||||
///
|
||||
/// Returns an error if the notification plugin refuses the call
|
||||
/// (typically because permission was denied by the user at the OS
|
||||
/// level). The nudge bus logs and swallows these — nudges are
|
||||
/// fire-and-forget and must never surface errors to the user.
|
||||
#[tauri::command]
|
||||
pub fn deliver_nudge(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
input: DeliverNudgeInput,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
|
||||
let title = input.title.trim();
|
||||
let body = input.body.trim();
|
||||
if title.is_empty() && body.is_empty() {
|
||||
// A blank nudge is worse than no nudge — stay quiet.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.notification()
|
||||
.builder()
|
||||
.title(if title.is_empty() { "Corbie" } else { title })
|
||||
.body(body)
|
||||
.show()
|
||||
.map_err(|e| format!("notification delivery failed: {e}"))
|
||||
}
|
||||
30
src-tauri/src/commands/security.rs
Normal file
30
src-tauri/src/commands/security.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use tauri::Emitter;
|
||||
|
||||
use crate::commands::build_initial_prompt;
|
||||
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||
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_SECS: usize = 3 * 60;
|
||||
const FILE_CHUNK_OVERLAP_SECS: usize = 2;
|
||||
const MAX_FILE_TRANSCRIPTION_SECS: f64 = 2.0 * 60.0 * 60.0;
|
||||
|
||||
struct ChunkingStrategy {
|
||||
chunk_samples: usize,
|
||||
@@ -138,6 +140,7 @@ fn transcribe_samples_sync(
|
||||
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_pcm(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
@@ -150,6 +153,7 @@ pub async fn transcribe_pcm(
|
||||
format_mode: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
let resolved_profile_id =
|
||||
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.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_file(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
path: String,
|
||||
engine: Option<String>,
|
||||
@@ -242,6 +247,7 @@ pub async fn transcribe_file(
|
||||
format_mode: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let resolved_profile_id =
|
||||
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 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 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())?;
|
||||
transcribe_samples_sync(
|
||||
engine,
|
||||
@@ -319,6 +340,7 @@ pub async fn transcribe_file(
|
||||
/// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_pcm_parakeet(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
@@ -329,6 +351,7 @@ pub async fn transcribe_pcm_parakeet(
|
||||
format_mode: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
|
||||
/// 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.
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update(app: AppHandle) -> Result<Option<String>, String> {
|
||||
let update = app
|
||||
.updater()
|
||||
.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),
|
||||
}
|
||||
pub async fn check_for_update(window: tauri::WebviewWindow) -> Result<Option<String>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Download and stage the update. The app will install it on next launch.
|
||||
#[tauri::command]
|
||||
pub async fn install_update(app: AppHandle) -> Result<(), String> {
|
||||
let update = app
|
||||
.updater()
|
||||
.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(())
|
||||
pub async fn install_update(window: tauri::WebviewWindow) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
Err("Updates are disabled until release signing is configured.".to_string())
|
||||
}
|
||||
|
||||
@@ -129,7 +129,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
// Phase 5 rituals: autostart. The plugin registers JS-facing
|
||||
// commands (isEnabled / enable / disable) that the Settings
|
||||
// toggle and first-run prompt invoke directly — no bespoke
|
||||
@@ -139,6 +138,12 @@ pub fn run() {
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))
|
||||
// Phase 6 nudges: OS-native notifications. The plugin exposes
|
||||
// isPermissionGranted / requestPermission / sendNotification on
|
||||
// the JS side; our deliver_nudge wrapper guards those calls
|
||||
// against the nudge-bus suppression rules and restricts
|
||||
// invocation to the main window via ensure_main_window.
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
// Remember size + position of every window across app restarts.
|
||||
// Without this, secondary windows (preview overlay, task float,
|
||||
// transcript viewer) open at whatever spot the compositor picks,
|
||||
@@ -177,8 +182,11 @@ pub fn run() {
|
||||
{
|
||||
main_window
|
||||
.with_webview(|webview| {
|
||||
use webkit2gtk::glib::prelude::Cast;
|
||||
use webkit2gtk::{
|
||||
PermissionRequest, PermissionRequestExt, SettingsExt, WebViewExt,
|
||||
PermissionRequest, PermissionRequestExt, SettingsExt,
|
||||
UserMediaPermissionRequest, UserMediaPermissionRequestExt,
|
||||
WebViewExt,
|
||||
};
|
||||
|
||||
let wv: webkit2gtk::WebView = webview.inner().clone();
|
||||
@@ -189,11 +197,25 @@ pub fn run() {
|
||||
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(
|
||||
&wv,
|
||||
|_wv, request: &PermissionRequest| {
|
||||
request.allow();
|
||||
if let Ok(media) =
|
||||
request.clone().downcast::<UserMediaPermissionRequest>()
|
||||
{
|
||||
if media.is_for_audio_device()
|
||||
&& !media.is_for_video_device()
|
||||
{
|
||||
request.allow();
|
||||
} else {
|
||||
request.deny();
|
||||
}
|
||||
} else {
|
||||
request.deny();
|
||||
}
|
||||
true
|
||||
},
|
||||
);
|
||||
@@ -236,11 +258,6 @@ pub fn run() {
|
||||
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
|
||||
// fallbacks to the frontend so Settings can render a one-line
|
||||
// hint. No-ops on a fully-supported box.
|
||||
@@ -260,6 +277,7 @@ pub fn run() {
|
||||
commands::models::check_model,
|
||||
commands::models::list_models,
|
||||
commands::models::load_model,
|
||||
commands::models::prewarm_default_model_cmd,
|
||||
commands::models::check_engine,
|
||||
commands::models::get_runtime_capabilities,
|
||||
// Local LLM management
|
||||
@@ -309,6 +327,8 @@ pub fn run() {
|
||||
// Rituals (Phase 5 roadmap)
|
||||
commands::rituals::get_last_morning_triage,
|
||||
commands::rituals::mark_morning_triage_shown,
|
||||
// Nudges (Phase 6 roadmap)
|
||||
commands::nudges::deliver_nudge,
|
||||
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
||||
commands::profiles::list_profiles_cmd,
|
||||
commands::profiles::get_profile_cmd,
|
||||
|
||||
@@ -23,16 +23,7 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://github.com/jakejars/kon/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": false,
|
||||
"pubkey": ""
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
93
src-tauri/tests/config_hardening.rs
Normal file
93
src-tauri/tests/config_hardening.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,15 @@
|
||||
parentTaskId,
|
||||
profileId: profilesStore.activeProfileId,
|
||||
});
|
||||
// Phase 6 nudge-bus signal. The bus schedules a 15-min idle
|
||||
// check on this parent task id — if no step or the task itself
|
||||
// is completed in that window, a gentle "still with that one?"
|
||||
// nudge fires.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('kon:microstep-generated', {
|
||||
detail: { parentTaskId },
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
@@ -65,6 +74,14 @@
|
||||
await invoke('complete_subtask_cmd', { subtaskId });
|
||||
const idx = subtasks.findIndex(s => s.id === subtaskId);
|
||||
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], done: true };
|
||||
// Phase 6 nudge-bus signal. Any completed step clears the
|
||||
// micro-step-idle timer for this parent task — the breakdown
|
||||
// is demonstrably getting worked, no nudge needed.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('kon:step-completed', {
|
||||
detail: { id: subtaskId, parentTaskId },
|
||||
}));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1532,6 +1532,45 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Nudges (Phase 6 roadmap) -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'nudges' ? null : 'nudges'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Nudges</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'nudges' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'nudges'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Corbie.
|
||||
</p>
|
||||
|
||||
<Toggle
|
||||
bind:checked={settings.nudgesEnabled}
|
||||
label="Enable nudges"
|
||||
description="Soft-touch notifications when a timer has been ticking while you're away, when a morning triage is waiting, or when a micro-step decomposition has sat untouched for 15 minutes."
|
||||
/>
|
||||
|
||||
{#if settings.nudgesEnabled}
|
||||
<div class="pl-1 py-1 animate-fade-in">
|
||||
<Toggle
|
||||
bind:checked={settings.nudgesMuted}
|
||||
label="Mute for now"
|
||||
description="Stops delivery without forgetting your settings. Flip back off when you want the nudges back."
|
||||
/>
|
||||
<Toggle
|
||||
bind:checked={settings.nudgesSpeakAloud}
|
||||
label="Speak nudges aloud"
|
||||
description="Also reads the nudge out through your Read-aloud voice. Useful if you keep the sound off but look up sometimes."
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Read aloud (Phase 4 roadmap) -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
@@ -1805,6 +1844,14 @@
|
||||
{/if}
|
||||
</p>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,11 @@ function createFocusTimerStore() {
|
||||
}
|
||||
|
||||
function clear() {
|
||||
// Broadcast cancellation so downstream listeners (Phase 6 nudge bus)
|
||||
// can reset their inactivity-while-timer state. Only fires when a
|
||||
// timer was actually active — dismissing the completion flash
|
||||
// after natural completion doesn't re-fire the event.
|
||||
const wasActive = startedAt !== null && !completionFired;
|
||||
startedAt = null;
|
||||
durationMs = 0;
|
||||
taskId = null;
|
||||
@@ -108,6 +113,9 @@ function createFocusTimerStore() {
|
||||
completionFired = false;
|
||||
writePersisted(null);
|
||||
stopTick();
|
||||
if (wasActive && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("kon:focus-timer-cancelled"));
|
||||
}
|
||||
}
|
||||
|
||||
function start(seconds: number, opts?: { taskId?: string | null; label?: string | null }): void {
|
||||
|
||||
292
src/lib/stores/nudgeBus.svelte.ts
Normal file
292
src/lib/stores/nudgeBus.svelte.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// Phase 6 Margot nudge bus. Frontend-owned, subscribes to in-app
|
||||
// signals Corbie already produces, applies suppression, and fans out
|
||||
// to OS notification (via Rust's deliver_nudge) + optional TTS.
|
||||
//
|
||||
// Why frontend-only: OS-wide keyboard/window detection is fragile
|
||||
// across Wayland/macOS/Windows (no sanctioned global-keyboard API on
|
||||
// Wayland, accessibility permission on macOS, message-loop hook on
|
||||
// Windows). The app-local signals we already have — timer state,
|
||||
// task completion, micro-step creation, app focus/visibility — cover
|
||||
// the Phase 6 trigger set without the platform pain.
|
||||
//
|
||||
// Suppression rules:
|
||||
// - nudgesEnabled && !nudgesMuted
|
||||
// - document has focus → never nudge (user is already looking)
|
||||
// - rolling 1-hour cap of 3 nudges
|
||||
// - timer-specific: no nudge in first 60 s of a running timer
|
||||
//
|
||||
// Trigger set v1 (in-app only):
|
||||
// - inactivity_with_active_timer
|
||||
// - pending_morning_triage
|
||||
// - micro_step_idle
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||
import { settings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
|
||||
const TIMER_WARMUP_MS = 60_000;
|
||||
const MICRO_STEP_IDLE_MS = 15 * 60_000;
|
||||
const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000;
|
||||
const MORNING_TRIAGE_TRIGGER_HOUR = 10;
|
||||
|
||||
interface TimerStartPayload {
|
||||
taskId?: string;
|
||||
seconds?: number;
|
||||
}
|
||||
|
||||
interface MicroStepPayload {
|
||||
parentTaskId: string;
|
||||
}
|
||||
|
||||
let started = false;
|
||||
let permissionRequested = false;
|
||||
let permissionGranted = false;
|
||||
|
||||
// Rolling timestamps of recent nudge deliveries (ms since epoch).
|
||||
// Older-than-one-hour entries are pruned lazily on each check.
|
||||
const recentNudges: number[] = [];
|
||||
|
||||
// Per-trigger state.
|
||||
let timerRunning = false;
|
||||
let timerStartedAt = 0;
|
||||
let timerNudgeFiredThisSession = false;
|
||||
let blurredAt: number | null = null;
|
||||
let blurCheckHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
let triagePollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// parentTaskId → scheduled timeout handle for "idle since step created".
|
||||
// Cleared when a step or parent task gets marked done in the same session.
|
||||
const microStepTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// --- Permission ------------------------------------------------------
|
||||
|
||||
async function ensurePermission(): Promise<boolean> {
|
||||
if (!hasTauriRuntime()) return false;
|
||||
if (permissionRequested) return permissionGranted;
|
||||
permissionRequested = true;
|
||||
try {
|
||||
const plugin = await import("@tauri-apps/plugin-notification");
|
||||
const already = await plugin.isPermissionGranted();
|
||||
if (already) {
|
||||
permissionGranted = true;
|
||||
return true;
|
||||
}
|
||||
const result = await plugin.requestPermission();
|
||||
permissionGranted = result === "granted";
|
||||
return permissionGranted;
|
||||
} catch {
|
||||
permissionGranted = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Suppression -----------------------------------------------------
|
||||
|
||||
function pruneRecentNudges(now: number): void {
|
||||
while (recentNudges.length > 0 && now - recentNudges[0] > HOUR_MS) {
|
||||
recentNudges.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function canNudgeNow(now: number): boolean {
|
||||
if (!settings.nudgesEnabled) return false;
|
||||
if (settings.nudgesMuted) return false;
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return false;
|
||||
pruneRecentNudges(now);
|
||||
if (recentNudges.length >= 3) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Delivery --------------------------------------------------------
|
||||
|
||||
async function deliver(title: string, body: string): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!canNudgeNow(now)) return;
|
||||
const ok = await ensurePermission();
|
||||
if (!ok) return;
|
||||
recentNudges.push(now);
|
||||
try {
|
||||
await invoke("deliver_nudge", { input: { title, body } });
|
||||
} catch {
|
||||
// Nudges are fire-and-forget — surfacing an error to the user
|
||||
// would defeat the "anticipatory, not push-notification" framing.
|
||||
}
|
||||
if (settings.nudgesSpeakAloud) {
|
||||
try {
|
||||
await invoke("tts_speak", {
|
||||
text: body || title,
|
||||
rate: settings.ttsRate,
|
||||
voice: settings.ttsVoice ?? null,
|
||||
});
|
||||
} catch {
|
||||
// Same reasoning — never surface TTS failures through a nudge.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trigger: inactivity with active timer ---------------------------
|
||||
|
||||
function onTimerStart(event: Event) {
|
||||
const detail = (event as CustomEvent<TimerStartPayload>).detail;
|
||||
timerRunning = true;
|
||||
timerStartedAt = Date.now();
|
||||
timerNudgeFiredThisSession = false;
|
||||
// If the user switched away before starting the timer, the next
|
||||
// blur check (below) will pick it up.
|
||||
void detail;
|
||||
}
|
||||
|
||||
function resetTimerState() {
|
||||
timerRunning = false;
|
||||
timerStartedAt = 0;
|
||||
timerNudgeFiredThisSession = false;
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
blurredAt = null;
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
blurredAt = Date.now();
|
||||
}
|
||||
|
||||
function checkInactivityWithActiveTimer(now: number) {
|
||||
if (!timerRunning || timerNudgeFiredThisSession) return;
|
||||
if (now - timerStartedAt < TIMER_WARMUP_MS) return;
|
||||
if (blurredAt === null) return;
|
||||
if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return;
|
||||
timerNudgeFiredThisSession = true;
|
||||
void deliver(
|
||||
"Still on that timer?",
|
||||
"It's been ticking while you've been away. Pick up where you left off, or stop it.",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Trigger: pending morning triage ---------------------------------
|
||||
|
||||
function todayLocalKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
let triageNudgeFiredOnKey: string | null = null;
|
||||
|
||||
async function checkPendingMorningTriage() {
|
||||
if (!settings.ritualsMorning) return;
|
||||
if (!hasTauriRuntime()) return;
|
||||
const now = new Date();
|
||||
if (now.getHours() < MORNING_TRIAGE_TRIGGER_HOUR) return;
|
||||
const today = todayLocalKey();
|
||||
if (triageNudgeFiredOnKey === today) return;
|
||||
let lastShown: string | null = null;
|
||||
try {
|
||||
lastShown = await invoke<string | null>("get_last_morning_triage");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (lastShown === today) return;
|
||||
triageNudgeFiredOnKey = today;
|
||||
void deliver(
|
||||
"A few things waiting",
|
||||
"When you're ready, Corbie has your morning list. No rush.",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Trigger: micro-step idle ----------------------------------------
|
||||
|
||||
function onMicroStepGenerated(event: Event) {
|
||||
const detail = (event as CustomEvent<MicroStepPayload>).detail;
|
||||
if (!detail?.parentTaskId) return;
|
||||
const parentTaskId = detail.parentTaskId;
|
||||
clearMicroStepTimer(parentTaskId);
|
||||
const handle = setTimeout(() => {
|
||||
microStepTimers.delete(parentTaskId);
|
||||
void deliver(
|
||||
"Still with that one?",
|
||||
"Your breakdown is here when you want to pick the first step.",
|
||||
);
|
||||
}, MICRO_STEP_IDLE_MS);
|
||||
microStepTimers.set(parentTaskId, handle);
|
||||
}
|
||||
|
||||
function clearMicroStepTimer(parentTaskId: string) {
|
||||
const handle = microStepTimers.get(parentTaskId);
|
||||
if (handle !== undefined) {
|
||||
clearTimeout(handle);
|
||||
microStepTimers.delete(parentTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
function onStepOrTaskCompleted(event: Event) {
|
||||
const detail = (event as CustomEvent<{ id?: string; parentTaskId?: string }>).detail;
|
||||
// A completed subtask clears its parent's idle timer; a completed
|
||||
// parent task clears its own idle timer (the step decomposition
|
||||
// attached to that task is no longer waiting on anyone).
|
||||
if (detail?.parentTaskId) clearMicroStepTimer(detail.parentTaskId);
|
||||
if (detail?.id) clearMicroStepTimer(detail.id);
|
||||
}
|
||||
|
||||
// --- Lifecycle -------------------------------------------------------
|
||||
|
||||
export function startNudgeBus(): void {
|
||||
if (started) return;
|
||||
if (typeof window === "undefined") return;
|
||||
started = true;
|
||||
|
||||
window.addEventListener("kon:start-timer", onTimerStart);
|
||||
window.addEventListener("kon:focus-timer-complete", resetTimerState);
|
||||
window.addEventListener("kon:focus-timer-cancelled", resetTimerState);
|
||||
window.addEventListener("kon:microstep-generated", onMicroStepGenerated);
|
||||
window.addEventListener("kon:step-completed", onStepOrTaskCompleted);
|
||||
window.addEventListener("kon:task-completed", onStepOrTaskCompleted);
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("blur", onBlur);
|
||||
|
||||
// Inactivity check runs while a timer is live. Low frequency is fine
|
||||
// — the nudge itself only fires once the 90 s threshold is crossed.
|
||||
blurCheckHandle = setInterval(() => {
|
||||
checkInactivityWithActiveTimer(Date.now());
|
||||
}, 10_000);
|
||||
|
||||
// Morning triage check runs on an interval + one immediate probe on
|
||||
// start. The Rust `get_last_morning_triage` call is cheap (one
|
||||
// SQLite read), so polling every 5 min costs nothing and means we
|
||||
// catch the 10:00 threshold without a scheduler.
|
||||
void checkPendingMorningTriage();
|
||||
triagePollHandle = setInterval(() => {
|
||||
void checkPendingMorningTriage();
|
||||
}, MORNING_TRIAGE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopNudgeBus(): void {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
|
||||
window.removeEventListener("kon:start-timer", onTimerStart);
|
||||
window.removeEventListener("kon:focus-timer-complete", resetTimerState);
|
||||
window.removeEventListener("kon:focus-timer-cancelled", resetTimerState);
|
||||
window.removeEventListener("kon:microstep-generated", onMicroStepGenerated);
|
||||
window.removeEventListener("kon:step-completed", onStepOrTaskCompleted);
|
||||
window.removeEventListener("kon:task-completed", onStepOrTaskCompleted);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
|
||||
if (blurCheckHandle !== null) {
|
||||
clearInterval(blurCheckHandle);
|
||||
blurCheckHandle = null;
|
||||
}
|
||||
if (triagePollHandle !== null) {
|
||||
clearInterval(triagePollHandle);
|
||||
triagePollHandle = null;
|
||||
}
|
||||
for (const handle of microStepTimers.values()) clearTimeout(handle);
|
||||
microStepTimers.clear();
|
||||
|
||||
resetTimerState();
|
||||
blurredAt = null;
|
||||
triageNudgeFiredOnKey = null;
|
||||
permissionRequested = false;
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const defaults: SettingsState = {
|
||||
llmModelId: null,
|
||||
llmPromptPreset: "default",
|
||||
aiGpuConcurrency: "parallel",
|
||||
prewarmModelOnStartup: false,
|
||||
saveAudio: false,
|
||||
outputFolder: "",
|
||||
globalHotkey: "Ctrl+Shift+R",
|
||||
@@ -78,6 +79,9 @@ const defaults: SettingsState = {
|
||||
ritualsEvening: false,
|
||||
launchAtLogin: false,
|
||||
ritualsPromptSeen: false,
|
||||
nudgesEnabled: false,
|
||||
nudgesMuted: false,
|
||||
nudgesSpeakAloud: false,
|
||||
};
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
@@ -452,6 +456,12 @@ export async function completeTask(id: string) {
|
||||
try {
|
||||
await invoke("complete_task_cmd", { id });
|
||||
applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() });
|
||||
// Phase 6 nudge-bus signal. Fire-and-forget; the bus subscribes
|
||||
// to this to clear micro-step-idle timers for the completed task
|
||||
// and, later, to drive Phase 7 "after a task completes" rules.
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("kon:task-completed", { detail: { id } }));
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error("Couldn't complete task", errorMessage(err));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface SettingsState {
|
||||
llmModelId: LlmModelIdStr | null;
|
||||
llmPromptPreset: LlmPromptPreset;
|
||||
aiGpuConcurrency: AiGpuConcurrency;
|
||||
prewarmModelOnStartup: boolean;
|
||||
saveAudio: boolean;
|
||||
outputFolder: string;
|
||||
globalHotkey: string;
|
||||
@@ -104,6 +105,18 @@ export interface SettingsState {
|
||||
* prompt from re-appearing on every update-triggered reboot.
|
||||
*/
|
||||
ritualsPromptSeen: boolean;
|
||||
/**
|
||||
* Phase 6 Margot nudges. All off by default per Jake's standing rule
|
||||
* about opt-in nudging. `nudgesMuted` is separate from `nudgesEnabled`
|
||||
* so the user can flip a hard mute without losing their preferences.
|
||||
*/
|
||||
nudgesEnabled: boolean;
|
||||
nudgesMuted: boolean;
|
||||
/**
|
||||
* When true, nudges also speak their body through Phase 4 TTS. Only
|
||||
* has effect when `nudgesEnabled` is true.
|
||||
*/
|
||||
nudgesSpeakAloud: boolean;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||
import { initI18n } from "$lib/i18n";
|
||||
import { refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
|
||||
import { startNudgeBus, stopNudgeBus } from "$lib/stores/nudgeBus.svelte.ts";
|
||||
|
||||
import { page as sveltePage } from "$app/stores";
|
||||
|
||||
@@ -277,6 +278,12 @@
|
||||
// Phase 5: subscribe to tray wind-down event.
|
||||
setupWindDownListener();
|
||||
|
||||
// Phase 6: start the nudge bus. The bus is self-gated on
|
||||
// settings.nudgesEnabled internally — starting it here is cheap
|
||||
// (just event listeners + two intervals), and means users don't
|
||||
// need to restart after flipping the toggle.
|
||||
startNudgeBus();
|
||||
|
||||
// Diagnostics: capture every uncaught frontend error to error_log.
|
||||
installGlobalErrorCapture();
|
||||
|
||||
@@ -324,6 +331,10 @@
|
||||
// calls from DictationPage around cleanup_transcript_text_cmd.
|
||||
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
|
||||
// appears (edge-triggered — no repeat toasts until the app goes away
|
||||
// and comes back). We never start recording from this signal; the
|
||||
@@ -380,6 +391,7 @@
|
||||
if (unlistenWindDown) {
|
||||
unlistenWindDown();
|
||||
}
|
||||
stopNudgeBus();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user