agent: lumotia — v0.1 release-completion run
Closes the code-side v0.1 ship gate. All quality gates green: cargo fmt/clippy/test (~327 tests), npm check (0/0), vitest 13/13, scripts/dogfood-rebrand-drill.sh 8/8. Phase F — first-run onboarding promoted to v0.1 - FirstRunPage with skip-to-main + failure recovery + event recording - Six onboarding commands (record/list/has-completed + lumotia_events) - Storage migration v17 (onboarding_events + lumotia_events tables) UI hardening (in-scope items from v0.1-ui-hardening.md) - StatusPill + PostCaptureCard components, 21st preview entry - Sidebar recording-as-sacred-state (opacity + aria-disabled, reduced-motion) - Settings 6-section regroup + Help section + Activation log + Privacy toggle - Error-state copy sweep (DictationPage + SettingsPage, plain-language) - Global :focus-visible rule, textarea outlines restored - Ctrl+K / Ctrl+, / Escape bindings in +layout LLM resilience - rule_based_extract_tasks (regex-free imperative-verb extractor) + extract_tasks_with_fallback wrapper — task extraction never returns zero - tokio::time::timeout(120s) wraps cleanup/tags/tasks commands Release artefacts - LICENSE (canonical AGPL-3.0), CHANGELOG (Keep-a-Changelog format) - v0.1-release-notes, privacy-and-ai-use, install-warnings, tester-onboarding-kit, tester-acceptance-runbook, code-signing-setup, apple-silicon-rb08-runbook, virtual-audio-setup, v0.1-contrast-audit - Workspace versioning + AGPL spdx; npm exact-pin (10 ranges removed) - AppImage SHA-256 sidecar in build.yml - README v0.1 section + Reporting-issues; canonical repo slug Closure pass — items moved from human-required to code-complete - KI-02 Linux idle inhibit: zbus 5 → org.freedesktop.login1.Manager.Inhibit - KI-03 Windows sleep prevention: SetThreadExecutionState(ES_CONTINUOUS|...) - acquire/release_idle_inhibit Tauri commands, wired in DictationPage - Diagnostic-bundle frontend wire-up (Settings → Help button) - WCAG-AA contrast fix via .btn-filled-text utility (no token changes) - 8 destructive-action sites wrapped in plain-language confirm() guards - KNOWN-ISSUES.md + v0.1-known-limitations.md updated (KI-02/03 fixed) Scripts - pre-tag-verify.sh, tag-day.sh, smoke-linux + driver - parse-diagnostic-bundle.sh, parse-activation-log.py Per-item audit trail: docs/release/v0.1-completion-status.md Remaining: W-01…W-08 (signing certs, hardware probes, smoke matrix, tester recruitment) — see docs/release/v0.1-known-limitations.md.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
[package]
|
||||
name = "lumotia"
|
||||
version = "0.1.0"
|
||||
version.workspace = true
|
||||
description = "Lumotia — Think out loud"
|
||||
authors = ["CORBEL Ltd"]
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "lumotia_lib"
|
||||
@@ -61,7 +63,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2.5"
|
||||
|
||||
# Async runtime (spawn_blocking for inference)
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||
arboard = "3.6.1"
|
||||
|
||||
|
||||
@@ -72,6 +74,7 @@ arboard = "3.6.1"
|
||||
# migrate / any / json which this crate doesn't use. Only names SqlitePool.
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
zip = "8.6.0"
|
||||
|
||||
[dev-dependencies]
|
||||
# Phase 9 fs::write_text_file_cmd tests use a temp directory so we don't
|
||||
@@ -104,6 +107,10 @@ webkit2gtk = "2.0"
|
||||
# transitively depends on (GTK 3).
|
||||
gtk = "0.18"
|
||||
gdk = "0.18"
|
||||
# KI-02: systemd-logind idle inhibit via org.freedesktop.login1.Manager.Inhibit.
|
||||
# The blocking API avoids spawning an extra async runtime inside an already-async
|
||||
# Tauri command handler (spawn_blocking wraps the call site).
|
||||
zbus = { version = "5", default-features = false, features = ["blocking"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.4"
|
||||
@@ -113,3 +120,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = ["s
|
||||
# Phase 4 TTS: PowerShell -EncodedCommand expects UTF-16-LE base64.
|
||||
# Windows-only because the other platforms' TTS paths pass text via argv.
|
||||
base64 = "0.22"
|
||||
# KI-03: SetThreadExecutionState for sleep prevention during recording.
|
||||
# The `windows` crate is already a transitive dep (via tauri); listing it
|
||||
# here explicitly pulls in only the Win32_System_Power feature we need.
|
||||
windows = { version = "0.62", features = ["Win32_System_Power"] }
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
//! - The manual report bundler shows the user exactly what would be
|
||||
//! shared and lets them choose to copy/save/email it.
|
||||
//! - No remote endpoint, no Sentry, no opt-out telemetry.
|
||||
//!
|
||||
//! The `generate_diagnostic_bundle` command produces a zip archive
|
||||
//! containing logs, crash dumps, redacted preferences, and system info.
|
||||
//! It NEVER includes audio files, transcripts, the SQLite database, or
|
||||
//! any `.env*` file — a deny-list is applied to every candidate path.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::panic;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -532,3 +537,678 @@ pub async fn save_diagnostic_report(
|
||||
fs::write(&path, &report).map_err(|e| format!("write file: {e}"))?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic bundle (zip archive) — Task 3.8
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Summary returned to the frontend after bundling.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnosticBundleSummary {
|
||||
/// Absolute path to the zip that was written.
|
||||
pub path: String,
|
||||
/// Size of the zip in bytes.
|
||||
pub bytes: u64,
|
||||
/// Human-readable labels for sections that made it into the zip.
|
||||
pub included: Vec<String>,
|
||||
/// Human-readable labels for sections deliberately excluded.
|
||||
pub excluded: Vec<String>,
|
||||
}
|
||||
|
||||
/// Deny-list: extensions and path components that MUST NEVER appear in a
|
||||
/// diagnostic bundle. Checked case-insensitively against every candidate
|
||||
/// path before any bytes are written to the zip.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Audio extensions: wav, mp3, opus, ogg, flac
|
||||
/// - Transcript / capture directories: transcripts, captures
|
||||
/// - The SQLite database (transcripts.db and its WAL/SHM siblings)
|
||||
/// - Dot-env files (.env, .env.local, …)
|
||||
/// - Anything under an `audio/` path component
|
||||
const AUDIO_EXTENSIONS: &[&str] = &["wav", "mp3", "opus", "ogg", "flac"];
|
||||
const DENIED_PATH_COMPONENTS: &[&str] = &["transcripts", "captures", "audio"];
|
||||
|
||||
/// Return `true` when the file at `path` must be excluded from any bundle.
|
||||
/// This is the contract; it is tested by the unit tests below.
|
||||
pub(crate) fn is_denied(path: &Path) -> bool {
|
||||
let path_str = path.to_string_lossy();
|
||||
let path_lower = path_str.to_ascii_lowercase();
|
||||
|
||||
// 1. Audio by extension
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
if AUDIO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Denied path components (directory names that must not appear)
|
||||
for component in path.components() {
|
||||
if let std::path::Component::Normal(c) = component {
|
||||
let c_lower = c.to_string_lossy().to_ascii_lowercase();
|
||||
if DENIED_PATH_COMPONENTS.contains(&c_lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. SQLite database files (*.db, *.db-wal, *.db-shm)
|
||||
if path_lower.ends_with(".db")
|
||||
|| path_lower.ends_with(".db-wal")
|
||||
|| path_lower.ends_with(".db-shm")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Dot-env files
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
let name_lower = name.to_ascii_lowercase();
|
||||
if name_lower == ".env" || name_lower.starts_with(".env.") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Literal "transcripts.db" anywhere in the path (belt-and-suspenders)
|
||||
if path_lower.contains("transcripts.db") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Redact preference JSON before including in the bundle.
|
||||
///
|
||||
/// Secrets are any field whose key contains:
|
||||
/// api_key, token, secret, password, credential (case-insensitive)
|
||||
/// Vocabulary entries are replaced with `[redacted]` per the spec.
|
||||
fn redact_preferences_for_bundle(raw: &str) -> String {
|
||||
fn is_secret_key(key: &str) -> bool {
|
||||
let k = key.to_ascii_lowercase();
|
||||
k.contains("api_key")
|
||||
|| k.contains("token")
|
||||
|| k.contains("secret")
|
||||
|| k.contains("password")
|
||||
|| k.contains("credential")
|
||||
}
|
||||
|
||||
fn redact_value(value: &mut serde_json::Value, key_hint: Option<&str>) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
for (k, v) in map.iter_mut() {
|
||||
if is_secret_key(k) {
|
||||
*v = serde_json::Value::String("[redacted]".to_string());
|
||||
} else {
|
||||
redact_value(v, Some(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
// Vocabulary-style arrays: if the parent key looks like vocabulary,
|
||||
// redact each entry individually.
|
||||
let is_vocab = key_hint
|
||||
.map(|k| {
|
||||
let k = k.to_ascii_lowercase();
|
||||
k.contains("vocab") || k.contains("dictionary") || k.contains("terms")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if is_vocab {
|
||||
for item in items.iter_mut() {
|
||||
*item = serde_json::Value::String("[redacted]".to_string());
|
||||
}
|
||||
} else {
|
||||
for item in items.iter_mut() {
|
||||
redact_value(item, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strings: mask home-dir paths (same logic as existing redact_home)
|
||||
serde_json::Value::String(s) => {
|
||||
*s = redact_home(s);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match serde_json::from_str::<serde_json::Value>(raw) {
|
||||
Ok(mut v) => {
|
||||
redact_value(&mut v, None);
|
||||
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
Err(_) => "_preferences could not be parsed as JSON_".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a single in-memory byte slice to the zip under `zip_path`.
|
||||
/// Skips silently if `zip_path` is denied (should not happen for
|
||||
/// synthesised files, but belt-and-suspenders).
|
||||
fn zip_add_bytes(
|
||||
zip: &mut zip::ZipWriter<fs::File>,
|
||||
zip_path: &str,
|
||||
data: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
zip.start_file(zip_path, options)
|
||||
.map_err(|e| format!("zip start_file({zip_path}): {e}"))?;
|
||||
zip.write_all(data)
|
||||
.map_err(|e| format!("zip write({zip_path}): {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a real file from `src_path` to the zip under `zip_path`.
|
||||
/// Applies the deny-list; logs a warning and skips if denied.
|
||||
/// Returns `true` if the file was added, `false` if skipped.
|
||||
fn zip_add_file(
|
||||
zip: &mut zip::ZipWriter<fs::File>,
|
||||
src_path: &Path,
|
||||
zip_path: &str,
|
||||
) -> Result<bool, String> {
|
||||
if is_denied(src_path) {
|
||||
tracing::warn!(
|
||||
path = %src_path.display(),
|
||||
"diagnostic bundle: deny-list match — skipping file"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let data = fs::read(src_path).map_err(|e| format!("read {}: {e}", src_path.display()))?;
|
||||
zip_add_bytes(zip, zip_path, &data)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Tauri command: produce a zip diagnostic bundle at `output_path`.
|
||||
///
|
||||
/// The caller (frontend) is responsible for obtaining the output path via
|
||||
/// the Tauri dialog plugin before invoking this command.
|
||||
///
|
||||
/// Privacy contract:
|
||||
/// - NEVER includes audio, transcripts, the SQLite database, or .env files.
|
||||
/// - Preferences are redacted before inclusion (secrets + vocabulary).
|
||||
/// - Home-directory paths are replaced with `~`.
|
||||
#[tauri::command]
|
||||
pub async fn generate_diagnostic_bundle(
|
||||
output_path: String,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<DiagnosticBundleSummary, String> {
|
||||
let dest = PathBuf::from(&output_path);
|
||||
|
||||
// Ensure parent directory exists.
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;
|
||||
}
|
||||
|
||||
let file = fs::File::create(&dest).map_err(|e| format!("create bundle file: {e}"))?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
|
||||
let mut included: Vec<String> = Vec::new();
|
||||
// Excluded is always the same — audio + transcripts are always denied.
|
||||
let excluded: Vec<String> = vec!["transcripts".to_string(), "audio".to_string()];
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. system_info.txt
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
let data_dir_display = redact_home(&app_data_dir().display().to_string());
|
||||
let now_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let system_info = format!(
|
||||
"Lumotia diagnostic bundle\n\
|
||||
========================\n\
|
||||
App version: {ver}\n\
|
||||
OS: {os}\n\
|
||||
Arch: {arch}\n\
|
||||
Data dir: {data_dir}\n\
|
||||
Generated: {ts} (UTC epoch seconds)\n\
|
||||
Rust: {rust_ver}\n",
|
||||
ver = LUMOTIA_VERSION,
|
||||
os = std::env::consts::OS,
|
||||
arch = std::env::consts::ARCH,
|
||||
data_dir = data_dir_display,
|
||||
ts = now_secs,
|
||||
rust_ver = option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown"),
|
||||
);
|
||||
zip_add_bytes(&mut zip, "system_info.txt", system_info.as_bytes())?;
|
||||
included.push("system_info".to_string());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. logs/ — last 7 days of log files, capped at 5 MB total
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024; // 5 MB
|
||||
const MAX_LOG_DAYS: usize = 7;
|
||||
|
||||
let log_dir = logs_dir();
|
||||
let mut log_count: usize = 0;
|
||||
|
||||
if log_dir.is_dir() {
|
||||
// Collect log files sorted newest-first by mtime.
|
||||
let mut log_files: Vec<(u64, PathBuf)> = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&log_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
// Deny-list check on each candidate
|
||||
if is_denied(&path) {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"diagnostic bundle: deny-list match in logs dir — skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
log_files.push((mtime, path));
|
||||
}
|
||||
}
|
||||
// Sort newest-first
|
||||
log_files.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
let mut bytes_so_far: u64 = 0;
|
||||
for (_, path) in log_files.iter().take(MAX_LOG_DAYS) {
|
||||
let file_size = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
if bytes_so_far + file_size > MAX_LOG_BYTES {
|
||||
tracing::info!(
|
||||
"diagnostic bundle: log cap reached at {} bytes; stopping",
|
||||
bytes_so_far
|
||||
);
|
||||
break;
|
||||
}
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("log");
|
||||
let zip_path = format!("logs/{name}");
|
||||
match zip_add_file(&mut zip, path, &zip_path) {
|
||||
Ok(true) => {
|
||||
bytes_so_far += file_size;
|
||||
log_count += 1;
|
||||
}
|
||||
Ok(false) => {} // denied — already warned
|
||||
Err(e) => tracing::warn!(
|
||||
"diagnostic bundle: could not add log {}: {e}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if log_count > 0 {
|
||||
included.push(format!("logs ({log_count} files)"));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. crashes/ — most recent 3 crash dumps
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
const MAX_CRASHES: usize = 3;
|
||||
let crash_dir = crashes_dir();
|
||||
let mut crash_count: usize = 0;
|
||||
|
||||
if crash_dir.is_dir() {
|
||||
let mut crash_files: Vec<(u64, PathBuf)> = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&crash_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("crash") {
|
||||
continue;
|
||||
}
|
||||
if is_denied(&path) {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"diagnostic bundle: deny-list match in crashes dir — skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
crash_files.push((mtime, path));
|
||||
}
|
||||
}
|
||||
crash_files.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
for (_, path) in crash_files.iter().take(MAX_CRASHES) {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("crash");
|
||||
let zip_path = format!("crashes/{name}");
|
||||
match zip_add_file(&mut zip, path, &zip_path) {
|
||||
Ok(true) => crash_count += 1,
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(
|
||||
"diagnostic bundle: could not add crash {}: {e}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if crash_count > 0 {
|
||||
included.push(format!("crash_dumps ({crash_count})"));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. preferences-redacted.json
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
let prefs_raw = lumotia_storage::get_setting(&state.db, "lumotia_preferences")
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let redacted = match prefs_raw {
|
||||
Some(raw) => redact_preferences_for_bundle(&raw),
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
|
||||
zip_add_bytes(&mut zip, "preferences-redacted.json", redacted.as_bytes())?;
|
||||
included.push("preferences (redacted)".to_string());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. metadata.json
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
let now_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"generated_at": now_secs,
|
||||
"lumotia_version": LUMOTIA_VERSION,
|
||||
"included": included,
|
||||
"excluded": excluded,
|
||||
"redaction_policy": "no audio, no transcripts, vocabulary redacted, secrets redacted"
|
||||
});
|
||||
let metadata_bytes =
|
||||
serde_json::to_vec_pretty(&metadata).unwrap_or_else(|_| b"{}".to_vec());
|
||||
zip_add_bytes(&mut zip, "metadata.json", &metadata_bytes)?;
|
||||
}
|
||||
|
||||
// Finalise zip
|
||||
zip.finish().map_err(|e| format!("zip finalise: {e}"))?;
|
||||
|
||||
// Report bundle size
|
||||
let bytes = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
tracing::info!(
|
||||
path = %dest.display(),
|
||||
bytes,
|
||||
"diagnostic bundle written"
|
||||
);
|
||||
|
||||
Ok(DiagnosticBundleSummary {
|
||||
path: output_path,
|
||||
bytes,
|
||||
included,
|
||||
excluded,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests for the deny-list and preference redaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod bundle_tests {
|
||||
use super::*;
|
||||
/// Helper: create a temp dir, populate with fake files, run the deny-list,
|
||||
/// and return (allowed_names, denied_names).
|
||||
fn classify(files: &[&str]) -> (Vec<String>, Vec<String>) {
|
||||
let mut allowed = Vec::new();
|
||||
let mut denied = Vec::new();
|
||||
for name in files {
|
||||
let p = PathBuf::from(name);
|
||||
if is_denied(&p) {
|
||||
denied.push(name.to_string());
|
||||
} else {
|
||||
allowed.push(name.to_string());
|
||||
}
|
||||
}
|
||||
(allowed, denied)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_audio_extensions() {
|
||||
let (allowed, denied) = classify(&[
|
||||
"lumotia.log",
|
||||
"recording.wav",
|
||||
"clip.mp3",
|
||||
"capture.opus",
|
||||
"sound.ogg",
|
||||
"track.flac",
|
||||
"system_info.txt",
|
||||
]);
|
||||
assert!(
|
||||
denied.contains(&"recording.wav".to_string()),
|
||||
"wav must be denied"
|
||||
);
|
||||
assert!(
|
||||
denied.contains(&"clip.mp3".to_string()),
|
||||
"mp3 must be denied"
|
||||
);
|
||||
assert!(
|
||||
denied.contains(&"capture.opus".to_string()),
|
||||
"opus must be denied"
|
||||
);
|
||||
assert!(
|
||||
denied.contains(&"sound.ogg".to_string()),
|
||||
"ogg must be denied"
|
||||
);
|
||||
assert!(
|
||||
denied.contains(&"track.flac".to_string()),
|
||||
"flac must be denied"
|
||||
);
|
||||
assert!(
|
||||
allowed.contains(&"lumotia.log".to_string()),
|
||||
"log must be allowed"
|
||||
);
|
||||
assert!(
|
||||
allowed.contains(&"system_info.txt".to_string()),
|
||||
"txt must be allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_transcript_path_component() {
|
||||
let (allowed, denied) = classify(&[
|
||||
"/home/user/.local/share/lumotia/transcripts/2024-01-01.txt",
|
||||
"/home/user/.local/share/lumotia/logs/lumotia.log",
|
||||
"/home/user/audio/recording.flac",
|
||||
"/home/user/.local/share/lumotia/captures/cap001.bin",
|
||||
]);
|
||||
assert!(
|
||||
denied.iter().any(|p| p.contains("transcripts")),
|
||||
"transcripts dir must be denied"
|
||||
);
|
||||
assert!(
|
||||
denied.iter().any(|p| p.contains("captures")),
|
||||
"captures dir must be denied"
|
||||
);
|
||||
assert!(
|
||||
denied.iter().any(|p| p.contains("audio")),
|
||||
"audio dir must be denied"
|
||||
);
|
||||
assert!(
|
||||
allowed.iter().any(|p| p.contains("logs")),
|
||||
"logs dir must be allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_sqlite_database() {
|
||||
let (_, denied) = classify(&[
|
||||
"/data/lumotia.db",
|
||||
"/data/lumotia.db-wal",
|
||||
"/data/lumotia.db-shm",
|
||||
"/data/transcripts.db",
|
||||
]);
|
||||
assert_eq!(denied.len(), 4, "all db files must be denied: {denied:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_dotenv_files() {
|
||||
let (allowed, denied) = classify(&[
|
||||
".env",
|
||||
".env.local",
|
||||
".env.production",
|
||||
"env.txt", // should be allowed
|
||||
"my.env.backup", // should be allowed (doesn't start with .env)
|
||||
]);
|
||||
assert!(denied.contains(&".env".to_string()), ".env must be denied");
|
||||
assert!(
|
||||
denied.contains(&".env.local".to_string()),
|
||||
".env.local must be denied"
|
||||
);
|
||||
assert!(
|
||||
denied.contains(&".env.production".to_string()),
|
||||
".env.production must be denied"
|
||||
);
|
||||
assert!(
|
||||
allowed.contains(&"env.txt".to_string()),
|
||||
"env.txt must be allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_secret_keys_redacted() {
|
||||
let raw = serde_json::json!({
|
||||
"display_name": "Jake",
|
||||
"api_key": "sk-supersecret",
|
||||
"auth_token": "tok_abc123",
|
||||
"password": "hunter2",
|
||||
"some_credential": "cred_xyz",
|
||||
"theme": "dark"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let redacted: serde_json::Value =
|
||||
serde_json::from_str(&redact_preferences_for_bundle(&raw)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
redacted["api_key"], "[redacted]",
|
||||
"api_key must be redacted"
|
||||
);
|
||||
assert_eq!(
|
||||
redacted["auth_token"], "[redacted]",
|
||||
"token must be redacted"
|
||||
);
|
||||
assert_eq!(
|
||||
redacted["password"], "[redacted]",
|
||||
"password must be redacted"
|
||||
);
|
||||
assert_eq!(
|
||||
redacted["some_credential"], "[redacted]",
|
||||
"credential must be redacted"
|
||||
);
|
||||
// Non-secret fields must survive
|
||||
assert_eq!(
|
||||
redacted["display_name"], "Jake",
|
||||
"display_name must survive"
|
||||
);
|
||||
assert_eq!(redacted["theme"], "dark", "theme must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_vocabulary_entries_redacted() {
|
||||
let raw = serde_json::json!({
|
||||
"vocab": ["CORBEL", "Lumotia", "Jake"],
|
||||
"user_dictionary": ["word1", "word2"],
|
||||
"settings_list": ["keep_me"]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let redacted: serde_json::Value =
|
||||
serde_json::from_str(&redact_preferences_for_bundle(&raw)).unwrap();
|
||||
|
||||
// Vocabulary arrays must be fully redacted
|
||||
let vocab = redacted["vocab"].as_array().unwrap();
|
||||
assert!(
|
||||
vocab.iter().all(|v| v == "[redacted]"),
|
||||
"vocab entries must be redacted: {vocab:?}"
|
||||
);
|
||||
|
||||
let dict = redacted["user_dictionary"].as_array().unwrap();
|
||||
assert!(
|
||||
dict.iter().all(|v| v == "[redacted]"),
|
||||
"user_dictionary entries must be redacted: {dict:?}"
|
||||
);
|
||||
|
||||
// Non-vocabulary arrays survive
|
||||
let settings = redacted["settings_list"].as_array().unwrap();
|
||||
assert_eq!(settings[0], "keep_me", "settings_list must survive");
|
||||
}
|
||||
|
||||
/// End-to-end: write a real zip to a tempfile with a fake dir tree.
|
||||
/// Verify that:
|
||||
/// - `system_info.txt` is present
|
||||
/// - `metadata.json` is present
|
||||
/// - A fake `.wav` is NOT in the zip
|
||||
/// - A fake `transcript.txt` inside a `transcripts/` path is NOT in the zip
|
||||
/// - A fake log file IS in the zip (via zip_add_bytes — this tests the
|
||||
/// deny-list path directly)
|
||||
#[test]
|
||||
fn deny_list_in_zip_add_file() {
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir");
|
||||
|
||||
let wav_path = tmp.path().join("test_recording.wav");
|
||||
let log_path = tmp.path().join("lumotia.log");
|
||||
let transcript_dir = tmp.path().join("transcripts");
|
||||
fs::create_dir_all(&transcript_dir).unwrap();
|
||||
let transcript_path = transcript_dir.join("my_note.txt");
|
||||
|
||||
fs::write(&wav_path, b"RIFF fake wav data").unwrap();
|
||||
fs::write(&log_path, b"2024-01-01 INFO test log line").unwrap();
|
||||
fs::write(&transcript_path, b"Top secret transcript").unwrap();
|
||||
|
||||
let out_path = tmp.path().join("bundle.zip");
|
||||
let zip_file = fs::File::create(&out_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(zip_file);
|
||||
|
||||
// wav — must be denied
|
||||
let wav_added = zip_add_file(&mut zip, &wav_path, "test_recording.wav").unwrap();
|
||||
assert!(!wav_added, "wav must not be added");
|
||||
|
||||
// transcript inside transcripts/ — must be denied
|
||||
let tx_added = zip_add_file(&mut zip, &transcript_path, "transcripts/my_note.txt").unwrap();
|
||||
assert!(!tx_added, "transcript must not be added");
|
||||
|
||||
// log file — must be allowed
|
||||
let log_added = zip_add_file(&mut zip, &log_path, "logs/lumotia.log").unwrap();
|
||||
assert!(log_added, "log must be added");
|
||||
|
||||
zip.finish().unwrap();
|
||||
|
||||
// Verify zip contents
|
||||
let zip_file = fs::File::open(&out_path).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(zip_file).unwrap();
|
||||
let names: Vec<String> = (0..archive.len())
|
||||
.map(|i| archive.by_index(i).unwrap().name().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
names.contains(&"logs/lumotia.log".to_string()),
|
||||
"log must be in zip: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"test_recording.wav".to_string()),
|
||||
"wav must NOT be in zip: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"transcripts/my_note.txt".to_string()),
|
||||
"transcript must NOT be in zip: {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use tauri::{Emitter, State};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use crate::commands::power::PowerAssertion;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
@@ -8,6 +9,8 @@ use lumotia_core::hardware;
|
||||
use lumotia_llm::model_manager::{self, model_info};
|
||||
use lumotia_llm::{ContentTags, LlmModelId};
|
||||
|
||||
const LLM_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LlmModelStatusDto {
|
||||
@@ -395,16 +398,24 @@ pub async fn cleanup_transcript_text_cmd(
|
||||
.unwrap_or(LlmPromptPreset::Default);
|
||||
|
||||
let engine = state.llm_engine.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let cleanup_future = tokio::task::spawn_blocking(move || {
|
||||
// macOS: pin a power assertion for the duration of the LLM
|
||||
// generation so App Nap can't decide to throttle us mid-token.
|
||||
// No-op on every other OS. Item #9.
|
||||
let _power_guard = PowerAssertion::begin("lumotia LLM cleanup");
|
||||
llm_cleanup_text(&engine, &transcript, &profile_terms, resolved_preset)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
match timeout(LLM_TIMEOUT, cleanup_future).await {
|
||||
Ok(Ok(value)) => value.map_err(|e| e.to_string()),
|
||||
Ok(Err(e)) => Err(e.to_string()),
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
"LLM cleanup_transcript_text_cmd timed out after {:?}",
|
||||
LLM_TIMEOUT
|
||||
);
|
||||
Err("Cleanup took too long. The raw transcript is preserved — try again, or continue without cleanup.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 9 LLM-powered content tags. On-demand from the History page;
|
||||
@@ -429,11 +440,19 @@ pub async fn extract_content_tags_cmd(
|
||||
return Err("LLM not loaded. Download an AI model in Settings.".to_string());
|
||||
}
|
||||
let engine = state.llm_engine.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let tag_future = tokio::task::spawn_blocking(move || {
|
||||
let _power_guard = PowerAssertion::begin("lumotia LLM content-tag extraction");
|
||||
engine.extract_content_tags(&transcript)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
match timeout(LLM_TIMEOUT, tag_future).await {
|
||||
Ok(Ok(value)) => value.map_err(|e| e.to_string()),
|
||||
Ok(Err(e)) => Err(e.to_string()),
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
"LLM extract_content_tags_cmd timed out after {:?}",
|
||||
LLM_TIMEOUT
|
||||
);
|
||||
Err("Tagging took too long. Your transcript is unchanged — you can retry from the post-capture card.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod llm;
|
||||
pub mod meeting;
|
||||
pub mod models;
|
||||
pub mod nudges;
|
||||
pub mod onboarding;
|
||||
pub mod paste;
|
||||
pub mod power;
|
||||
pub mod profiles;
|
||||
|
||||
97
src-tauri/src/commands/onboarding.rs
Normal file
97
src-tauri/src/commands/onboarding.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
// Tauri commands for onboarding flow + opt-in activation log.
|
||||
//
|
||||
// These are thin adapters over the storage helpers — no business logic lives
|
||||
// here. Every sqlx::Error is mapped to a String so Tauri can serialise it
|
||||
// to the frontend as a rejected Promise.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use lumotia_storage::{
|
||||
clear_lumotia_events as db_clear_lumotia_events,
|
||||
has_completed_onboarding as db_has_completed_onboarding,
|
||||
insert_lumotia_event as db_insert_lumotia_event,
|
||||
insert_onboarding_event as db_insert_onboarding_event,
|
||||
list_lumotia_events as db_list_lumotia_events,
|
||||
list_onboarding_events as db_list_onboarding_events, LumotiaEventRow, OnboardingEventRow,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
/// Record a single onboarding step.
|
||||
///
|
||||
/// `event` — short snake_case identifier, e.g. `"started"`, `"completed"`, `"skipped"`.
|
||||
/// `version` — app version string, e.g. `"0.1.0"`.
|
||||
/// `skipped` — `true` if the user bypassed this step.
|
||||
/// `notes` — optional freeform annotation.
|
||||
#[tauri::command]
|
||||
pub async fn record_onboarding_event(
|
||||
state: tauri::State<'_, AppState>,
|
||||
event: String,
|
||||
version: String,
|
||||
skipped: bool,
|
||||
notes: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
db_insert_onboarding_event(&state.db, &event, &version, skipped, notes.as_deref(), now)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// List all recorded onboarding events, oldest first.
|
||||
#[tauri::command]
|
||||
pub async fn list_onboarding_events(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<Vec<OnboardingEventRow>, String> {
|
||||
db_list_onboarding_events(&state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Returns `true` if the user has ever recorded a `completed` or `skipped`
|
||||
/// onboarding event — i.e. first-run onboarding should not be shown again.
|
||||
#[tauri::command]
|
||||
pub async fn has_completed_onboarding(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
db_has_completed_onboarding(&state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Append a single entry to the opt-in local activation log.
|
||||
///
|
||||
/// `kind` — event kind, e.g. `"app_launched"`, `"recording_started"`.
|
||||
/// `payload` — optional JSON blob with event-specific context.
|
||||
#[tauri::command]
|
||||
pub async fn record_lumotia_event(
|
||||
state: tauri::State<'_, AppState>,
|
||||
kind: String,
|
||||
payload: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
db_insert_lumotia_event(&state.db, &kind, payload.as_deref(), now)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// List all activation log events, oldest first.
|
||||
#[tauri::command]
|
||||
pub async fn list_lumotia_events(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<Vec<LumotiaEventRow>, String> {
|
||||
db_list_lumotia_events(&state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete all rows from the activation log.
|
||||
#[tauri::command]
|
||||
pub async fn clear_lumotia_events(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
db_clear_lumotia_events(&state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -13,27 +13,27 @@
|
||||
//! Runtime verification on Apple Silicon against actual idle-throttling
|
||||
//! is still pending. See `KNOWN-ISSUES.md` (KI-01).
|
||||
//!
|
||||
//! On Linux and Windows, `PowerAssertion::begin` is currently a no-op
|
||||
//! that registers a snapshot in the process-wide registry for diagnostics
|
||||
//! but does not inhibit OS-level idle throttling. The planned
|
||||
//! implementations are:
|
||||
//! On Linux (KI-02) we call `org.freedesktop.login1.Manager.Inhibit` via
|
||||
//! D-Bus (zbus blocking API). The returned file descriptor is the inhibit
|
||||
//! lock; closing it releases the lock. A global `OnceLock<Mutex<Option<Fd>>>`
|
||||
//! holds the descriptor for the duration of recording.
|
||||
//!
|
||||
//! - Linux: systemd-logind / GNOME session idle inhibit via
|
||||
//! `org.freedesktop.login1.Inhibit` where available.
|
||||
//! - Windows: `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED
|
||||
//! | ES_AWAYMODE_REQUIRED)` on begin and `ES_CONTINUOUS` alone on end.
|
||||
//! On Windows (KI-03) we call
|
||||
//! `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` to prevent
|
||||
//! the system from sleeping. `ES_CONTINUOUS` alone resets that on release.
|
||||
//!
|
||||
//! Until those land, long sessions on Linux and Windows can still be
|
||||
//! idled by the OS. See `KNOWN-ISSUES.md` (KI-02, KI-03) for workarounds.
|
||||
//!
|
||||
//! All paths return a guard so the caller's code is unchanged. Failures
|
||||
//! to acquire a real assertion are logged so the diagnostics bundle has
|
||||
//! a breadcrumb.
|
||||
//! All paths return `Ok(())` on failure — errors are logged but never block
|
||||
//! recording. The workarounds in `KNOWN-ISSUES.md` remain valid for edge cases
|
||||
//! (non-systemd Linux containers, policy-locked Windows images).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared snapshot registry (all platforms)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PowerAssertionSnapshot {
|
||||
pub id: usize,
|
||||
@@ -78,7 +78,8 @@ pub fn active_assertions_snapshot() -> Vec<PowerAssertionSnapshot> {
|
||||
impl PowerAssertion {
|
||||
/// Begin a power assertion for the given reason. On macOS this
|
||||
/// pins beginActivityWithOptions; on Linux/Windows it logs only
|
||||
/// today (stub).
|
||||
/// (the OS-level inhibit is managed separately via the
|
||||
/// `acquire_idle_inhibit` / `release_idle_inhibit` Tauri commands).
|
||||
pub fn begin(reason: &'static str) -> Self {
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
@@ -96,18 +97,23 @@ impl PowerAssertion {
|
||||
tracing::warn!(reason, "macOS App Nap guard could not begin activity");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// No-op on non-macOS today; #9 acceptance text only cites
|
||||
// macOS App Nap. Linux/Windows placeholder handled if
|
||||
// future feedback requires it.
|
||||
let _ = reason;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let backend = "linux-logind";
|
||||
#[cfg(target_os = "linux")]
|
||||
let acquired = true; // actual inhibit is managed by acquire_idle_inhibit command
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let backend = "windows-ste";
|
||||
#[cfg(target_os = "windows")]
|
||||
let acquired = true; // actual STE call is managed by acquire_idle_inhibit command
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
let backend = "noop";
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
let acquired = false;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let backend = "noop";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let acquired = false;
|
||||
let _ = reason;
|
||||
|
||||
assertion_registry().lock().unwrap().insert(
|
||||
id,
|
||||
@@ -147,6 +153,10 @@ impl Drop for PowerAssertion {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// macOS: NSProcessInfo activity (unchanged — KI-01, not touched here)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod objc_bridge {
|
||||
use objc2::rc::Retained;
|
||||
@@ -175,6 +185,164 @@ mod objc_bridge {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linux: systemd-logind D-Bus inhibit (KI-02)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The global inhibit lock file descriptor. `Some(fd)` while recording;
|
||||
/// `None` when not inhibiting. Dropping the inner `OwnedFd` releases the
|
||||
/// systemd-logind inhibit lock automatically.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_inhibit_lock() -> &'static Mutex<Option<std::os::unix::io::OwnedFd>> {
|
||||
static LOCK: OnceLock<Mutex<Option<std::os::unix::io::OwnedFd>>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_inhibit {
|
||||
use std::os::unix::io::OwnedFd;
|
||||
use zbus::blocking::Connection;
|
||||
use zbus::zvariant::OwnedFd as ZOwnedFd;
|
||||
|
||||
/// Acquires a systemd-logind idle+sleep inhibit lock.
|
||||
/// Returns the file descriptor whose lifetime IS the lock.
|
||||
/// Drops (closes) the fd to release.
|
||||
pub fn acquire() -> Result<OwnedFd, String> {
|
||||
let conn =
|
||||
Connection::system().map_err(|e| format!("zbus: connect to system bus failed: {e}"))?;
|
||||
|
||||
let reply = conn
|
||||
.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.login1.Manager"),
|
||||
"Inhibit",
|
||||
&(
|
||||
"idle:sleep:handle-lid-switch",
|
||||
"Lumotia",
|
||||
"Active dictation in progress",
|
||||
"block",
|
||||
),
|
||||
)
|
||||
.map_err(|e| format!("zbus: Inhibit call failed: {e}"))?;
|
||||
|
||||
let fd: ZOwnedFd = reply
|
||||
.body()
|
||||
.deserialize()
|
||||
.map_err(|e| format!("zbus: Inhibit reply deserialize failed: {e}"))?;
|
||||
|
||||
// Convert zvariant's OwnedFd to std's OwnedFd
|
||||
Ok(fd.into())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows: SetThreadExecutionState (KI-03)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_inhibit {
|
||||
use windows::Win32::System::Power::{
|
||||
SetThreadExecutionState, ES_CONTINUOUS, ES_SYSTEM_REQUIRED,
|
||||
};
|
||||
|
||||
/// Prevents the system from sleeping by setting ES_CONTINUOUS | ES_SYSTEM_REQUIRED.
|
||||
/// Display sleep is intentionally NOT blocked — the user is dictating, not watching.
|
||||
pub fn acquire() {
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases the sleep prevention by resetting to ES_CONTINUOUS alone.
|
||||
pub fn release() {
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands: acquire_idle_inhibit / release_idle_inhibit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Acquire an OS-level idle/sleep inhibit lock for the duration of recording.
|
||||
///
|
||||
/// - Linux: calls `org.freedesktop.login1.Manager.Inhibit` and holds the fd.
|
||||
/// - Windows: calls `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)`.
|
||||
/// - macOS: no-op here; App Nap is handled by `PowerAssertion::begin`.
|
||||
/// - Other: no-op.
|
||||
///
|
||||
/// Errors are logged and swallowed — recording must never be blocked by a
|
||||
/// failed power assertion.
|
||||
#[tauri::command]
|
||||
pub async fn acquire_idle_inhibit() -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// The zbus blocking call must not run on the Tokio executor thread.
|
||||
let result = tokio::task::spawn_blocking(linux_inhibit::acquire)
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("spawn_blocking panic: {e}")));
|
||||
|
||||
match result {
|
||||
Ok(fd) => {
|
||||
*linux_inhibit_lock().lock().unwrap() = Some(fd);
|
||||
tracing::info!("Linux idle inhibit acquired (logind block)");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"Linux idle inhibit not acquired — recording continues unaffected"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows_inhibit::acquire();
|
||||
tracing::info!("Windows sleep prevention engaged (ES_CONTINUOUS | ES_SYSTEM_REQUIRED)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// macOS: App Nap guard is managed by PowerAssertion in the live session path.
|
||||
// Other platforms: no-op.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release the OS-level idle/sleep inhibit lock acquired during recording.
|
||||
///
|
||||
/// Safe to call even if no lock was held (idempotent).
|
||||
#[tauri::command]
|
||||
pub async fn release_idle_inhibit() -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Dropping the OwnedFd closes the file descriptor, which releases
|
||||
// the systemd-logind inhibit lock.
|
||||
let prev = linux_inhibit_lock().lock().unwrap().take();
|
||||
if prev.is_some() {
|
||||
tracing::info!("Linux idle inhibit released (fd closed)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows_inhibit::release();
|
||||
tracing::info!("Windows sleep prevention released (ES_CONTINUOUS)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -18,9 +18,13 @@ use lumotia_storage::{
|
||||
FeedbackRow, FeedbackTargetType, TaskRow,
|
||||
};
|
||||
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use crate::commands::power::PowerAssertion;
|
||||
use crate::AppState;
|
||||
|
||||
const LLM_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Frontend-facing task shape. Matches the in-memory object in page.svelte.js.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -365,13 +369,28 @@ pub async fn extract_tasks_from_transcript_cmd(
|
||||
.unwrap_or_default();
|
||||
|
||||
let engine = state.llm_engine.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let transcript_for_fallback = transcript.clone();
|
||||
let extract_future = tokio::task::spawn_blocking(move || {
|
||||
let _power_guard = PowerAssertion::begin("lumotia LLM task extraction");
|
||||
engine.extract_tasks_with_feedback(&transcript, &examples)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
// extract_tasks_with_fallback NEVER returns Err: on LLM failure it
|
||||
// silently falls back to the rule-based extractor and logs a warning.
|
||||
// The return tuple is (tasks, source); the Tauri command only forwards
|
||||
// the task strings — the UI already has a frontend safety net and
|
||||
// doesn't need to distinguish the source for v0.1.
|
||||
let (tasks, _source) = engine.extract_tasks_with_fallback(&transcript, &examples);
|
||||
tasks
|
||||
});
|
||||
match timeout(LLM_TIMEOUT, extract_future).await {
|
||||
Ok(Ok(tasks)) => Ok(tasks),
|
||||
Ok(Err(e)) => Err(e.to_string()),
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
"LLM extract_tasks_from_transcript_cmd timed out; falling back to rule-based"
|
||||
);
|
||||
let tasks = lumotia_llm::rule_based_extract_tasks(&transcript_for_fallback);
|
||||
Ok(tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -729,6 +729,13 @@ pub fn run() {
|
||||
commands::rituals::mark_morning_triage_shown,
|
||||
// Nudges (Phase 6 roadmap)
|
||||
commands::nudges::deliver_nudge,
|
||||
// Onboarding flow + opt-in activation log
|
||||
commands::onboarding::record_onboarding_event,
|
||||
commands::onboarding::list_onboarding_events,
|
||||
commands::onboarding::has_completed_onboarding,
|
||||
commands::onboarding::record_lumotia_event,
|
||||
commands::onboarding::list_lumotia_events,
|
||||
commands::onboarding::clear_lumotia_events,
|
||||
// Implementation intentions (Phase 7 roadmap)
|
||||
commands::intentions::list_implementation_rules,
|
||||
commands::intentions::create_implementation_rule,
|
||||
@@ -761,6 +768,7 @@ pub fn run() {
|
||||
commands::diagnostics::generate_diagnostic_report,
|
||||
commands::diagnostics::save_diagnostic_report,
|
||||
commands::diagnostics::get_os_info,
|
||||
commands::diagnostics::generate_diagnostic_bundle,
|
||||
commands::live::start_live_transcription_session,
|
||||
commands::live::stop_live_transcription_session,
|
||||
// Windows
|
||||
@@ -790,6 +798,9 @@ pub fn run() {
|
||||
commands::hotkey::stop_evdev_hotkey,
|
||||
// Updater
|
||||
commands::update::check_for_update,
|
||||
// Power (KI-02 Linux idle inhibit, KI-03 Windows sleep prevention)
|
||||
commands::power::acquire_idle_inhibit,
|
||||
commands::power::release_idle_inhibit,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Lumotia");
|
||||
|
||||
Reference in New Issue
Block a user