agent: lumotia — v0.1 release-completion run
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

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:
2026-05-15 06:59:08 +01:00
parent bf1b68275a
commit 3770815fbf
77 changed files with 8697 additions and 1017 deletions

View File

@@ -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"] }

View File

@@ -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:?}"
);
}
}

View File

@@ -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())
}
}
}

View File

@@ -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;

View 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())
}

View File

@@ -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::*;

View File

@@ -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]

View File

@@ -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");