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.
This commit is contained in:
Hibryda 2026-03-12 04:57:29 +01:00
parent 3cb65fd5e5
commit c193db49a8
9 changed files with 1377 additions and 20 deletions

473
v2/Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -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<ctx::CtxDb>,
pub memora_db: Arc<memora::MemoraDb>,
pub remote_manager: Arc<RemoteManager>,
pub search_db: Arc<search::SearchDb>,
pub app_config: Arc<AppConfig>,
_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,
});

View file

@ -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()?;

View file

@ -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",

View file

@ -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<void> {
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<void> {
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<void> {
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<string, unknow
isError: cost.isError,
});
if (cost.isError) {
updateAgentStatus(sessionId, 'error', cost.errors?.join('; '));
notify('error', `Agent failed: ${cost.errors?.[0] ?? 'Unknown error'}`);
const costErrorMsg = cost.errors?.join('; ') ?? 'Unknown error';
const costClassified = classifyError(costErrorMsg);
updateAgentStatus(sessionId, 'error', costErrorMsg);
if (costClassified.type === 'rate_limit') {
notify('warning', `Rate limited. ${costClassified.retryDelaySec > 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<string, unknow
else recordActivity(actProjId);
}
appendAgentMessages(sessionId, mainMessages);
// Index searchable text content into FTS5 search database
for (const msg of mainMessages) {
if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) {
indexMessage(sessionId, 'assistant', msg.content).catch(() => {});
}
}
}
// Append messages to child panes and update their status

View file

@ -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<ThemeId>(getCurrentTheme());
// Updater state
let appVersion = $state('');
let updateCheckResult = $state<UpdateInfo | null>(null);
let updateChecking = $state(false);
let updateLastCheck = $state<string>('');
// Secrets state
let keyringAvailable = $state(false);
let storedKeys = $state<string[]>([]);
let knownKeys = $state<string[]>([]);
let revealedKey = $state<string | null>(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 @@
</div>
</section>
<section class="settings-section">
<h2>Updates</h2>
<div class="settings-list">
<div class="setting-field">
<span class="setting-label">Current version</span>
<span class="setting-value">{appVersion || '...'}</span>
</div>
{#if updateLastCheck}
<div class="setting-field">
<span class="setting-label">Last checked</span>
<span class="setting-value setting-muted">{updateLastCheck}</span>
</div>
{/if}
{#if updateCheckResult?.available}
<div class="setting-field">
<span class="setting-label">Available</span>
<span class="setting-value update-available">v{updateCheckResult.version}</span>
</div>
{/if}
<div class="setting-field">
<button
class="btn-primary"
onclick={handleCheckForUpdates}
disabled={updateChecking}
>
{updateChecking ? 'Checking...' : 'Check for Updates'}
</button>
</div>
</div>
</section>
<section class="settings-section">
<h2>Providers</h2>
<div class="provider-list">
@ -605,6 +766,171 @@
</div>
</section>
<section class="settings-section">
<h2>Secrets</h2>
<div class="secrets-status">
<span class="keyring-indicator" class:available={keyringAvailable} class:unavailable={!keyringAvailable}></span>
<span class="keyring-label">
{keyringAvailable ? 'System keyring available' : 'System keyring unavailable'}
</span>
</div>
{#if !keyringAvailable}
<div class="secrets-warning">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>System keyring not available. Secrets cannot be stored securely.</span>
</div>
{:else}
{#if storedKeys.length > 0}
<div class="secrets-list">
{#each storedKeys as key}
<div class="secret-row">
<div class="secret-info">
<span class="secret-key-name">{getSecretKeyLabel(key)}</span>
<span class="secret-key-id">{key}</span>
</div>
<div class="secret-value-area">
{#if revealedKey === key}
<input
type="text"
class="secret-value-input"
value={revealedValue}
readonly
/>
{:else}
<span class="secret-masked">{'\u25CF'.repeat(8)}</span>
{/if}
</div>
<div class="secret-actions">
<button
class="secret-btn"
title={revealedKey === key ? 'Hide' : 'Reveal'}
onclick={() => handleRevealSecret(key)}
>
{#if revealedKey === key}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
<button
class="secret-btn secret-btn-danger"
title="Delete"
onclick={() => handleDeleteSecret(key)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
</button>
</div>
</div>
{/each}
</div>
{/if}
<div class="secret-add-form">
<div class="secret-add-row">
<div class="custom-dropdown secret-key-dropdown">
<button
class="dropdown-trigger"
onclick={() => { secretsKeyDropdownOpen = !secretsKeyDropdownOpen; }}
aria-haspopup="listbox"
aria-expanded={secretsKeyDropdownOpen}
>
<span class="dropdown-label">{newSecretKeyLabel}</span>
<span class="dropdown-arrow">{secretsKeyDropdownOpen ? '\u25B4' : '\u25BE'}</span>
</button>
{#if secretsKeyDropdownOpen}
<div class="dropdown-menu" role="listbox">
{#each availableKeysForAdd as key}
<button
class="dropdown-option"
class:active={newSecretKey === key}
role="option"
aria-selected={newSecretKey === key}
onclick={() => { newSecretKey = key; secretsKeyDropdownOpen = false; }}
>
<span class="dropdown-option-label">{getSecretKeyLabel(key)}</span>
<span class="secret-key-hint">{key}</span>
</button>
{/each}
{#if availableKeysForAdd.length === 0}
<span class="dropdown-empty">All keys configured</span>
{/if}
</div>
{/if}
</div>
<input
type="password"
class="secret-value-new"
bind:value={newSecretValue}
placeholder="Secret value"
disabled={!newSecretKey}
/>
<button
class="btn-primary"
onclick={handleSaveSecret}
disabled={!newSecretKey || !newSecretValue || secretsSaving}
>
{secretsSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/if}
</section>
<section class="settings-section">
<h2>Plugins</h2>
{#if pluginEntries.length === 0}
<p class="empty-notice">No plugins found in ~/.config/bterminal/plugins/</p>
{:else}
<div class="plugin-list">
{#each pluginEntries as entry (entry.meta.id)}
<div class="plugin-row">
<div class="plugin-info">
<span class="plugin-name">{entry.meta.name}</span>
<span class="plugin-version">v{entry.meta.version}</span>
{#if entry.status === 'loaded'}
<span class="plugin-badge loaded" title="Loaded">loaded</span>
{:else if entry.status === 'error'}
<span class="plugin-badge error" title={entry.error ?? 'Error'}>error</span>
{:else if entry.status === 'disabled'}
<span class="plugin-badge disabled">disabled</span>
{:else}
<span class="plugin-badge discovered">discovered</span>
{/if}
</div>
{#if entry.meta.description}
<p class="plugin-desc">{entry.meta.description}</p>
{/if}
{#if entry.meta.permissions.length > 0}
<div class="plugin-perms">
{#each entry.meta.permissions as perm}
<span class="perm-badge">{perm}</span>
{/each}
</div>
{/if}
{#if entry.error}
<p class="plugin-error">{entry.error}</p>
{/if}
<label class="card-toggle" title={entry.status === 'disabled' ? 'Disabled' : 'Enabled'}>
<input
type="checkbox"
checked={entry.status !== 'disabled'}
onchange={async (e) => {
const enabled = (e.target as HTMLInputElement).checked;
await setPluginEnabled(entry.meta.id, enabled);
}}
/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
{/each}
</div>
{/if}
<button class="btn-primary reload-plugins-btn" onclick={handleReloadPlugins}>
Reload Plugins
</button>
</section>
<section class="settings-section">
<h2>Groups</h2>
<div class="group-list">
@ -962,6 +1288,21 @@
</label>
</div>
<div class="card-field card-field-row">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Sandbox (Landlock)
</span>
<label class="card-toggle" title={project.sandboxEnabled ? 'Filesystem sandbox enabled' : 'Filesystem sandbox disabled'}>
<input
type="checkbox"
checked={project.sandboxEnabled ?? false}
onchange={e => updateProject(activeGroupId, project.id, { sandboxEnabled: (e.target as HTMLInputElement).checked })}
/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
@ -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;
}
</style>

View file

@ -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<string> {
try {
const update = await check();
return await getVersion();
} catch {
return '0.0.0';
}
}
export async function checkForUpdates(): Promise<UpdateInfo> {
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<void> {
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
}
}