From afc059b3464933326eb8896918de36e10c581c2a Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 12 Mar 2026 04:57:29 +0100 Subject: [PATCH] feat: integrate all production readiness modules Register new commands in lib.rs, add command modules, update Cargo deps (notify-rust, keyring, bundled-full), fix PRAGMA WAL for bundled-full, add notifications/heartbeats/FTS5 indexing to agent-dispatcher, update SettingsTab with secrets/plugins/sandbox/updates sections. --- v2/Cargo.lock | 473 ++++++++++++- v2/src-tauri/Cargo.toml | 4 +- v2/src-tauri/src/commands/mod.rs | 4 + v2/src-tauri/src/lib.rs | 108 +++ v2/src-tauri/src/session/mod.rs | 7 +- v2/src-tauri/tauri.conf.json | 4 +- v2/src/lib/agent-dispatcher.ts | 73 +- .../components/Workspace/SettingsTab.svelte | 665 ++++++++++++++++++ v2/src/lib/utils/updater.ts | 59 +- 9 files changed, 1377 insertions(+), 20 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 4ee4566..90821cd 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -118,6 +118,126 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -209,6 +329,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.2" @@ -237,8 +370,10 @@ dependencies = [ "bterminal-core", "dirs 5.0.1", "futures-util", + "keyring", "log", "notify", + "notify-rust", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", @@ -264,6 +399,7 @@ name = "bterminal-core" version = "0.1.0" dependencies = [ "dirs 5.0.1", + "landlock", "log", "portable-pty", "serde", @@ -492,6 +628,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -618,6 +763,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.9" @@ -869,6 +1035,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -919,6 +1112,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1109,6 +1323,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1512,6 +1739,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1987,6 +2220,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "linux-keyutils", + "log", + "zeroize", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2019,6 +2263,17 @@ dependencies = [ "selectors", ] +[[package]] +name = "landlock" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2094,6 +2349,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2127,6 +2392,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2349,6 +2626,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2673,6 +2964,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2712,6 +3013,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2907,6 +3214,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2927,7 +3245,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2945,6 +3263,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3112,6 +3444,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3436,11 +3777,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ "bitflags 2.11.0", + "chrono", + "csv", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", + "serde_json", "smallvec", + "time", + "url", + "uuid", ] [[package]] @@ -4518,6 +4865,18 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -5050,6 +5409,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -6208,6 +6578,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.40" @@ -6305,3 +6736,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/v2/src-tauri/Cargo.toml b/v2/src-tauri/Cargo.toml index 8b0b6ad..6e32a62 100644 --- a/v2/src-tauri/Cargo.toml +++ b/v2/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.10.3", features = [] } -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.31", features = ["bundled-full"] } dirs = "5" notify = { version = "6", features = ["macos_fsevent"] } tauri-plugin-updater = "2.10.0" @@ -38,6 +38,8 @@ opentelemetry = "0.28" opentelemetry_sdk = { version = "0.28", features = ["rt-tokio"] } opentelemetry-otlp = { version = "0.28", features = ["http-proto", "reqwest-client"] } tracing-opentelemetry = "0.29" +keyring = { version = "3", features = ["linux-native"] } +notify-rust = "4" [dev-dependencies] tempfile = "3" diff --git a/v2/src-tauri/src/commands/mod.rs b/v2/src-tauri/src/commands/mod.rs index 76c49a6..27c796c 100644 --- a/v2/src-tauri/src/commands/mod.rs +++ b/v2/src-tauri/src/commands/mod.rs @@ -11,3 +11,7 @@ pub mod remote; pub mod misc; pub mod btmsg; pub mod bttask; +pub mod notifications; +pub mod search; +pub mod plugins; +pub mod secrets; diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 3008a7c..cb85294 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -6,8 +6,12 @@ mod event_sink; mod fs_watcher; mod groups; mod memora; +mod notifications; +mod plugins; mod pty; +mod secrets; mod remote; +mod search; mod sidecar; mod session; mod telemetry; @@ -21,6 +25,7 @@ use session::SessionDb; use sidecar::{SidecarConfig, SidecarManager}; use fs_watcher::ProjectFsWatcher; use watcher::FileWatcherManager; +use std::path::Path; use std::sync::Arc; use tauri::Manager; @@ -33,10 +38,72 @@ pub(crate) struct AppState { pub ctx_db: Arc, pub memora_db: Arc, pub remote_manager: Arc, + pub search_db: Arc, pub app_config: Arc, _telemetry: telemetry::TelemetryGuard, } +/// Install btmsg/bttask CLI tools to ~/.local/bin/ so agent subprocesses can find them. +/// Sources: bundled resources (production) or repo root (development). +/// Only overwrites if the source is newer or the destination doesn't exist. +fn install_cli_tools(resource_dir: &Path, dev_root: &Path) { + let bin_dir = dirs::home_dir() + .unwrap_or_default() + .join(".local") + .join("bin"); + if let Err(e) = std::fs::create_dir_all(&bin_dir) { + log::warn!("Failed to create ~/.local/bin: {e}"); + return; + } + + for tool_name in &["btmsg", "bttask"] { + // Try resource dir first (production bundle), then dev repo root + let source = [ + resource_dir.join(tool_name), + dev_root.join(tool_name), + ] + .into_iter() + .find(|p| p.is_file()); + + let source = match source { + Some(p) => p, + None => { + log::warn!("CLI tool '{tool_name}' not found in resources or dev root"); + continue; + } + }; + + let dest = bin_dir.join(tool_name); + let should_install = if dest.exists() { + // Compare modification times — install if source is newer + match (source.metadata(), dest.metadata()) { + (Ok(sm), Ok(dm)) => { + sm.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH) + > dm.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH) + } + _ => true, + } + } else { + true + }; + + if should_install { + match std::fs::copy(&source, &dest) { + Ok(_) => { + // Ensure executable permission on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755)); + } + log::info!("Installed {tool_name} to {}", dest.display()); + } + Err(e) => log::warn!("Failed to install {tool_name}: {e}"), + } + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Force dark GTK theme for native dialogs (file chooser, etc.) @@ -74,6 +141,7 @@ pub fn run() { commands::agent::agent_stop, commands::agent::agent_ready, commands::agent::agent_restart, + commands::agent::agent_set_sandbox, // File watcher commands::watcher::file_watch, commands::watcher::file_unwatch, @@ -159,6 +227,16 @@ pub fn run() { commands::btmsg::btmsg_channel_send, commands::btmsg::btmsg_create_channel, commands::btmsg::btmsg_add_channel_member, + commands::btmsg::btmsg_register_agents, + // btmsg health monitoring + commands::btmsg::btmsg_record_heartbeat, + commands::btmsg::btmsg_get_stale_agents, + commands::btmsg::btmsg_get_dead_letters, + commands::btmsg::btmsg_clear_dead_letters, + // Audit log + commands::btmsg::audit_log_event, + commands::btmsg::audit_log_list, + commands::btmsg::audit_log_for_agent, // bttask (task board) commands::bttask::bttask_list, commands::bttask::bttask_comments, @@ -167,6 +245,23 @@ pub fn run() { commands::bttask::bttask_create, commands::bttask::bttask_delete, commands::bttask::bttask_review_queue_count, + // Search (FTS5) + commands::search::search_init, + commands::search::search_query, + commands::search::search_rebuild, + commands::search::search_index_message, + // Notifications + commands::notifications::notify_desktop, + // Secrets (system keyring) + commands::secrets::secrets_store, + commands::secrets::secrets_get, + commands::secrets::secrets_delete, + commands::secrets::secrets_list, + commands::secrets::secrets_has_keyring, + commands::secrets::secrets_known_keys, + // Plugins + commands::plugins::plugins_discover, + commands::plugins::plugin_read_file, // Misc commands::misc::cli_get_group, commands::misc::open_url, @@ -200,6 +295,11 @@ pub fn run() { .parent() .unwrap() .to_path_buf(); + // Install btmsg/bttask CLI tools to ~/.local/bin/ + if !config.is_test_mode() { + install_cli_tools(&resource_dir, &dev_root); + } + // Forward test mode env vars to sidecar processes let mut env_overrides = std::collections::HashMap::new(); if config.is_test_mode() { @@ -218,6 +318,7 @@ pub fn run() { dev_root.join("sidecar"), ], env_overrides, + sandbox: bterminal_core::sandbox::SandboxConfig::default(), }; let pty_manager = Arc::new(PtyManager::new(sink.clone())); @@ -234,6 +335,12 @@ pub fn run() { let memora_db = Arc::new(memora::MemoraDb::new_with_path(config.memora_db_path.clone())); let remote_manager = Arc::new(RemoteManager::new()); + // Initialize FTS5 search database + let search_db_path = config.data_dir.join("bterminal").join("search.db"); + let search_db = Arc::new( + search::SearchDb::open(&search_db_path).expect("Failed to open search database"), + ); + // Start local sidecar match sidecar_manager.start() { Ok(()) => log::info!("Sidecar startup initiated"), @@ -249,6 +356,7 @@ pub fn run() { ctx_db, memora_db, remote_manager, + search_db, app_config: config, _telemetry: telemetry_guard, }); diff --git a/v2/src-tauri/src/session/mod.rs b/v2/src-tauri/src/session/mod.rs index 699b8ab..dd47ccf 100644 --- a/v2/src-tauri/src/session/mod.rs +++ b/v2/src-tauri/src/session/mod.rs @@ -34,8 +34,11 @@ impl SessionDb { .map_err(|e| format!("Failed to open database: {e}"))?; // Enable WAL mode for better concurrent read performance - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") - .map_err(|e| format!("Failed to set pragmas: {e}"))?; + // journal_mode returns a result row, so use query_row instead of pragma_update + conn.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(())) + .map_err(|e| format!("Failed to set journal_mode: {e}"))?; + conn.pragma_update(None, "foreign_keys", "ON") + .map_err(|e| format!("Failed to set foreign_keys: {e}"))?; let db = Self { conn: Mutex::new(conn) }; db.migrate()?; diff --git a/v2/src-tauri/tauri.conf.json b/v2/src-tauri/tauri.conf.json index 2deb127..60920c8 100644 --- a/v2/src-tauri/tauri.conf.json +++ b/v2/src-tauri/tauri.conf.json @@ -44,7 +44,9 @@ "icons/icon.ico" ], "resources": [ - "../sidecar/dist/claude-runner.mjs" + "../sidecar/dist/claude-runner.mjs", + "../../btmsg", + "../../bttask" ], "category": "DeveloperTool", "shortDescription": "Multi-session Claude agent dashboard", diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index fcd9beb..dd1e5e6 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -14,7 +14,8 @@ import { getAgentSessions, getAgentSession, } from './stores/agents.svelte'; -import { notify } from './stores/notifications.svelte'; +import { notify, addNotification } from './stores/notifications.svelte'; +import { classifyError } from './utils/error-classifier'; import { tel } from './adapters/telemetry-bridge'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; @@ -35,6 +36,10 @@ import { spawnSubagentPane, clearSubagentRoutes, } from './utils/subagent-router'; +import { indexMessage } from './adapters/search-bridge'; +import { recordHeartbeat } from './adapters/btmsg-bridge'; +import { logAuditEvent } from './adapters/audit-bridge'; +import type { AgentId } from './types/ids'; // Re-export public API consumed by other modules export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence'; @@ -72,11 +77,20 @@ export async function startAgentDispatcher(): Promise { if (!msg.sessionId) return; const sessionId = SessionId(msg.sessionId); + // Record heartbeat on any agent activity (best-effort, fire-and-forget) + const hbProjectId = getSessionProjectId(sessionId); + if (hbProjectId) { + recordHeartbeat(hbProjectId as unknown as AgentId).catch(() => {}); + } + switch (msg.type) { case 'agent_started': updateAgentStatus(sessionId, 'running'); recordSessionStart(sessionId); tel.info('agent_started', { sessionId }); + if (hbProjectId) { + logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(() => {}); + } break; case 'agent_event': @@ -87,13 +101,39 @@ export async function startAgentDispatcher(): Promise { updateAgentStatus(sessionId, 'done'); tel.info('agent_stopped', { sessionId }); notify('success', `Agent ${sessionId.slice(0, 8)} completed`); + addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined); + if (hbProjectId) { + logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(() => {}); + } break; - case 'agent_error': - updateAgentStatus(sessionId, 'error', msg.message); - tel.error('agent_error', { sessionId, error: msg.message }); - notify('error', `Agent error: ${msg.message ?? 'Unknown'}`); + case 'agent_error': { + const errorMsg = msg.message ?? 'Unknown'; + const classified = classifyError(errorMsg); + updateAgentStatus(sessionId, 'error', errorMsg); + tel.error('agent_error', { sessionId, error: errorMsg, errorType: classified.type }); + + // Show type-specific toast + if (classified.type === 'rate_limit') { + notify('warning', `Rate limited. ${classified.retryDelaySec > 0 ? `Retrying in ~${classified.retryDelaySec}s...` : ''}`); + } else if (classified.type === 'auth') { + notify('error', 'API key invalid or expired. Check Settings.'); + } else if (classified.type === 'quota') { + notify('error', 'API quota exceeded. Check your billing.'); + } else if (classified.type === 'overloaded') { + notify('warning', 'API overloaded. Will retry shortly...'); + } else if (classified.type === 'network') { + notify('error', 'Network error. Check your connection.'); + } else { + notify('error', `Agent error: ${errorMsg}`); + } + + addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined); + if (hbProjectId) { + logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(() => {}); + } break; + } case 'agent_log': break; @@ -121,6 +161,7 @@ export async function startAgentDispatcher(): Promise { restartAttempts++; const delayMs = 1000 * Math.pow(2, restartAttempts - 1); // 1s, 2s, 4s notify('warning', `Sidecar crashed, restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})...`); + addNotification('Sidecar crashed', `Restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})`, 'system'); await new Promise((resolve) => setTimeout(resolve, delayMs)); try { await restartAgent(); @@ -234,8 +275,19 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record 0 ? `Retrying in ~${costClassified.retryDelaySec}s...` : ''}`); + } else if (costClassified.type === 'auth') { + notify('error', 'API key invalid or expired. Check Settings.'); + } else if (costClassified.type === 'quota') { + notify('error', 'API quota exceeded. Check your billing.'); + } else { + notify('error', `Agent failed: ${cost.errors?.[0] ?? 'Unknown error'}`); + } } else { updateAgentStatus(sessionId, 'done'); notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`); @@ -264,6 +316,13 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record {}); + } + } } // Append messages to child panes and update their status diff --git a/v2/src/lib/components/Workspace/SettingsTab.svelte b/v2/src/lib/components/Workspace/SettingsTab.svelte index c63487f..8acba3f 100644 --- a/v2/src/lib/components/Workspace/SettingsTab.svelte +++ b/v2/src/lib/components/Workspace/SettingsTab.svelte @@ -25,6 +25,22 @@ import type { ProviderId, ProviderSettings } from '../../providers/types'; import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors'; import { WAKE_STRATEGIES, WAKE_STRATEGY_LABELS, WAKE_STRATEGY_DESCRIPTIONS, type WakeStrategy } from '../../types/wake'; + import { + storeSecret, getSecret, deleteSecret, listSecrets, + hasKeyring, knownSecretKeys, SECRET_KEY_LABELS, + } from '../../adapters/secrets-bridge'; + import { + checkForUpdates, + getCurrentVersion, + getLastCheckTimestamp, + type UpdateInfo, + } from '../../utils/updater'; + import { + getPluginEntries, + setPluginEnabled, + reloadAllPlugins, + type PluginEntry, + } from '../../stores/plugins.svelte'; const PROJECT_ICONS = [ '📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻', @@ -61,6 +77,23 @@ let filesSaveOnBlur = $state(false); let selectedTheme = $state(getCurrentTheme()); + // Updater state + let appVersion = $state(''); + let updateCheckResult = $state(null); + let updateChecking = $state(false); + let updateLastCheck = $state(''); + + // Secrets state + let keyringAvailable = $state(false); + let storedKeys = $state([]); + let knownKeys = $state([]); + let revealedKey = $state(null); + let revealedValue = $state(''); + let newSecretKey = $state(''); + let newSecretValue = $state(''); + let secretsKeyDropdownOpen = $state(false); + let secretsSaving = $state(false); + // Dropdown open states let themeDropdownOpen = $state(false); let uiFontDropdownOpen = $state(false); @@ -152,12 +185,40 @@ } catch { providerSettings = {}; } + + // Load secrets state + try { + keyringAvailable = await hasKeyring(); + if (keyringAvailable) { + storedKeys = await listSecrets(); + knownKeys = await knownSecretKeys(); + } + } catch { + keyringAvailable = false; + } + + // Load app version for updater section + appVersion = await getCurrentVersion(); + const ts = getLastCheckTimestamp(); + if (ts) updateLastCheck = new Date(ts).toLocaleString(); }); function applyCssProp(prop: string, value: string) { document.documentElement.style.setProperty(prop, value); } + async function handleCheckForUpdates() { + updateChecking = true; + try { + updateCheckResult = await checkForUpdates(); + updateLastCheck = new Date().toLocaleString(); + } catch { + updateCheckResult = { available: false }; + } finally { + updateChecking = false; + } + } + async function saveGlobalSetting(key: string, value: string) { try { await setSetting(key, value); @@ -244,6 +305,66 @@ return providerSettings[providerId]?.enabled ?? true; } + // --- Secrets handlers --- + + async function handleRevealSecret(key: string) { + if (revealedKey === key) { + revealedKey = null; + revealedValue = ''; + return; + } + try { + const val = await getSecret(key); + revealedKey = key; + revealedValue = val ?? ''; + } catch (e) { + console.error(`Failed to reveal secret '${key}':`, e); + } + } + + async function handleSaveSecret() { + if (!newSecretKey || !newSecretValue) return; + secretsSaving = true; + try { + await storeSecret(newSecretKey, newSecretValue); + storedKeys = await listSecrets(); + newSecretKey = ''; + newSecretValue = ''; + // If we just saved the currently revealed key, clear reveal + revealedKey = null; + revealedValue = ''; + } catch (e) { + console.error('Failed to store secret:', e); + } finally { + secretsSaving = false; + } + } + + async function handleDeleteSecret(key: string) { + try { + await deleteSecret(key); + storedKeys = await listSecrets(); + if (revealedKey === key) { + revealedKey = null; + revealedValue = ''; + } + } catch (e) { + console.error(`Failed to delete secret '${key}':`, e); + } + } + + function getSecretKeyLabel(key: string): string { + return SECRET_KEY_LABELS[key] ?? key; + } + + let availableKeysForAdd = $derived( + knownKeys.filter(k => !storedKeys.includes(k)), + ); + + let newSecretKeyLabel = $derived( + newSecretKey ? getSecretKeyLabel(newSecretKey) : 'Select key...', + ); + function handleClickOutside(e: MouseEvent) { const target = e.target as HTMLElement; if (!target.closest('.custom-dropdown')) { @@ -251,6 +372,7 @@ uiFontDropdownOpen = false; termFontDropdownOpen = false; providerDropdownOpenFor = null; + secretsKeyDropdownOpen = false; } if (!target.closest('.icon-field')) { iconPickerOpenFor = null; @@ -267,6 +389,7 @@ termFontDropdownOpen = false; iconPickerOpenFor = null; profileDropdownOpenFor = null; + secretsKeyDropdownOpen = false; } } @@ -301,6 +424,13 @@ newCwd = ''; } + // Plugin entries (reactive from store) + let pluginEntries = $derived(getPluginEntries()); + + async function handleReloadPlugins() { + await reloadAllPlugins(activeGroupId); + } + // New group form let newGroupName = $state(''); @@ -548,6 +678,37 @@ +
+

