chore(hardening): tighten security and footprint defaults
This commit is contained in:
@@ -42,7 +42,6 @@ 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
|
||||
|
||||
@@ -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",
|
||||
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"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]}}
|
||||
{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}}
|
||||
@@ -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> =
|
||||
|
||||
@@ -12,6 +12,7 @@ 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))
|
||||
|
||||
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
|
||||
@@ -177,8 +176,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 +191,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 +252,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 +271,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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user