Updates

+
+
+ Current version + {appVersion || '...'} +
+ {#if updateLastCheck} +
+ Last checked + {updateLastCheck} +
+ {/if} + {#if updateCheckResult?.available} +
+ Available + v{updateCheckResult.version} +
+ {/if} +
+ +
+
+
+

Providers

@@ -605,6 +766,171 @@
+
+

Secrets

+
+ + + {keyringAvailable ? 'System keyring available' : 'System keyring unavailable'} + +
+ + {#if !keyringAvailable} +
+ + System keyring not available. Secrets cannot be stored securely. +
+ {:else} + {#if storedKeys.length > 0} +
+ {#each storedKeys as key} +
+
+ {getSecretKeyLabel(key)} + {key} +
+
+ {#if revealedKey === key} + + {:else} + {'\u25CF'.repeat(8)} + {/if} +
+
+ + +
+
+ {/each} +
+ {/if} + +
+
+
+ + {#if secretsKeyDropdownOpen} + + {/if} +
+ + +
+
+ {/if} +
+ +
+

Plugins

+ {#if pluginEntries.length === 0} +

No plugins found in ~/.config/bterminal/plugins/

+ {:else} +
+ {#each pluginEntries as entry (entry.meta.id)} +
+
+ {entry.meta.name} + v{entry.meta.version} + {#if entry.status === 'loaded'} + loaded + {:else if entry.status === 'error'} + error + {:else if entry.status === 'disabled'} + disabled + {:else} + discovered + {/if} +
+ {#if entry.meta.description} +

{entry.meta.description}

+ {/if} + {#if entry.meta.permissions.length > 0} +
+ {#each entry.meta.permissions as perm} + {perm} + {/each} +
+ {/if} + {#if entry.error} +

{entry.error}

+ {/if} + +
+ {/each} +
+ {/if} + +
+

Groups

@@ -962,6 +1288,21 @@
+
+ + + Sandbox (Landlock) + + +
+
@@ -1068,6 +1409,21 @@ letter-spacing: 0.03em; } + .setting-value { + font-size: 0.8rem; + color: var(--ctp-text); + } + + .setting-muted { + color: var(--ctp-overlay0); + font-size: 0.75rem; + } + + .update-available { + color: var(--ctp-green); + font-weight: 600; + } + .setting-field > input, .setting-field .input-with-browse input { padding: 0.375rem 0.625rem; @@ -2044,4 +2400,313 @@ overflow-y: auto; border-top: 1px solid var(--ctp-surface1); } + + /* Secrets section */ + .secrets-status { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.625rem; + } + + .keyring-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .keyring-indicator.available { + background: var(--ctp-green); + box-shadow: 0 0 4px color-mix(in srgb, var(--ctp-green) 50%, transparent); + } + + .keyring-indicator.unavailable { + background: var(--ctp-red); + box-shadow: 0 0 4px color-mix(in srgb, var(--ctp-red) 50%, transparent); + } + + .keyring-label { + font-size: 0.75rem; + color: var(--ctp-subtext0); + } + + .secrets-warning { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + background: color-mix(in srgb, var(--ctp-red) 8%, var(--ctp-surface0)); + border: 1px solid color-mix(in srgb, var(--ctp-red) 30%, var(--ctp-surface1)); + border-radius: 0.375rem; + color: var(--ctp-red); + font-size: 0.75rem; + line-height: 1.4; + } + + .secrets-warning svg { + flex-shrink: 0; + } + + .secrets-list { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-bottom: 0.625rem; + } + + .secret-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + background: var(--ctp-surface0); + border: 1px solid var(--ctp-surface1); + border-radius: 0.375rem; + transition: border-color 0.15s; + } + + .secret-row:hover { + border-color: var(--ctp-surface2); + } + + .secret-info { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + flex-shrink: 0; + } + + .secret-key-name { + font-size: 0.78rem; + font-weight: 600; + color: var(--ctp-text); + white-space: nowrap; + } + + .secret-key-id { + font-size: 0.625rem; + color: var(--ctp-overlay0); + font-family: var(--term-font-family, monospace); + } + + .secret-value-area { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + } + + .secret-masked { + color: var(--ctp-overlay0); + font-size: 0.75rem; + letter-spacing: 0.1em; + } + + .secret-value-input { + width: 100%; + padding: 0.25rem 0.5rem; + background: var(--ctp-base); + border: 1px solid var(--ctp-surface1); + border-radius: 0.25rem; + color: var(--ctp-text); + font-size: 0.75rem; + font-family: var(--term-font-family, monospace); + } + + .secret-actions { + display: flex; + gap: 0.25rem; + flex-shrink: 0; + } + + .secret-btn { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + background: transparent; + border: 1px solid var(--ctp-surface1); + border-radius: 0.25rem; + color: var(--ctp-overlay1); + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; + } + + .secret-btn:hover { + color: var(--ctp-text); + background: var(--ctp-surface0); + border-color: var(--ctp-surface2); + } + + .secret-btn-danger:hover { + color: var(--ctp-red); + background: color-mix(in srgb, var(--ctp-red) 8%, transparent); + border-color: color-mix(in srgb, var(--ctp-red) 30%, var(--ctp-surface1)); + } + + .secret-add-form { + padding: 0.5rem 0.625rem; + background: var(--ctp-mantle); + border: 1px dashed var(--ctp-surface1); + border-radius: 0.375rem; + } + + .secret-add-row { + display: flex; + gap: 0.375rem; + align-items: stretch; + } + + .secret-key-dropdown { + min-width: 10rem; + flex-shrink: 0; + } + + .secret-key-hint { + font-size: 0.625rem; + color: var(--ctp-overlay0); + font-family: var(--term-font-family, monospace); + margin-left: auto; + padding-left: 0.5rem; + } + + .dropdown-empty { + display: block; + padding: 0.375rem 0.625rem; + font-size: 0.75rem; + color: var(--ctp-overlay0); + font-style: italic; + } + + .secret-value-new { + flex: 1; + min-width: 0; + padding: 0.375rem 0.625rem; + background: var(--ctp-surface0); + border: 1px solid var(--ctp-surface1); + border-radius: 0.25rem; + color: var(--ctp-text); + font-size: 0.8rem; + } + + .secret-value-new:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .secret-value-new:focus { + border-color: var(--ctp-blue); + outline: none; + } + + /* --- Plugins section --- */ + + .plugin-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.625rem; + } + + .plugin-row { + position: relative; + background: var(--ctp-surface0); + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + } + + .plugin-info { + display: flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.125rem; + } + + .plugin-name { + font-size: 0.82rem; + font-weight: 600; + color: var(--ctp-text); + } + + .plugin-version { + font-size: 0.68rem; + color: var(--ctp-overlay0); + } + + .plugin-badge { + font-size: 0.6rem; + padding: 0.05rem 0.3rem; + border-radius: 0.1875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .plugin-badge.loaded { + background: color-mix(in srgb, var(--ctp-green) 20%, transparent); + color: var(--ctp-green); + } + + .plugin-badge.error { + background: color-mix(in srgb, var(--ctp-red) 20%, transparent); + color: var(--ctp-red); + } + + .plugin-badge.disabled { + background: color-mix(in srgb, var(--ctp-overlay0) 20%, transparent); + color: var(--ctp-overlay0); + } + + .plugin-badge.discovered { + background: color-mix(in srgb, var(--ctp-blue) 20%, transparent); + color: var(--ctp-blue); + } + + .plugin-desc { + font-size: 0.75rem; + color: var(--ctp-subtext0); + margin: 0.125rem 0 0.25rem; + } + + .plugin-perms { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + margin-top: 0.125rem; + } + + .perm-badge { + font-size: 0.6rem; + padding: 0.05rem 0.25rem; + border-radius: 0.125rem; + background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent); + color: var(--ctp-mauve); + font-family: var(--font-mono, monospace); + } + + .plugin-error { + font-size: 0.7rem; + color: var(--ctp-red); + margin: 0.25rem 0 0; + word-break: break-word; + } + + .plugin-row .card-toggle { + position: absolute; + top: 0.5rem; + right: 0.5rem; + } + + .empty-notice { + font-size: 0.78rem; + color: var(--ctp-overlay0); + margin: 0 0 0.5rem; + } + + .reload-plugins-btn { + margin-top: 0.25rem; + } diff --git a/v2/src/lib/utils/updater.ts b/v2/src/lib/utils/updater.ts index 5a1f0c9..5b9313f 100644 --- a/v2/src/lib/utils/updater.ts +++ b/v2/src/lib/utils/updater.ts @@ -1,32 +1,75 @@ // Auto-update checker — uses Tauri updater plugin // Requires signing key to be configured in tauri.conf.json before use -import { check } from '@tauri-apps/plugin-updater'; +import { check, type Update } from '@tauri-apps/plugin-updater'; +import { getVersion } from '@tauri-apps/api/app'; -export async function checkForUpdates(): Promise<{ +export interface UpdateInfo { available: boolean; version?: string; notes?: string; -}> { + date?: string; + currentVersion?: string; +} + +// Cache the last check result for UI access +let lastCheckResult: UpdateInfo | null = null; +let lastCheckTimestamp: number | null = null; +let cachedUpdate: Update | null = null; + +export function getLastCheckResult(): UpdateInfo | null { + return lastCheckResult; +} + +export function getLastCheckTimestamp(): number | null { + return lastCheckTimestamp; +} + +export async function getCurrentVersion(): Promise { try { - const update = await check(); + return await getVersion(); + } catch { + return '0.0.0'; + } +} + +export async function checkForUpdates(): Promise { + try { + const [update, currentVersion] = await Promise.all([check(), getCurrentVersion()]); + lastCheckTimestamp = Date.now(); + if (update) { - return { + cachedUpdate = update; + lastCheckResult = { available: true, version: update.version, notes: update.body ?? undefined, + date: update.date ?? undefined, + currentVersion, + }; + } else { + cachedUpdate = null; + lastCheckResult = { + available: false, + currentVersion, }; } - return { available: false }; + + return lastCheckResult; } catch { // Updater not configured or network error — silently skip - return { available: false }; + lastCheckResult = { available: false }; + lastCheckTimestamp = Date.now(); + return lastCheckResult; } } export async function installUpdate(): Promise { - const update = await check(); + // Use cached update from last check if available + const update = cachedUpdate ?? (await check()); if (update) { + // downloadAndInstall will restart the app after installation await update.downloadAndInstall(); + // If we reach here, the app should relaunch automatically } }