From 7d6e4d1c20744ea46c59de019ec8db43b3efd89b Mon Sep 17 00:00:00 2001 From: Manuel Date: Sat, 27 Jun 2026 19:31:18 +0200 Subject: [PATCH 01/21] Mirror status.json to host dir to stop cross-app TCC prompts The non-sandboxed Tauri host read status.json from the App Group container, so macOS fired "Git-Same would like to access data from other apps" up to five times on launch and after each sync. The monitor now mirrors a real status.json into ~/.config/git-same/ finder/ (StatusFileWriter::new_with_mirrors) in addition to the container, and the host reads only that host-home copy via a shared tauri::State resolved once at startup. ensure_legacy_symlinks no longer symlinks status.json (only finder.sock), and the host unlinks any leftover status.json symlink before reading so it never follows a link into the container during the upgrade window. The FinderSync extension, the socket, and all entitlements are unchanged, so no Apple re-sign or macOS-26 re-test is required. --- crates/git-same-app/src/commands.rs | 57 +++++++--- crates/git-same-app/src/commands_tests.rs | 39 +++++++ crates/git-same-app/src/main.rs | 11 +- crates/git-same-app/src/status_stream.rs | 10 +- crates/git-same-core/src/ipc/mod.rs | 12 ++ crates/git-same-core/src/ipc/mod_tests.rs | 16 +++ crates/git-same-core/src/ipc/status_file.rs | 106 ++++++++++++++---- .../src/ipc/status_file_tests.rs | 79 +++++++++++++ crates/git-same-core/src/monitor/run.rs | 36 +++++- .../src/monitor/socket_handler.rs | 16 +-- .../src/monitor/socket_handler_tests.rs | 5 +- 11 files changed, 333 insertions(+), 54 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 904caf4..ff04d0e 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -38,6 +38,14 @@ const FINDER_EXTENSION_ID: &str = "com.zaai.git-same.badges"; #[path = "commands_tests.rs"] mod tests; +/// Resolved host-facing IPC config, shared across Tauri command handlers via +/// `tauri::State`. Resolved once in `main.rs` `setup()` so handlers read live +/// status from `~/.config/git-same/finder/` (where the monitor mirrors a real +/// `status.json`) instead of reaching into the app-group container, which would +/// trigger the "access data from other apps" TCC prompt on the non-sandboxed +/// host. +pub struct HostIpc(pub IpcConfig); + #[derive(Debug, Clone, Serialize)] pub struct WorkspaceSummary { pub id: String, @@ -394,13 +402,18 @@ pub fn set_default_workspace( } #[tauri::command] -pub async fn check_requirements() -> Result, String> { +pub async fn check_requirements( + ipc: tauri::State<'_, HostIpc>, +) -> Result, String> { + // Clone the resolved host IPC config out of the state guard before any + // `.await` so no borrow of the guard is held across an await point. + let host_ipc = ipc.inner().0.clone(); let mut checks: Vec = git_same_core::checks::check_requirements() .await .into_iter() .map(requirement_check_dto) .collect(); - checks.extend(app_requirement_checks()); + checks.extend(app_requirement_checks(&host_ipc)); Ok(checks) } @@ -602,15 +615,19 @@ pub async fn read_workspace_structure( } #[tauri::command] -pub async fn read_status() -> Result { - read_status_snapshot().map_err(error_string) +pub async fn read_status(ipc: tauri::State<'_, HostIpc>) -> Result { + read_status_snapshot_with(&ipc.0).map_err(error_string) } #[tauri::command] pub async fn start_sync( app: tauri::AppHandle, workspace_id: String, + ipc: tauri::State<'_, HostIpc>, ) -> Result { + // Clone the resolved host IPC config out of the state guard before any + // `.await` so no borrow of the guard is held across an await point. + let host_ipc = ipc.inner().0.clone(); let config = Config::load().map_err(error_string)?; let mut workspace = WorkspaceManager::resolve(Some(&workspace_id), &config).map_err(error_string)?; @@ -652,8 +669,7 @@ pub async fn start_sync( workspace.last_synced = Some(chrono::Utc::now().to_rfc3339()); WorkspaceManager::save(&workspace).map_err(error_string)?; - let ipc = IpcConfig::default_path().map_err(error_string)?; - read_status_snapshot_with(&ipc).map_err(error_string) + read_status_snapshot_with(&host_ipc).map_err(error_string) } fn sync_progress_reporter(app: tauri::AppHandle, workspace_id: String) -> ProgressReporter { @@ -926,7 +942,7 @@ fn sync_mode_label(sync_mode: SyncMode) -> String { .to_string() } -fn app_requirement_checks() -> Vec { +fn app_requirement_checks(ipc: &IpcConfig) -> Vec { let config_path = match Config::default_path() { Ok(path) => path, Err(error) => { @@ -952,7 +968,7 @@ fn app_requirement_checks() -> Vec { critical: true, }]; - let snapshot = read_status_snapshot().ok(); + let snapshot = read_status_snapshot_with(ipc).ok(); let monitor_agent = monitor_launch_agent_status_inner().ok(); checks.push(RequirementCheckDto { name: "Monitor".to_string(), @@ -1214,15 +1230,12 @@ fn requirement_check_dto(check: CheckResult) -> RequirementCheckDto { } } -pub(crate) fn read_status_snapshot() -> Result { - let ipc = IpcConfig::default_path()?; - read_status_snapshot_with(&ipc) -} - /// `stale` describes badge-data freshness only. Whether a monitor process /// is running is a separate question answered by the monitor status. -fn read_status_snapshot_with(ipc: &IpcConfig) -> Result { +pub(crate) fn read_status_snapshot_with(ipc: &IpcConfig) -> Result { + ipc.ensure_dir()?; let status_path = ipc.status_file_path(); + remove_legacy_status_symlink(&status_path); let writer = StatusFileWriter::new(status_path.clone()); let modified = fs::metadata(&status_path) .ok() @@ -1244,6 +1257,22 @@ fn read_status_snapshot_with(ipc: &IpcConfig) -> Result, diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index 786eabd..1f62c69 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -211,6 +211,45 @@ fn read_status_snapshot_freshness_does_not_depend_on_the_monitor_process() { assert!(status.repos.is_empty()); } +#[cfg(unix)] +#[test] +fn read_status_snapshot_removes_a_status_symlink_and_reports_absent() { + use std::os::unix::fs::symlink; + + let temp = TestDir::new("status-symlink"); + let ipc = IpcConfig { + dir: temp.path().join("ipc"), + }; + ipc.ensure_dir().unwrap(); + + // Simulate the pre-upgrade layout: status.json is a symlink into another + // location (the app-group container). Following it would re-trigger the + // cross-app TCC prompt. + let external_target = temp.path().join("container-status.json"); + let mut external = FinderStatus::new(4242, chrono::Utc::now().to_rfc3339()); + external.repos = Vec::new(); + StatusFileWriter::new(external_target.clone()) + .write(&external) + .unwrap(); + let status_path = ipc.status_file_path(); + symlink(&external_target, &status_path).unwrap(); + assert!(std::fs::symlink_metadata(&status_path) + .unwrap() + .file_type() + .is_symlink()); + + let snapshot = read_status_snapshot_with(&ipc).unwrap(); + + // The guard unlinks the symlink and reports status absent rather than + // dereferencing it into the container. + assert!(snapshot.status.is_none()); + assert!(snapshot.stale); + assert!( + std::fs::symlink_metadata(&status_path).is_err(), + "status.json symlink must be removed" + ); +} + #[test] fn ensure_config_creates_default_config() { let temp = TestDir::new("ensure-config"); diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index 4eb2206..c929ec2 100644 --- a/crates/git-same-app/src/main.rs +++ b/crates/git-same-app/src/main.rs @@ -1,6 +1,8 @@ mod commands; mod status_stream; +use tauri::Manager; + fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -30,7 +32,14 @@ fn main() { ]) .manage(commands::MonitorStatusCache::default()) .setup(|app| { - if let Err(error) = status_stream::spawn_watcher(app.handle().clone()) { + // Resolve the host-facing IPC config once and share it with every + // command handler via state, so handlers read the mirrored + // status.json from the host's own home rather than reaching into the + // app-group container (which triggers the "access data from other + // apps" TCC prompt). + let host_ipc = git_same_core::ipc::IpcConfig::host_status_path()?; + app.manage(commands::HostIpc(host_ipc.clone())); + if let Err(error) = status_stream::spawn_watcher(app.handle().clone(), host_ipc) { eprintln!("failed to start status watcher: {error}"); } // Recover monitoring in the background; the window stays responsive. diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs index 08e6f81..f7d647b 100644 --- a/crates/git-same-app/src/status_stream.rs +++ b/crates/git-same-app/src/status_stream.rs @@ -8,7 +8,7 @@ //! //! Temp files of atomic writes, the lock file, and caches are ignored. -use crate::commands::{read_status_snapshot, refresh_monitor_status}; +use crate::commands::{read_status_snapshot_with, refresh_monitor_status}; use git_same_core::ipc::IpcConfig; use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use std::ffi::OsString; @@ -141,8 +141,10 @@ impl Debouncer { } } -pub fn spawn_watcher(app: AppHandle) -> anyhow::Result<()> { - let ipc = IpcConfig::default_path()?; +/// `ipc` is the resolved host-facing config (`~/.config/git-same/finder/`, +/// where the monitor mirrors a real `status.json`), so neither the watch nor +/// the reads cross into the app-group container. +pub fn spawn_watcher(app: AppHandle, ipc: IpcConfig) -> anyhow::Result<()> { ipc.ensure_dir()?; std::thread::Builder::new() @@ -188,7 +190,7 @@ pub fn spawn_watcher(app: AppHandle) -> anyhow::Result<()> { continue; }; if fired == Relevance::DataAndMonitor { - if let Ok(snapshot) = read_status_snapshot() { + if let Ok(snapshot) = read_status_snapshot_with(&ipc) { let _ = app.emit("status-updated", snapshot); } } diff --git a/crates/git-same-core/src/ipc/mod.rs b/crates/git-same-core/src/ipc/mod.rs index b63996c..b988dca 100644 --- a/crates/git-same-core/src/ipc/mod.rs +++ b/crates/git-same-core/src/ipc/mod.rs @@ -77,6 +77,18 @@ impl IpcConfig { }) } + /// Returns the host-facing, non-container IPC dir (`~/.config/git-same/finder/`). + /// + /// On macOS the monitor mirrors a real `status.json` here so the + /// non-sandboxed Tauri host can read live status without reaching into the + /// app-group container, which would trigger the "access data from other + /// apps" TCC prompt. This is the same directory as `legacy_default_path()`; + /// the distinct name documents *why* the host uses it (it is the host's own + /// home, not a legacy fallback). + pub fn host_status_path() -> Result { + Self::legacy_default_path() + } + /// Path to the status JSON file. pub fn status_file_path(&self) -> PathBuf { self.dir.join("status.json") diff --git a/crates/git-same-core/src/ipc/mod_tests.rs b/crates/git-same-core/src/ipc/mod_tests.rs index 2ab779b..ea36670 100644 --- a/crates/git-same-core/src/ipc/mod_tests.rs +++ b/crates/git-same-core/src/ipc/mod_tests.rs @@ -99,3 +99,19 @@ fn test_legacy_default_path_ends_in_finder() { ); } } + +#[test] +fn test_host_status_path_matches_legacy_default_path() { + // The host reads from the non-container host path; it must resolve to the + // same directory as legacy_default_path (a distinct name for clarity). + let host = IpcConfig::host_status_path(); + let legacy = IpcConfig::legacy_default_path(); + match (host, legacy) { + (Ok(host), Ok(legacy)) => { + assert_eq!(host.dir, legacy.dir); + assert!(host.dir.ends_with("git-same/finder")); + } + (Err(_), Err(_)) => {} + _ => panic!("host_status_path and legacy_default_path disagreed on success"), + } +} diff --git a/crates/git-same-core/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs index 9c38ce0..358ae0b 100644 --- a/crates/git-same-core/src/ipc/status_file.rs +++ b/crates/git-same-core/src/ipc/status_file.rs @@ -12,12 +12,28 @@ use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct StatusFileWriter { path: PathBuf, + mirrors: Vec, } impl StatusFileWriter { /// Creates a writer for the given status file path. pub fn new(path: PathBuf) -> Self { - Self { path } + Self { + path, + mirrors: Vec::new(), + } + } + + /// Creates a writer that, after writing the primary `path`, writes an + /// identical atomic copy to each path in `mirrors`. + /// + /// Used on macOS so the monitor can keep `status.json` in the app-group + /// container (read by the sandboxed Badges extension) while also mirroring + /// a real copy into `~/.config/git-same/finder/` that the non-sandboxed + /// Tauri host can read without crossing the container boundary (which would + /// trigger the "access data from other apps" TCC prompt). + pub fn new_with_mirrors(path: PathBuf, mirrors: Vec) -> Self { + Self { path, mirrors } } /// The path this writer writes to. @@ -25,18 +41,20 @@ impl StatusFileWriter { &self.path } - /// Writes the status atomically (write to temp, then rename). + /// Writes the status atomically to the primary path and every mirror. + /// + /// Each destination is written to a sibling temp file and then renamed, so + /// readers never observe a partial file and any pre-existing symlink at a + /// destination is replaced by a real file (rename swaps the directory + /// entry; it does not follow the link). pub fn write(&self, status: &FinderStatus) -> Result<(), AppError> { let json = serde_json::to_string_pretty(status) .map_err(|e| AppError::config(format!("Failed to serialize finder status: {}", e)))?; - crate::fsutil::atomic_write(&self.path, json.as_bytes(), None).map_err(|e| { - AppError::path(format!( - "Failed to write status file '{}': {}", - self.path.display(), - e - )) - })?; + write_atomic(&self.path, &json)?; + for mirror in &self.mirrors { + write_atomic(mirror, &json)?; + } Ok(()) } @@ -61,8 +79,54 @@ impl StatusFileWriter { } } -/// On macOS, ensures `~/.config/git-same/finder/{status.json, finder.sock}` are -/// symlinks pointing into the app-group container directory. +/// Writes `json` to `path` atomically: write to a sibling `.json.tmp` +/// file, then rename it over `path`. The rename replaces the destination +/// directory entry (including a pre-existing symlink) without following it. +fn write_atomic(path: &Path, json: &str) -> Result<(), AppError> { + let temp_path = path.with_extension("json.tmp"); + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::path(format!( + "Failed to create directory '{}': {}", + parent.display(), + e + )) + })?; + } + + // Write to temp file + std::fs::write(&temp_path, json).map_err(|e| { + AppError::path(format!( + "Failed to write temp status file '{}': {}", + temp_path.display(), + e + )) + })?; + + // Atomic rename + std::fs::rename(&temp_path, path).map_err(|e| { + AppError::path(format!( + "Failed to rename '{}' -> '{}': {}", + temp_path.display(), + path.display(), + e + )) + })?; + + Ok(()) +} + +/// On macOS, ensures `~/.config/git-same/finder/finder.sock` is a symlink +/// pointing into the app-group container directory. +/// +/// `status.json` is deliberately **not** symlinked: the monitor writes a real +/// mirror copy there (see [`StatusFileWriter::new_with_mirrors`]) so the +/// non-sandboxed Tauri host can read it without following a link into the +/// container (which would re-trigger the "access data from other apps" prompt). +/// The monitor's first mirror write replaces any leftover `status.json` symlink +/// from an earlier layout with a real file. /// /// Idempotent. If a legacy regular file already exists at the destination, it /// is renamed aside as `.user-saved-` and a `warn` log @@ -72,7 +136,7 @@ impl StatusFileWriter { /// Pre-existing 3.x users had the monitor writing to `~/.config/git-same/finder/` /// and the FinderSync extension reading from it via an absolute-path entitlement /// exception. After Phase B.5, the monitor writes to the group container -/// directly; this helper makes any tool that hardcoded the legacy path +/// directly; this helper makes any tool that hardcoded the legacy socket path /// continue to work via symlink redirection. #[cfg(target_os = "macos")] pub fn ensure_legacy_symlinks(group_dir: &Path) -> Result<(), AppError> { @@ -80,19 +144,23 @@ pub fn ensure_legacy_symlinks(group_dir: &Path) -> Result<(), AppError> { Ok(cfg) => cfg.dir, Err(_) => return Ok(()), }; + ensure_legacy_symlinks_in(&legacy_dir, group_dir) +} +/// Core of [`ensure_legacy_symlinks`] with the legacy dir passed in, so tests +/// can exercise it against a controlled directory. +#[cfg(target_os = "macos")] +fn ensure_legacy_symlinks_in(legacy_dir: &Path, group_dir: &Path) -> Result<(), AppError> { if !legacy_dir.exists() { // Fresh install (no XDG config dir at all yet); nothing to migrate. return Ok(()); } - for filename in &["status.json", "finder.sock"] { - let legacy_path = legacy_dir.join(filename); - let target_path = group_dir.join(filename); - ensure_one_symlink(&legacy_path, &target_path)?; - } - - Ok(()) + // Only the socket is symlinked; status.json is a real mirror file written + // by the monitor (see the doc comment on `ensure_legacy_symlinks`). + let legacy_sock = legacy_dir.join("finder.sock"); + let target_sock = group_dir.join("finder.sock"); + ensure_one_symlink(&legacy_sock, &target_sock) } /// Non-macOS no-op so the monitor can call this unconditionally without `cfg` diff --git a/crates/git-same-core/src/ipc/status_file_tests.rs b/crates/git-same-core/src/ipc/status_file_tests.rs index 98831d5..baaab8b 100644 --- a/crates/git-same-core/src/ipc/status_file_tests.rs +++ b/crates/git-same-core/src/ipc/status_file_tests.rs @@ -104,6 +104,65 @@ fn test_no_temp_file_remains_after_write() { assert!(!temp_path.exists()); } +#[test] +fn test_write_produces_primary_and_every_mirror() { + let temp = tempfile::tempdir().unwrap(); + let primary = temp.path().join("container/status.json"); + let mirror = temp.path().join("host/status.json"); + let writer = StatusFileWriter::new_with_mirrors(primary.clone(), vec![mirror.clone()]); + + let status = sample_status(); + writer.write(&status).unwrap(); + + // Both files exist as real files with identical content. + assert!(primary.exists()); + assert!(mirror.exists()); + assert_eq!( + std::fs::read_to_string(&primary).unwrap(), + std::fs::read_to_string(&mirror).unwrap() + ); + + // The writer reads back from the primary. + assert_eq!(writer.read().unwrap(), status); + + // A reader pointed at the mirror sees the same status. + let mirror_reader = StatusFileWriter::new(mirror); + assert_eq!(mirror_reader.read().unwrap(), status); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_mirror_write_replaces_existing_symlink_with_real_file() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let container = temp.path().join("container"); + let host = temp.path().join("host"); + std::fs::create_dir_all(&container).unwrap(); + std::fs::create_dir_all(&host).unwrap(); + + let primary = container.join("status.json"); + let mirror = host.join("status.json"); + + // Simulate the pre-upgrade layout: the host mirror path is a symlink into + // the container. + symlink(&primary, &mirror).unwrap(); + assert!(std::fs::symlink_metadata(&mirror) + .unwrap() + .file_type() + .is_symlink()); + + let writer = StatusFileWriter::new_with_mirrors(primary, vec![mirror.clone()]); + writer.write(&sample_status()).unwrap(); + + // The first mirror write replaces the symlink with a real file. + let meta = std::fs::symlink_metadata(&mirror).unwrap(); + assert!( + meta.file_type().is_file(), + "mirror must be a real file, not a symlink, after write" + ); +} + #[cfg(target_os = "macos")] mod symlink_helper { use super::*; @@ -188,6 +247,26 @@ mod symlink_helper { assert_eq!(aside_count, 1, "expected one aside file"); } + #[test] + fn ensure_legacy_symlinks_symlinks_only_the_socket() { + let (_root, legacy, group) = dirs(); + + ensure_legacy_symlinks_in(&legacy, &group).unwrap(); + + // finder.sock is symlinked into the group container. + let sock = legacy.join("finder.sock"); + let sock_meta = fs::symlink_metadata(&sock).unwrap(); + assert!(sock_meta.file_type().is_symlink()); + assert_eq!(fs::read_link(&sock).unwrap(), group.join("finder.sock")); + + // status.json is deliberately NOT symlinked; the monitor mirrors a real + // file there instead. + assert!( + fs::symlink_metadata(legacy.join("status.json")).is_err(), + "status.json must not be symlinked" + ); + } + #[test] fn ensure_legacy_symlinks_is_noop_when_legacy_dir_missing() { // Use a non-existent legacy dir override path: we can't easily inject diff --git a/crates/git-same-core/src/monitor/run.rs b/crates/git-same-core/src/monitor/run.rs index 7f34843..a65375f 100644 --- a/crates/git-same-core/src/monitor/run.rs +++ b/crates/git-same-core/src/monitor/run.rs @@ -138,7 +138,11 @@ where output.info("Starting git-same monitor..."); let live = LiveConfig::new(config.clone(), context.config_path.clone()); - let status_writer = StatusFileWriter::new(ipc_config.status_file_path()); + let primary_status_path = ipc_config.status_file_path(); + let status_writer = StatusFileWriter::new_with_mirrors( + primary_status_path.clone(), + status_mirror_paths(&primary_status_path), + ); let git = ShellGit::new(); let owner_types = OwnerTypeCache::load(OwnerTypeCache::default_path(&ipc_config.dir)); @@ -272,7 +276,7 @@ where live: live.clone(), reload_tx: reload_tx.clone(), pid, - status_path: status_writer.path().to_path_buf(), + status_writer: status_writer.clone(), shared_status: shared_status.clone(), owner_types: owner_types.clone(), ambient_upgrades: ambient_upgrades.clone(), @@ -317,6 +321,30 @@ where Ok(()) } +/// Mirror paths for the status writer. On macOS the primary `status.json` +/// lives in the app-group container; mirror a real copy into the host-facing +/// `~/.config/git-same/finder/` so the non-sandboxed Tauri host can read live +/// status without reaching into the container (which would trigger the "access +/// data from other apps" TCC prompt). On other platforms the primary path is +/// already the host path, so there are no mirrors. +fn status_mirror_paths(primary: &Path) -> Vec { + #[cfg(target_os = "macos")] + { + if let Ok(host) = IpcConfig::host_status_path() { + let mirror = host.status_file_path(); + if mirror.as_path() != primary { + return vec![mirror]; + } + } + Vec::new() + } + #[cfg(not(target_os = "macos"))] + { + let _ = primary; + Vec::new() + } +} + /// Everything a socket task needs, cloned out of the loop. // Only the `#[cfg(unix)]` `serve_connection` reads these fields; off Unix the // struct is still built but never consumed, so every field reads as dead. @@ -325,7 +353,7 @@ struct ConnectionState { live: LiveConfig, reload_tx: tokio::sync::mpsc::UnboundedSender<()>, pid: u32, - status_path: PathBuf, + status_writer: StatusFileWriter, shared_status: Arc>, owner_types: OwnerTypeCache, ambient_upgrades: AmbientUpgradeCache, @@ -367,7 +395,7 @@ fn serve_connection(connection: Connection, state: ConnectionState) { &state.live, &state.reload_tx, state.pid, - &state.status_path, + state.status_writer, state.shared_status, Some(state.owner_types), Some(state.ambient_upgrades), diff --git a/crates/git-same-core/src/monitor/socket_handler.rs b/crates/git-same-core/src/monitor/socket_handler.rs index 4a6209a..dd9c45b 100644 --- a/crates/git-same-core/src/monitor/socket_handler.rs +++ b/crates/git-same-core/src/monitor/socket_handler.rs @@ -10,7 +10,6 @@ use crate::ipc::StatusFileWriter; use crate::monitor::incremental::rescan_and_merge; use crate::monitor::live_config::LiveConfig; use crate::types::FinderStatus; -use std::path::Path; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -28,7 +27,7 @@ pub async fn handle_socket_connection( live: &LiveConfig, reload_tx: &tokio::sync::mpsc::UnboundedSender<()>, pid: u32, - status_path: &Path, + status_writer: StatusFileWriter, shared_status: Arc>, owner_types: Option, ambient_upgrades: Option, @@ -68,8 +67,7 @@ pub async fn handle_socket_connection( let mut status = shared_status.lock().expect("status mutex poisoned"); let changed = rescan_and_merge(&service, &mut status, &canonical); if changed { - let file_writer = StatusFileWriter::new(status_path.to_path_buf()); - if let Err(e) = file_writer.write(&status) { + if let Err(e) = status_writer.write(&status) { error!(error = %e, "Failed to write status file after Refresh"); } } @@ -85,8 +83,7 @@ pub async fn handle_socket_connection( Ok(new_status) => { let mut status = shared_status.lock().expect("status mutex poisoned"); *status = new_status; - let file_writer = StatusFileWriter::new(status_path.to_path_buf()); - if let Err(e) = file_writer.write(&status) { + if let Err(e) = status_writer.write(&status) { error!(error = %e, "Failed to write status file after RefreshAll"); } "OK\n".to_string() @@ -96,7 +93,7 @@ pub async fn handle_socket_connection( "ERROR\n".to_string() } }, - DaemonCommand::Status => status_response(status_path), + DaemonCommand::Status => status_response(&status_writer), DaemonCommand::Unknown(cmd) => { format!("UNKNOWN: {}\n", cmd) } @@ -110,9 +107,8 @@ pub async fn handle_socket_connection( /// pretty JSON terminated by a newline so it matches the line-framed protocol /// (`PONG\n`, `OK\n`, `ERROR\n`). Returns `ERROR\n` if the file can't be read /// or serialized. -fn status_response(status_path: &Path) -> String { - let file_writer = StatusFileWriter::new(status_path.to_path_buf()); - match file_writer.read() { +fn status_response(writer: &StatusFileWriter) -> String { + match writer.read() { Ok(status) => serde_json::to_string_pretty(&status) .map(|s| format!("{s}\n")) .unwrap_or_else(|_| "ERROR\n".to_string()), diff --git a/crates/git-same-core/src/monitor/socket_handler_tests.rs b/crates/git-same-core/src/monitor/socket_handler_tests.rs index e8d74e8..aaec994 100644 --- a/crates/git-same-core/src/monitor/socket_handler_tests.rs +++ b/crates/git-same-core/src/monitor/socket_handler_tests.rs @@ -10,7 +10,7 @@ fn status_response_ends_with_newline() { .write(&FinderStatus::new(0, "2026-06-21T00:00:00Z".to_string())) .unwrap(); - let resp = status_response(&path); + let resp = status_response(&writer); assert!( resp.ends_with('\n'), "Status response must end with newline" @@ -21,6 +21,7 @@ fn status_response_ends_with_newline() { #[test] fn status_response_error_when_missing() { let dir = TempDir::new().unwrap(); - let resp = status_response(&dir.path().join("does-not-exist.json")); + let writer = StatusFileWriter::new(dir.path().join("does-not-exist.json")); + let resp = status_response(&writer); assert_eq!(resp, "ERROR\n"); } From 54770e72bf70c178264586d9cb273a81670d45df Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 1 Jul 2026 10:56:36 +0200 Subject: [PATCH 02/21] Cap time at <0.3.52 so tauri's cookie 0.18.1 keeps compiling time 0.3.52 changed its sealed Parsable::parse trait method from one argument to two (added defaults: Option). cookie 0.18.1, pulled in transitively via tauri, calls the one-argument form and fails to compile against time >= 0.3.52. No fixed cookie release exists (0.18.1 is the latest and tauri pins cookie 0.18), so cap time below 0.3.52 in the git-same-app manifest and re-pin the lockfile to the latest compatible time 0.3.51. Remove the cap once cookie ships a fix. --- Cargo.lock | 9 +++++---- crates/git-same-app/Cargo.toml | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b51c86..9592a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1627,6 +1627,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tempfile", + "time", "tokio", "toml 1.1.6+spec-1.1.0", ] @@ -5192,9 +5193,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.55" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", "libc", @@ -5214,9 +5215,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.32" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", diff --git a/crates/git-same-app/Cargo.toml b/crates/git-same-app/Cargo.toml index 820ffc3..af8aab0 100644 --- a/crates/git-same-app/Cargo.toml +++ b/crates/git-same-app/Cargo.toml @@ -25,6 +25,10 @@ serde = { workspace = true } serde_json = { workspace = true } shellexpand = { workspace = true } tauri = { version = "2", features = [] } +# Pin: tauri's transitive `cookie` 0.18.1 calls time's Parsable::parse with the +# pre-0.3.52 one-arg signature; time 0.3.52 made it two-arg and fails to compile. +# No fixed cookie release exists yet. Remove this cap once cookie ships a fix. +time = ">=0.3, <0.3.52" tauri-plugin-dialog = "2" tokio = { workspace = true } toml = { workspace = true } From 2219887b3ad3027a217da9a06c3d652e41741ba6 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 2 Jul 2026 00:52:41 +0200 Subject: [PATCH 03/21] Bump version to 3.2.0 across workspace, app, and badges for release --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/git-same-app/tauri.conf.json | 2 +- crates/git-same-app/ui/package.json | 2 +- crates/git-same-cli/Cargo.toml | 2 +- macos/GitSameBadges/Info.plist | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9592a0c..dc2968c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1589,7 +1589,7 @@ dependencies = [ [[package]] name = "git-same" -version = "3.1.2" +version = "3.2.0" dependencies = [ "anyhow", "chrono", @@ -1614,7 +1614,7 @@ dependencies = [ [[package]] name = "git-same-app" -version = "3.1.2" +version = "3.2.0" dependencies = [ "anyhow", "chrono", @@ -1634,7 +1634,7 @@ dependencies = [ [[package]] name = "git-same-core" -version = "3.1.2" +version = "3.2.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 77eebfb..c44a4be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.1.2" +version = "3.2.0" edition = "2021" authors = ["Manuel Gruber"] license = "MIT" diff --git a/crates/git-same-app/tauri.conf.json b/crates/git-same-app/tauri.conf.json index b6b479b..b8d752c 100644 --- a/crates/git-same-app/tauri.conf.json +++ b/crates/git-same-app/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Git-Same", - "version": "3.1.2", + "version": "3.2.0", "identifier": "com.zaai.git-same", "build": { "beforeDevCommand": "corepack pnpm dev", diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json index 93a6577..0d4f39d 100644 --- a/crates/git-same-app/ui/package.json +++ b/crates/git-same-app/ui/package.json @@ -1,7 +1,7 @@ { "name": "git-same-app-ui", "private": true, - "version": "3.1.2", + "version": "3.2.0", "type": "module", "packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38", "scripts": { diff --git a/crates/git-same-cli/Cargo.toml b/crates/git-same-cli/Cargo.toml index 5b90844..eeaacc2 100644 --- a/crates/git-same-cli/Cargo.toml +++ b/crates/git-same-cli/Cargo.toml @@ -40,7 +40,7 @@ tui = ["dep:ratatui", "dep:crossterm"] release-tools = ["dep:clap_complete", "dep:clap_mangen"] [dependencies] -git-same-core = { path = "../git-same-core", version = "=3.1.2" } +git-same-core = { path = "../git-same-core", version = "=3.2.0" } clap = { workspace = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/macos/GitSameBadges/Info.plist b/macos/GitSameBadges/Info.plist index 4674a56..a55e641 100644 --- a/macos/GitSameBadges/Info.plist +++ b/macos/GitSameBadges/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 3.1.2 + 3.2.0 CFBundleVersion - 3.1.2 + 3.2.0 NSExtension NSExtensionPointIdentifier From cfd9f40ed85885cf1d9013a7d2d843be4a717af9 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 2 Jul 2026 01:06:09 +0200 Subject: [PATCH 04/21] Bump Cargo and pnpm dependencies to latest in-range versions Updates 6 crates.io packages (clap_complete, console, indicatif, inotify-sys, libredox, tauri) and 3 npm packages (@lucide/svelte, @tauri-apps/cli, vite) to their latest semver-compatible releases. The time <0.3.52 cap in git-same-app/Cargo.toml stays in place since tauri's transitive cookie 0.18.1 still hasn't shipped a fix. --- crates/git-same-app/ui/package.json | 11 +- crates/git-same-app/ui/pnpm-lock.yaml | 455 ++++++++++++-------------- 2 files changed, 211 insertions(+), 255 deletions(-) diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json index 0d4f39d..15a69ea 100644 --- a/crates/git-same-app/ui/package.json +++ b/crates/git-same-app/ui/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "dependencies": { - "@lucide/svelte": "^1.21.0", + "@lucide/svelte": "^1.22.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.7.1", "svelte": "^5.56.4", @@ -19,10 +19,15 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@tauri-apps/cli": "^2.11.3", + "@tauri-apps/cli": "^2.11.4", "svelte-check": "^4.7.1", "typescript": "^6.0.3", - "vite": "^8.1.0", + "vite": "^8.1.2", "vitest": "^3.2.4" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] } } diff --git a/crates/git-same-app/ui/pnpm-lock.yaml b/crates/git-same-app/ui/pnpm-lock.yaml index f0a25b2..ef6543a 100644 --- a/crates/git-same-app/ui/pnpm-lock.yaml +++ b/crates/git-same-app/ui/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@lucide/svelte': - specifier: ^1.21.0 - version: 1.21.0(svelte@5.56.4) + specifier: ^1.22.0 + version: 1.47.0(svelte@5.56.4) '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 @@ -26,10 +26,10 @@ importers: devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^7.1.2 - version: 7.1.2(svelte@5.56.4)(vite@8.1.0(esbuild@0.28.2)) + version: 7.1.2(svelte@5.56.4)(vite@8.3.0(esbuild@0.28.2)) '@tauri-apps/cli': - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.4 + version: 2.11.5 svelte-check: specifier: ^4.7.1 version: 4.7.1(picomatch@4.0.4)(svelte@5.56.4)(typescript@6.0.3) @@ -37,23 +37,14 @@ importers: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.1.0 - version: 8.1.0(esbuild@0.28.2) + specifier: ^8.1.2 + version: 8.3.0(esbuild@0.28.2) vitest: specifier: ^3.2.4 - version: 3.2.7(lightningcss@1.32.0) + version: 3.2.7(lightningcss@1.33.0) packages: - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -226,8 +217,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lucide/svelte@1.21.0': - resolution: {integrity: sha512-MEv//A7Jv3kHukZowv/DWp1MAtUzJKYwtJsmnQ7X98lCgtac3z3NbaToDl3Q6jO3gS9sougFpcD+t+YuxOkRMw==} + '@lucide/svelte@1.47.0': + resolution: {integrity: sha512-tpuE7JIK9FzYDWsJx6PxHj9ROyCpnDxZfvHu5q5yHGKS2CFNjzSyf1o9/Ypu2A27GLvkPGqZcIMrF6idM+eQZQ==} peerDependencies: svelte: ^5 @@ -238,106 +229,101 @@ packages: os: [linux] libc: [glibc] - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} - '@oxc-project/types@0.137.0': - resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] - '@rolldown/binding-android-arm64@1.1.3': - resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.3': - resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.3': - resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.3': - resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': - resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.3': - resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.3': - resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.3': - resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.3': - resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.3': - resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.3': - resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.3': - resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.3': - resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.3': - resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.3': - resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -502,88 +488,85 @@ packages: '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - '@tauri-apps/cli-darwin-arm64@2.11.3': - resolution: {integrity: sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==} + '@tauri-apps/cli-darwin-arm64@2.11.5': + resolution: {integrity: sha512-tggOiVOohjIHdiElbjeBfB41s6cmfsq+ZQ0PX0fEtCdsDdZ9cCi54e5gNK7tdPbR6JbdDe7RA4kbQdyyBUUk/Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.11.3': - resolution: {integrity: sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==} + '@tauri-apps/cli-darwin-x64@2.11.5': + resolution: {integrity: sha512-SrO+KCbqvG1IvVPdlzQX4GEGZRQDISrmDY9bGcW68DLVFBCC06H+Loz6oyUZETUxsSMR6B4oDfvbQVgD4hxsqQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': - resolution: {integrity: sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.5': + resolution: {integrity: sha512-qLrOaCa8hLD5QVPQRjAgOXvhlTFKXmTliVF773GgvADYcWZSdmncnF5zBFAkdu1uQK8KfQbDlBUwqQYZqBfBRQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.11.3': - resolution: {integrity: sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==} + '@tauri-apps/cli-linux-arm64-gnu@2.11.5': + resolution: {integrity: sha512-Tq32xpQjiQEdIrvwDq7lwHhXemn070CXWdbCvr9rdi9rsv9NFTbQ6izVSIfNE+59QaerxuwtUtZ5IxDNWaoYBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.11.3': - resolution: {integrity: sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==} + '@tauri-apps/cli-linux-arm64-musl@2.11.5': + resolution: {integrity: sha512-+OdWKj5Aq+C8HXypQ4t926V8cpVnc0ZcJIdhTXWjVAJHkaNuo8EuQR6KRZKHWIbJJgKdMPCDosvwzIFaBBqHKQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': - resolution: {integrity: sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==} + '@tauri-apps/cli-linux-riscv64-gnu@2.11.5': + resolution: {integrity: sha512-Q0V2hjpllyDW8dWQZSsITuQDfcY9BpBZuhXkMa2T5AFLZAn+FJPXQSyvnxWNVxRowdQ59Xn0QVtGm6HEHNUEvw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.11.3': - resolution: {integrity: sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==} + '@tauri-apps/cli-linux-x64-gnu@2.11.5': + resolution: {integrity: sha512-Xz1s87gFjZJZMQKAhLa77YeiCJM6yVog3f9OxDgVd/KxDIoB1dA5V2xOnMM+SRkOGk9juwIBqesVAjV0kUB0DA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.11.3': - resolution: {integrity: sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==} + '@tauri-apps/cli-linux-x64-musl@2.11.5': + resolution: {integrity: sha512-6A88wqAZXZHgMaBrGiy/Bpa4GgUnftXM7uquKOeOXCuK78/XdBxEECicK/1oHgrT179XBFOaA6lQEHysjRmGig==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.11.3': - resolution: {integrity: sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==} + '@tauri-apps/cli-win32-arm64-msvc@2.11.5': + resolution: {integrity: sha512-sRGgF/ObRfLcS3PtUdyTbJtkGq3E+PNZTknvAURwMNUtHw9DU/BF6S5si43y9lS5yEO9bWJCA+GGYvnn6cphQw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.11.3': - resolution: {integrity: sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==} + '@tauri-apps/cli-win32-ia32-msvc@2.11.5': + resolution: {integrity: sha512-bHOv/yxqfKpI7ioIFQi5wOfZ7jkbPzJD/UhXY52I/hrmE3qNh71cohIPyqZB5GQn3LZeJWdCUOqmIg/w6XX+Wg==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.11.3': - resolution: {integrity: sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==} + '@tauri-apps/cli-win32-x64-msvc@2.11.5': + resolution: {integrity: sha512-vaaOmavqdCeS7oMib2banBZemgrC5v54tSkwoBV+bxrXQZhQZrOwE2EO2i4x+CHNtvQUM/6bSs3iymZMAsycWg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.11.3': - resolution: {integrity: sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==} + '@tauri-apps/cli@2.11.5': + resolution: {integrity: sha512-YbeJ6tctNoo40purO4u3Eeo3RVqmsjJr0NoKQRu+4pvO4gMLzkjta7x0k96SxSLmx+rQ+8um1ThOlw+HLpgFug==} engines: {node: '>= 10'} hasBin: true '@tauri-apps/plugin-dialog@2.7.1': resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -732,78 +715,78 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} locate-character@3.0.0: @@ -845,6 +828,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -857,8 +844,8 @@ packages: resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==} engines: {node: '>=8'} - rolldown@1.1.3: - resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -926,9 +913,6 @@ packages: resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} engines: {node: '>=14.0.0'} - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -979,13 +963,13 @@ packages: yaml: optional: true - vite@8.1.0: - resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.7.1 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1068,22 +1052,6 @@ packages: snapshots: - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true @@ -1181,69 +1149,58 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lucide/svelte@1.21.0(svelte@5.56.4)': + '@lucide/svelte@1.47.0(svelte@5.56.4)': dependencies: svelte: 5.56.4 '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@oxc-project/types@0.137.0': {} + '@oxc-project/types@0.150.0': {} - '@rolldown/binding-android-arm64@1.1.3': + '@rolldown/binding-android-arm-eabi@1.2.9': optional: true - '@rolldown/binding-darwin-arm64@1.1.3': + '@rolldown/binding-android-arm64@1.2.9': optional: true - '@rolldown/binding-darwin-x64@1.1.3': + '@rolldown/binding-darwin-arm64@1.2.9': optional: true - '@rolldown/binding-freebsd-x64@1.1.3': + '@rolldown/binding-darwin-x64@1.2.9': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + '@rolldown/binding-freebsd-x64@1.2.9': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.3': + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.3': + '@rolldown/binding-linux-arm64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.3': + '@rolldown/binding-linux-arm64-musl@1.2.9': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.3': + '@rolldown/binding-linux-ppc64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.3': + '@rolldown/binding-linux-s390x-gnu@1.2.9': optional: true - '@rolldown/binding-linux-x64-musl@1.1.3': + '@rolldown/binding-linux-x64-gnu@1.2.9': optional: true - '@rolldown/binding-openharmony-arm64@1.1.3': + '@rolldown/binding-linux-x64-musl@1.2.9': optional: true - '@rolldown/binding-wasm32-wasi@1.1.3': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-openharmony-arm64@1.2.9': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.3': + '@rolldown/binding-win32-arm64-msvc@1.2.9': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.3': + '@rolldown/binding-win32-x64-msvc@1.2.9': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1329,73 +1286,68 @@ snapshots: '@sveltejs/load-config@0.2.0': {} - '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.1.0(esbuild@0.28.2))': + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.3.0(esbuild@0.28.2))': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.3 svelte: 5.56.4 - vite: 8.1.0(esbuild@0.28.2) - vitefu: 1.1.3(vite@8.1.0(esbuild@0.28.2)) + vite: 8.3.0(esbuild@0.28.2) + vitefu: 1.1.3(vite@8.3.0(esbuild@0.28.2)) '@tauri-apps/api@2.11.1': {} - '@tauri-apps/cli-darwin-arm64@2.11.3': + '@tauri-apps/cli-darwin-arm64@2.11.5': optional: true - '@tauri-apps/cli-darwin-x64@2.11.3': + '@tauri-apps/cli-darwin-x64@2.11.5': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.5': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.11.3': + '@tauri-apps/cli-linux-arm64-gnu@2.11.5': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.11.3': + '@tauri-apps/cli-linux-arm64-musl@2.11.5': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.5': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.11.3': + '@tauri-apps/cli-linux-x64-gnu@2.11.5': optional: true - '@tauri-apps/cli-linux-x64-musl@2.11.3': + '@tauri-apps/cli-linux-x64-musl@2.11.5': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.11.3': + '@tauri-apps/cli-win32-arm64-msvc@2.11.5': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.11.3': + '@tauri-apps/cli-win32-ia32-msvc@2.11.5': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.11.3': + '@tauri-apps/cli-win32-x64-msvc@2.11.5': optional: true - '@tauri-apps/cli@2.11.3': + '@tauri-apps/cli@2.11.5': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.11.3 - '@tauri-apps/cli-darwin-x64': 2.11.3 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.3 - '@tauri-apps/cli-linux-arm64-gnu': 2.11.3 - '@tauri-apps/cli-linux-arm64-musl': 2.11.3 - '@tauri-apps/cli-linux-riscv64-gnu': 2.11.3 - '@tauri-apps/cli-linux-x64-gnu': 2.11.3 - '@tauri-apps/cli-linux-x64-musl': 2.11.3 - '@tauri-apps/cli-win32-arm64-msvc': 2.11.3 - '@tauri-apps/cli-win32-ia32-msvc': 2.11.3 - '@tauri-apps/cli-win32-x64-msvc': 2.11.3 + '@tauri-apps/cli-darwin-arm64': 2.11.5 + '@tauri-apps/cli-darwin-x64': 2.11.5 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.5 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.5 + '@tauri-apps/cli-linux-arm64-musl': 2.11.5 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.5 + '@tauri-apps/cli-linux-x64-gnu': 2.11.5 + '@tauri-apps/cli-linux-x64-musl': 2.11.5 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.5 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.5 + '@tauri-apps/cli-win32-x64-msvc': 2.11.5 '@tauri-apps/plugin-dialog@2.7.1': dependencies: '@tauri-apps/api': 2.11.1 - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1415,13 +1367,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6(lightningcss@1.32.0))': + '@vitest/mocker@3.2.7(vite@7.3.6(lightningcss@1.33.0))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(lightningcss@1.32.0) + vite: 7.3.6(lightningcss@1.33.0) '@vitest/pretty-format@3.2.7': dependencies: @@ -1543,54 +1495,54 @@ snapshots: js-tokens@9.0.1: {} - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 locate-character@3.0.0: {} @@ -1616,6 +1568,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -1626,26 +1580,26 @@ snapshots: regexparam@2.0.2: {} - rolldown@1.1.3: + rolldown@1.2.9: dependencies: - '@oxc-project/types': 0.137.0 + '@oxc-project/types': 0.150.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.3 - '@rolldown/binding-darwin-arm64': 1.1.3 - '@rolldown/binding-darwin-x64': 1.1.3 - '@rolldown/binding-freebsd-x64': 1.1.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 - '@rolldown/binding-linux-arm64-gnu': 1.1.3 - '@rolldown/binding-linux-arm64-musl': 1.1.3 - '@rolldown/binding-linux-ppc64-gnu': 1.1.3 - '@rolldown/binding-linux-s390x-gnu': 1.1.3 - '@rolldown/binding-linux-x64-gnu': 1.1.3 - '@rolldown/binding-linux-x64-musl': 1.1.3 - '@rolldown/binding-openharmony-arm64': 1.1.3 - '@rolldown/binding-wasm32-wasi': 1.1.3 - '@rolldown/binding-win32-arm64-msvc': 1.1.3 - '@rolldown/binding-win32-x64-msvc': 1.1.3 + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 rollup@4.63.4: dependencies: @@ -1749,18 +1703,15 @@ snapshots: tinyspy@4.0.6: {} - tslib@2.8.1: - optional: true - typescript@6.0.3: {} - vite-node@3.2.4(lightningcss@1.32.0): + vite-node@3.2.4(lightningcss@1.33.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(lightningcss@1.32.0) + vite: 7.3.6(lightningcss@1.33.0) transitivePeerDependencies: - '@types/node' - jiti @@ -1775,7 +1726,7 @@ snapshots: - tsx - yaml - vite@7.3.6(lightningcss@1.32.0): + vite@7.3.6(lightningcss@1.33.0): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.4) @@ -1785,28 +1736,28 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 - lightningcss: 1.32.0 + lightningcss: 1.33.0 - vite@8.1.0(esbuild@0.28.2): + vite@8.3.0(esbuild@0.28.2): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 + lightningcss: 1.33.0 + picomatch: 4.0.7 postcss: 8.5.28 - rolldown: 1.1.3 + rolldown: 1.2.9 tinyglobby: 0.2.17 optionalDependencies: esbuild: 0.28.2 fsevents: 2.3.3 - vitefu@1.1.3(vite@8.1.0(esbuild@0.28.2)): + vitefu@1.1.3(vite@8.3.0(esbuild@0.28.2)): optionalDependencies: - vite: 8.1.0(esbuild@0.28.2) + vite: 8.3.0(esbuild@0.28.2) - vitest@3.2.7(lightningcss@1.32.0): + vitest@3.2.7(lightningcss@1.33.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(lightningcss@1.32.0)) + '@vitest/mocker': 3.2.7(vite@7.3.6(lightningcss@1.33.0)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -1824,8 +1775,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(lightningcss@1.32.0) - vite-node: 3.2.4(lightningcss@1.32.0) + vite: 7.3.6(lightningcss@1.33.0) + vite-node: 3.2.4(lightningcss@1.33.0) why-is-node-running: 2.3.0 transitivePeerDependencies: - jiti From 939beffeea80ab79f0df15d14174337ab5282225 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 2 Jul 2026 22:25:03 +0200 Subject: [PATCH 05/21] Fix legacy status symlink errors to prevent container reads --- crates/git-same-app/src/commands.rs | 20 +++++++++++--- crates/git-same-app/src/commands_tests.rs | 33 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index ff04d0e..8bf1fa2 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -1235,7 +1235,7 @@ fn requirement_check_dto(check: CheckResult) -> RequirementCheckDto { pub(crate) fn read_status_snapshot_with(ipc: &IpcConfig) -> Result { ipc.ensure_dir()?; let status_path = ipc.status_file_path(); - remove_legacy_status_symlink(&status_path); + remove_legacy_status_symlink(&status_path)?; let writer = StatusFileWriter::new(status_path.clone()); let modified = fs::metadata(&status_path) .ok() @@ -1265,12 +1265,26 @@ pub(crate) fn read_status_snapshot_with(ipc: &IpcConfig) -> Result Result<(), AppError> { + remove_legacy_status_symlink_with(status_path, |path| fs::remove_file(path)) +} + +fn remove_legacy_status_symlink_with( + status_path: &Path, + remove_file: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result<(), AppError> { if let Ok(meta) = fs::symlink_metadata(status_path) { if meta.file_type().is_symlink() { - let _ = fs::remove_file(status_path); + remove_file(status_path).map_err(|error| { + AppError::path(format!( + "Failed to remove legacy status symlink '{}': {}", + status_path.display(), + error + )) + })?; } } + Ok(()) } fn workspace_summary( diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index 1f62c69..8239ea1 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -250,6 +250,39 @@ fn read_status_snapshot_removes_a_status_symlink_and_reports_absent() { ); } +#[cfg(unix)] +#[test] +fn remove_legacy_status_symlink_returns_remove_errors() { + use std::io; + use std::os::unix::fs::symlink; + + let temp = TestDir::new("status-symlink-remove-error"); + let status_path = temp.path().join("status.json"); + let external_target = temp.path().join("container-status.json"); + symlink(&external_target, &status_path).unwrap(); + + let error = remove_legacy_status_symlink_with(&status_path, |_| { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "synthetic unlink failure", + )) + }) + .unwrap_err(); + + match error { + AppError::Path(message) => { + assert!(message.contains("Failed to remove legacy status symlink")); + assert!(message.contains(&status_path.display().to_string())); + assert!(message.contains("synthetic unlink failure")); + } + other => panic!("expected path error, got {other}"), + } + assert!(std::fs::symlink_metadata(&status_path) + .unwrap() + .file_type() + .is_symlink()); +} + #[test] fn ensure_config_creates_default_config() { let temp = TestDir::new("ensure-config"); From 92f0eac455105cf3d901b8193f68a07a7d7b5da2 Mon Sep 17 00:00:00 2001 From: Manuel Date: Tue, 7 Jul 2026 08:15:22 +0200 Subject: [PATCH 06/21] Harden status mirroring and fix review findings on TCC branch Address findings from an xhigh code review of the status-mirror change: - Gate the Windows-hostile suffix assert in the IPC test so S1 CI passes. - Make status mirror writes best-effort (warn, not error) so an unwritable host dir cannot crash-loop the monitor under launchd. - Add core remove_symlink_if_present (NotFound-tolerant), reuse it from the host, and drop the duplicated app-side helper and its synthetic test. - Move the mirror policy into IpcConfig::status_writer so a custom IpcConfig can never clobber the real user's host status.json. - Drop the false state-guard clones in the Tauri handlers, single-parse the status snapshot, and filter watcher events to status.json. - Correct the ipc module docs to describe the mirror design. --- crates/git-same-app/src/commands.rs | 61 ++++++------------- crates/git-same-app/src/commands_tests.rs | 40 ++++-------- crates/git-same-core/src/ipc/mod.rs | 53 +++++++++++++--- crates/git-same-core/src/ipc/mod_tests.rs | 37 +++++++++++ crates/git-same-core/src/ipc/status_file.rs | 52 +++++++++++++++- .../src/ipc/status_file_tests.rs | 55 +++++++++++++++++ crates/git-same-core/src/monitor/run.rs | 30 +-------- 7 files changed, 218 insertions(+), 110 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 8bf1fa2..4ba57ab 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -9,7 +9,7 @@ use git_same_core::config::{ use git_same_core::discovery::DiscoveryOrchestrator; use git_same_core::domain::RepoPathTemplate; use git_same_core::errors::{AppError, MonitorAgentError}; -use git_same_core::ipc::{IpcConfig, StatusFileWriter}; +use git_same_core::ipc::{remove_symlink_if_present, IpcConfig, StatusFileWriter}; use git_same_core::macos::folder_icon; use git_same_core::macos::monitor_agent::{self, MonitorAgentState, MonitorAgentStatus}; use git_same_core::progress::{ProgressEvent, ProgressReporter}; @@ -405,15 +405,12 @@ pub fn set_default_workspace( pub async fn check_requirements( ipc: tauri::State<'_, HostIpc>, ) -> Result, String> { - // Clone the resolved host IPC config out of the state guard before any - // `.await` so no borrow of the guard is held across an await point. - let host_ipc = ipc.inner().0.clone(); let mut checks: Vec = git_same_core::checks::check_requirements() .await .into_iter() .map(requirement_check_dto) .collect(); - checks.extend(app_requirement_checks(&host_ipc)); + checks.extend(app_requirement_checks(&ipc.0)); Ok(checks) } @@ -625,9 +622,6 @@ pub async fn start_sync( workspace_id: String, ipc: tauri::State<'_, HostIpc>, ) -> Result { - // Clone the resolved host IPC config out of the state guard before any - // `.await` so no borrow of the guard is held across an await point. - let host_ipc = ipc.inner().0.clone(); let config = Config::load().map_err(error_string)?; let mut workspace = WorkspaceManager::resolve(Some(&workspace_id), &config).map_err(error_string)?; @@ -669,7 +663,7 @@ pub async fn start_sync( workspace.last_synced = Some(chrono::Utc::now().to_rfc3339()); WorkspaceManager::save(&workspace).map_err(error_string)?; - read_status_snapshot_with(&host_ipc).map_err(error_string) + read_status_snapshot_with(&ipc.0).map_err(error_string) } fn sync_progress_reporter(app: tauri::AppHandle, workspace_id: String) -> ProgressReporter { @@ -1235,12 +1229,18 @@ fn requirement_check_dto(check: CheckResult) -> RequirementCheckDto { pub(crate) fn read_status_snapshot_with(ipc: &IpcConfig) -> Result { ipc.ensure_dir()?; let status_path = ipc.status_file_path(); - remove_legacy_status_symlink(&status_path)?; - let writer = StatusFileWriter::new(status_path.clone()); + // Older layouts symlinked status.json into the app-group container; + // following that link would re-trigger the "access data from other apps" + // TCC prompt, so unlink it before anything dereferences the path. The + // monitor's next mirror write recreates a real file here. + remove_symlink_if_present(&status_path)?; + // Single parse: None covers both a missing and a corrupt status file. + let status = StatusFileWriter::new(status_path.clone()).read().ok(); let modified = fs::metadata(&status_path) .ok() .and_then(|meta| meta.modified().ok()); - let stale = modified + let updated_at = modified.map(system_time_to_rfc3339); + let stale_by_age = modified .map(|modified| { modified .elapsed() @@ -1248,45 +1248,18 @@ pub(crate) fn read_status_snapshot_with(ipc: &IpcConfig) -> Result Duration::from_secs(DAEMON_STALE_AFTER_SECS) }) .unwrap_or(true); + // A file we cannot parse carries no usable badge data, so it is stale + // regardless of its mtime. + let stale = stale_by_age || status.is_none(); Ok(StatusSnapshot { status_path: status_path.display().to_string(), - updated_at: modified.map(system_time_to_rfc3339), + updated_at, stale, - status: writer.read().ok(), + status, }) } -/// Removes a `status.json` left behind as a symlink by an earlier layout. -/// -/// Older versions symlinked `~/.config/git-same/finder/status.json` into the -/// app-group container. Following that link (via `metadata`/`exists`, which -/// dereference symlinks) would re-trigger the "access data from other apps" TCC -/// prompt on the non-sandboxed host. `symlink_metadata` does not follow the -/// link, so detecting and unlinking it never touches the container; the -/// monitor's next mirror write recreates a real file here. -fn remove_legacy_status_symlink(status_path: &Path) -> Result<(), AppError> { - remove_legacy_status_symlink_with(status_path, |path| fs::remove_file(path)) -} - -fn remove_legacy_status_symlink_with( - status_path: &Path, - remove_file: impl FnOnce(&Path) -> std::io::Result<()>, -) -> Result<(), AppError> { - if let Ok(meta) = fs::symlink_metadata(status_path) { - if meta.file_type().is_symlink() { - remove_file(status_path).map_err(|error| { - AppError::path(format!( - "Failed to remove legacy status symlink '{}': {}", - status_path.display(), - error - )) - })?; - } - } - Ok(()) -} - fn workspace_summary( workspace: &WorkspaceConfig, default_workspace: Option<&str>, diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index 8239ea1..b58435b 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -250,37 +250,21 @@ fn read_status_snapshot_removes_a_status_symlink_and_reports_absent() { ); } -#[cfg(unix)] #[test] -fn remove_legacy_status_symlink_returns_remove_errors() { - use std::io; - use std::os::unix::fs::symlink; - - let temp = TestDir::new("status-symlink-remove-error"); - let status_path = temp.path().join("status.json"); - let external_target = temp.path().join("container-status.json"); - symlink(&external_target, &status_path).unwrap(); +fn read_status_snapshot_reports_stale_when_status_file_is_corrupt() { + let temp = TestDir::new("status-corrupt"); + let ipc = IpcConfig { + dir: temp.path().join("ipc"), + }; + ipc.ensure_dir().unwrap(); + std::fs::write(ipc.status_file_path(), "{ not json").unwrap(); - let error = remove_legacy_status_symlink_with(&status_path, |_| { - Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "synthetic unlink failure", - )) - }) - .unwrap_err(); + let snapshot = read_status_snapshot_with(&ipc).unwrap(); - match error { - AppError::Path(message) => { - assert!(message.contains("Failed to remove legacy status symlink")); - assert!(message.contains(&status_path.display().to_string())); - assert!(message.contains("synthetic unlink failure")); - } - other => panic!("expected path error, got {other}"), - } - assert!(std::fs::symlink_metadata(&status_path) - .unwrap() - .file_type() - .is_symlink()); + // A corrupt file must degrade to "no status, stale", not an error. + assert!(snapshot.status.is_none()); + assert!(snapshot.stale); + assert!(snapshot.updated_at.is_some()); } #[test] diff --git a/crates/git-same-core/src/ipc/mod.rs b/crates/git-same-core/src/ipc/mod.rs index b988dca..4b1a388 100644 --- a/crates/git-same-core/src/ipc/mod.rs +++ b/crates/git-same-core/src/ipc/mod.rs @@ -11,9 +11,16 @@ //! //! On macOS, IPC files live in the app-group container at //! `~/Library/Group Containers//` so the sandboxed Badges -//! extension and the (non-sandboxed) Tauri host can both reach them via the -//! `application-groups` entitlement, instead of via per-path absolute-path -//! exceptions that cannot be expanded for arbitrary users. +//! extension can reach them via the `application-groups` entitlement, instead +//! of via per-path absolute-path exceptions that cannot be expanded for +//! arbitrary users. +//! +//! The non-sandboxed Tauri host deliberately does NOT read from the container: +//! for a non-sandboxed process, reaching into an app container triggers the +//! "access data from other apps" TCC prompt. Instead the monitor mirrors a +//! real `status.json` into the host-facing dir from +//! [`IpcConfig::host_status_path`] (`~/.config/git-same/finder/`), and only +//! `finder.sock` is symlinked there (see `status_file::ensure_legacy_symlinks`). //! //! On non-macOS platforms (Linux, Windows), IPC files live under the user's //! XDG config dir at `~/.config/git-same/finder/`. @@ -23,7 +30,7 @@ pub mod status_file; #[cfg(unix)] pub mod unix_socket; -pub use status_file::StatusFileWriter; +pub use status_file::{remove_symlink_if_present, StatusFileWriter}; #[cfg(unix)] pub use unix_socket::{UnixSocketClient, UnixSocketListener}; @@ -66,7 +73,9 @@ impl IpcConfig { /// Returns the legacy `~/.config/git-same/finder/` path. /// /// Used as the macOS fallback and as the source side of legacy-symlink - /// migration on macOS (see `status_file::ensure_legacy_symlinks`). + /// migration on macOS (see `status_file::ensure_legacy_symlinks`). This is + /// the same directory as [`Self::host_status_path`], which is the + /// host-facing name for it; hosts reading live status should use that name. pub fn legacy_default_path() -> Result { let config_dir = crate::config::Config::default_path()?; let base_dir = config_dir @@ -82,9 +91,10 @@ impl IpcConfig { /// On macOS the monitor mirrors a real `status.json` here so the /// non-sandboxed Tauri host can read live status without reaching into the /// app-group container, which would trigger the "access data from other - /// apps" TCC prompt. This is the same directory as `legacy_default_path()`; - /// the distinct name documents *why* the host uses it (it is the host's own - /// home, not a legacy fallback). + /// apps" TCC prompt. This is the same directory as + /// [`Self::legacy_default_path`]: the distinct name documents the + /// host-facing role, while the legacy name documents its role as the + /// source side of the symlink migration. pub fn host_status_path() -> Result { Self::legacy_default_path() } @@ -94,6 +104,33 @@ impl IpcConfig { self.dir.join("status.json") } + /// Returns the status writer for this config, with the platform's mirror + /// policy applied. + /// + /// On macOS, when this config points at the app-group container (the + /// monitor's primary location), the writer also mirrors `status.json` + /// into the host-facing dir from [`Self::host_status_path`] so the + /// non-sandboxed Tauri host can read live status without crossing the + /// container boundary (which would trigger the "access data from other + /// apps" TCC prompt). Custom directories (tests, embedders) and other + /// platforms get a plain, mirror-less writer, so a caller-supplied dir + /// never leaks writes into the real user's host dir. + pub fn status_writer(&self) -> StatusFileWriter { + let primary = self.status_file_path(); + #[cfg(target_os = "macos")] + { + if Some(self.dir.as_path()) == macos_group_container_dir().as_deref() { + if let Ok(host) = Self::host_status_path() { + let mirror = host.status_file_path(); + if mirror != primary { + return StatusFileWriter::new_with_mirrors(primary, vec![mirror]); + } + } + } + } + StatusFileWriter::new(primary) + } + /// Path to the Unix socket (macOS/Linux). #[cfg(unix)] pub fn socket_path(&self) -> PathBuf { diff --git a/crates/git-same-core/src/ipc/mod_tests.rs b/crates/git-same-core/src/ipc/mod_tests.rs index ea36670..af7e011 100644 --- a/crates/git-same-core/src/ipc/mod_tests.rs +++ b/crates/git-same-core/src/ipc/mod_tests.rs @@ -109,9 +109,46 @@ fn test_host_status_path_matches_legacy_default_path() { match (host, legacy) { (Ok(host), Ok(legacy)) => { assert_eq!(host.dir, legacy.dir); + // On Windows the dir ends in `git-same\config\finder` (see the + // comment on test_legacy_default_path_ends_in_finder), so the + // suffix check is unix-only; the equality above is the real point. + #[cfg(unix)] assert!(host.dir.ends_with("git-same/finder")); } (Err(_), Err(_)) => {} _ => panic!("host_status_path and legacy_default_path disagreed on success"), } } + +#[test] +fn test_status_writer_has_no_mirrors_for_custom_dir() { + // A caller-supplied dir (tests, embedders) must never leak mirror writes + // into the real user's host dir. + let temp = tempfile::tempdir().unwrap(); + let config = IpcConfig { + dir: temp.path().join("ipc"), + }; + let writer = config.status_writer(); + assert_eq!(writer.path(), config.status_file_path().as_path()); + assert!(writer.mirror_paths().is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_status_writer_mirrors_host_status_for_group_container() { + if std::env::var_os("HOME").is_none() { + return; + } + let config = IpcConfig::default_path().expect("default_path"); + let writer = config.status_writer(); + if Some(config.dir.as_path()) == macos_group_container_dir().as_deref() { + let host = IpcConfig::host_status_path().expect("host_status_path"); + assert_eq!( + writer.mirror_paths().to_vec(), + vec![host.status_file_path()] + ); + } else { + // Legacy fallback (HOME unset is handled above; this arm is defensive). + assert!(writer.mirror_paths().is_empty()); + } +} diff --git a/crates/git-same-core/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs index 358ae0b..5127c03 100644 --- a/crates/git-same-core/src/ipc/status_file.rs +++ b/crates/git-same-core/src/ipc/status_file.rs @@ -47,13 +47,25 @@ impl StatusFileWriter { /// readers never observe a partial file and any pre-existing symlink at a /// destination is replaced by a real file (rename swaps the directory /// entry; it does not follow the link). + /// + /// Only a primary-path failure is an error. Mirrors are a convenience copy + /// for the host app, so a failing mirror (e.g. an unwritable + /// `~/.config/git-same/finder/`) is logged as a warning and skipped rather + /// than taking down the caller (the monitor would otherwise crash-loop + /// under launchd even though the container primary was written fine). pub fn write(&self, status: &FinderStatus) -> Result<(), AppError> { let json = serde_json::to_string_pretty(status) .map_err(|e| AppError::config(format!("Failed to serialize finder status: {}", e)))?; write_atomic(&self.path, &json)?; for mirror in &self.mirrors { - write_atomic(mirror, &json)?; + if let Err(e) = write_atomic(mirror, &json) { + tracing::warn!( + mirror = %mirror.display(), + error = %e, + "Failed to write status mirror; primary status file was written" + ); + } } Ok(()) @@ -77,6 +89,44 @@ impl StatusFileWriter { pub fn exists(&self) -> bool { self.path.exists() } + + /// Mirror paths this writer copies to after the primary (test support). + #[cfg(test)] + pub(crate) fn mirror_paths(&self) -> &[PathBuf] { + &self.mirrors + } +} + +/// Removes `path` if it is a symlink, leaving regular files untouched. +/// +/// Returns `Ok(true)` when a symlink was removed (or vanished concurrently +/// mid-removal) and `Ok(false)` when there was nothing to remove. +/// +/// Used by the Tauri host before reading `status.json`: older layouts +/// symlinked `~/.config/git-same/finder/status.json` into the app-group +/// container, and following that link (via `metadata`/`exists`, which +/// dereference symlinks) would re-trigger the "access data from other apps" +/// TCC prompt on the non-sandboxed host. `symlink_metadata` does not follow +/// the link, so detecting and unlinking it never touches the container; the +/// monitor's next mirror write recreates a real file at the path. +/// +/// Concurrent callers may race between the check and the unlink; `NotFound` +/// from the removal is treated as success. The narrower race where the +/// monitor renames a real file over the symlink inside that window is +/// accepted: the next monitor write (at most one scan interval) restores it. +pub fn remove_symlink_if_present(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => match std::fs::remove_file(path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(e) => Err(AppError::path(format!( + "Failed to remove symlink '{}': {}", + path.display(), + e + ))), + }, + _ => Ok(false), + } } /// Writes `json` to `path` atomically: write to a sibling `.json.tmp` diff --git a/crates/git-same-core/src/ipc/status_file_tests.rs b/crates/git-same-core/src/ipc/status_file_tests.rs index baaab8b..88e3a22 100644 --- a/crates/git-same-core/src/ipc/status_file_tests.rs +++ b/crates/git-same-core/src/ipc/status_file_tests.rs @@ -130,6 +130,61 @@ fn test_write_produces_primary_and_every_mirror() { assert_eq!(mirror_reader.read().unwrap(), status); } +#[test] +fn test_mirror_write_failure_does_not_fail_primary_write() { + let temp = tempfile::tempdir().unwrap(); + let primary = temp.path().join("container/status.json"); + // A regular file where the mirror's parent dir should be makes + // create_dir_all fail deterministically on every platform. + let blocker = temp.path().join("blocker"); + std::fs::write(&blocker, "not a directory").unwrap(); + let mirror = blocker.join("status.json"); + + let writer = StatusFileWriter::new_with_mirrors(primary.clone(), vec![mirror.clone()]); + let status = sample_status(); + writer.write(&status).unwrap(); + + // The primary is written and readable; the failed mirror is only warned. + assert!(primary.exists()); + assert_eq!(writer.read().unwrap(), status); + assert!( + std::fs::symlink_metadata(&mirror).is_err(), + "mirror must not exist" + ); +} + +#[test] +fn test_remove_symlink_if_present_leaves_regular_file() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("status.json"); + std::fs::write(&file, "{}").unwrap(); + + assert!(!remove_symlink_if_present(&file).unwrap()); + assert!(file.exists()); +} + +#[test] +fn test_remove_symlink_if_present_ok_when_missing() { + let temp = tempfile::tempdir().unwrap(); + assert!(!remove_symlink_if_present(&temp.path().join("absent.json")).unwrap()); +} + +#[cfg(unix)] +#[test] +fn test_remove_symlink_if_present_removes_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target.json"); + std::fs::write(&target, "{}").unwrap(); + let link = temp.path().join("status.json"); + symlink(&target, &link).unwrap(); + + assert!(remove_symlink_if_present(&link).unwrap()); + assert!(std::fs::symlink_metadata(&link).is_err()); + assert!(target.exists(), "the symlink target must be untouched"); +} + #[cfg(target_os = "macos")] #[test] fn test_mirror_write_replaces_existing_symlink_with_real_file() { diff --git a/crates/git-same-core/src/monitor/run.rs b/crates/git-same-core/src/monitor/run.rs index a65375f..f8bf241 100644 --- a/crates/git-same-core/src/monitor/run.rs +++ b/crates/git-same-core/src/monitor/run.rs @@ -138,11 +138,7 @@ where output.info("Starting git-same monitor..."); let live = LiveConfig::new(config.clone(), context.config_path.clone()); - let primary_status_path = ipc_config.status_file_path(); - let status_writer = StatusFileWriter::new_with_mirrors( - primary_status_path.clone(), - status_mirror_paths(&primary_status_path), - ); + let status_writer = ipc_config.status_writer(); let git = ShellGit::new(); let owner_types = OwnerTypeCache::load(OwnerTypeCache::default_path(&ipc_config.dir)); @@ -321,30 +317,6 @@ where Ok(()) } -/// Mirror paths for the status writer. On macOS the primary `status.json` -/// lives in the app-group container; mirror a real copy into the host-facing -/// `~/.config/git-same/finder/` so the non-sandboxed Tauri host can read live -/// status without reaching into the container (which would trigger the "access -/// data from other apps" TCC prompt). On other platforms the primary path is -/// already the host path, so there are no mirrors. -fn status_mirror_paths(primary: &Path) -> Vec { - #[cfg(target_os = "macos")] - { - if let Ok(host) = IpcConfig::host_status_path() { - let mirror = host.status_file_path(); - if mirror.as_path() != primary { - return vec![mirror]; - } - } - Vec::new() - } - #[cfg(not(target_os = "macos"))] - { - let _ = primary; - Vec::new() - } -} - /// Everything a socket task needs, cloned out of the loop. // Only the `#[cfg(unix)]` `serve_connection` reads these fields; off Unix the // struct is still built but never consumed, so every field reads as dead. From 250ac7d67375f411aa28260e3af1c703cfcf57ac Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 10 Jul 2026 10:38:56 +0200 Subject: [PATCH 07/21] Stamp monitor build version in status so the app can flag skew Add monitor_version to FinderStatus, stamped in FinderStatus::new with the building crate's CARGO_PKG_VERSION, so each status records the monitor build that wrote it. The Tauri Monitor requirement check compares it against the app's own version and, when a readable status reports a different build, tells the user to restart the monitor. Informational only: it does not flip the check to failed, and the stale hint still takes priority when no status is readable. Old status files without the field parse as None. --- crates/git-same-app/src/commands.rs | 56 +++++++++++++++++-- crates/git-same-app/src/commands_tests.rs | 52 ++++++++++++++++- crates/git-same-app/ui/src/lib/types.ts | 1 + .../git-same-core/src/types/finder_status.rs | 6 ++ .../src/types/finder_status_tests.rs | 23 ++++++++ 5 files changed, 130 insertions(+), 8 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 4ba57ab..eecf908 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -967,8 +967,16 @@ fn app_requirement_checks(ipc: &IpcConfig) -> Vec { checks.push(RequirementCheckDto { name: "Monitor".to_string(), passed: monitor_agent.as_ref().is_some_and(monitor_is_healthy), - message: monitor_requirement_message(monitor_agent.as_ref()), - suggestion: monitor_requirement_suggestion(monitor_agent.as_ref()), + message: monitor_requirement_message( + monitor_agent.as_ref(), + snapshot.as_ref(), + env!("CARGO_PKG_VERSION"), + ), + suggestion: monitor_requirement_suggestion( + monitor_agent.as_ref(), + snapshot.as_ref(), + env!("CARGO_PKG_VERSION"), + ), critical: false, }); @@ -1019,8 +1027,39 @@ fn monitor_is_healthy(agent: &MonitorLaunchAgentStatusDto) -> bool { ) } -fn monitor_requirement_message(agent: Option<&MonitorLaunchAgentStatusDto>) -> String { +/// The monitor's build version when the mirrored status reports one that +/// differs from the app's own build, or `None` when they match or none is +/// known. Older monitors that predate the `monitor_version` field, or that are +/// too old to mirror a readable status at all, report `None` here; the agent +/// state arms cover that case instead. +fn monitor_version_mismatch( + snapshot: Option<&StatusSnapshot>, + app_version: &str, +) -> Option { + snapshot + .and_then(|snapshot| snapshot.status.as_ref()) + .and_then(|status| status.monitor_version.clone()) + .filter(|version| version != app_version) +} + +fn monitor_requirement_message( + agent: Option<&MonitorLaunchAgentStatusDto>, + snapshot: Option<&StatusSnapshot>, + app_version: &str, +) -> String { match agent { + Some(agent) if monitor_is_healthy(agent) => { + match monitor_version_mismatch(snapshot, app_version) { + Some(skew) => format!( + "Monitor is running a different build ({}) than the app ({})", + skew, app_version + ), + None => agent + .detail + .clone() + .unwrap_or_else(|| agent.message.clone()), + } + } Some(agent) => agent .detail .clone() @@ -1029,10 +1068,17 @@ fn monitor_requirement_message(agent: Option<&MonitorLaunchAgentStatusDto>) -> S } } -fn monitor_requirement_suggestion(agent: Option<&MonitorLaunchAgentStatusDto>) -> Option { +fn monitor_requirement_suggestion( + agent: Option<&MonitorLaunchAgentStatusDto>, + snapshot: Option<&StatusSnapshot>, + app_version: &str, +) -> Option { let agent = agent?; match agent.state { - MonitorAgentState::Running | MonitorAgentState::Starting => None, + MonitorAgentState::Running | MonitorAgentState::Starting => { + monitor_version_mismatch(snapshot, app_version) + .map(|_| "Restart the monitor so it runs the same build as the app".to_string()) + } MonitorAgentState::Deferred => { Some("Nothing to do: it starts at your next login".to_string()) } diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index b58435b..21a6c7d 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -115,7 +115,7 @@ fn monitor_requirement_treats_a_long_first_scan_as_healthy() { assert!(monitor_is_healthy(&agent_in(MonitorAgentState::Starting))); assert!(monitor_is_healthy(&agent_in(MonitorAgentState::Running))); assert_eq!( - monitor_requirement_suggestion(Some(&agent_in(MonitorAgentState::Starting))), + monitor_requirement_suggestion(Some(&agent_in(MonitorAgentState::Starting)), None, "3.2.0"), None ); } @@ -125,7 +125,7 @@ fn monitor_requirement_does_not_call_an_intentional_stop_broken() { let stopped = agent_in(MonitorAgentState::Disabled); assert!(!monitor_is_healthy(&stopped)); assert_eq!( - monitor_requirement_suggestion(Some(&stopped)), + monitor_requirement_suggestion(Some(&stopped), None, "3.2.0"), Some("Start monitoring to see Finder badges".to_string()) ); } @@ -134,7 +134,7 @@ fn monitor_requirement_does_not_call_an_intentional_stop_broken() { fn monitor_requirement_prefers_the_concrete_error_detail() { let failed = agent_in(MonitorAgentState::Failed).failed("launchctl bootstrap failed"); assert_eq!( - monitor_requirement_message(Some(&failed)), + monitor_requirement_message(Some(&failed), None, "3.2.0"), "launchctl bootstrap failed" ); } @@ -625,3 +625,49 @@ fn open_url_scheme_match_is_case_insensitive() { "X-Apple.SystemPreferences:com.apple.LoginItems-Settings.extension" )); } + +fn running_agent() -> MonitorLaunchAgentStatusDto { + agent_in(MonitorAgentState::Running) +} + +fn snapshot_with_monitor_version(version: Option<&str>) -> StatusSnapshot { + let mut status = FinderStatus::new(4242, "2026-07-07T00:00:00Z".to_string()); + status.monitor_version = version.map(str::to_string); + StatusSnapshot { + status_path: "/tmp/status.json".to_string(), + updated_at: Some("2026-07-07T00:00:00Z".to_string()), + stale: false, + status: Some(status), + } +} + +#[test] +fn monitor_requirement_flags_version_skew() { + let agent = running_agent(); + let snapshot = snapshot_with_monitor_version(Some("3.1.0")); + + assert_eq!( + monitor_requirement_message(Some(&agent), Some(&snapshot), "3.2.0"), + "Monitor is running a different build (3.1.0) than the app (3.2.0)" + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), Some(&snapshot), "3.2.0"), + Some("Restart the monitor so it runs the same build as the app".to_string()) + ); +} + +#[test] +fn monitor_requirement_ignores_matching_version() { + let agent = running_agent(); + let snapshot = snapshot_with_monitor_version(Some("3.2.0")); + + // Matching versions leave the healthy agent message and no skew hint. + assert_eq!( + monitor_requirement_message(Some(&agent), Some(&snapshot), "3.2.0"), + "Running" + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), Some(&snapshot), "3.2.0"), + None + ); +} diff --git a/crates/git-same-app/ui/src/lib/types.ts b/crates/git-same-app/ui/src/lib/types.ts index c5c02fd..e5721e9 100644 --- a/crates/git-same-app/ui/src/lib/types.ts +++ b/crates/git-same-app/ui/src/lib/types.ts @@ -250,6 +250,7 @@ export interface FinderStatus { org_folders?: OrgFolderInfo[]; monitored_roots?: string[]; boot_volume_aliases?: string[]; + monitor_version?: string; } export interface StatusSnapshot { diff --git a/crates/git-same-core/src/types/finder_status.rs b/crates/git-same-core/src/types/finder_status.rs index 7b31c5b..1aa9100 100644 --- a/crates/git-same-core/src/types/finder_status.rs +++ b/crates/git-same-core/src/types/finder_status.rs @@ -154,6 +154,11 @@ pub struct FinderStatus { /// container. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub boot_volume_aliases: Vec, + /// Version of the monitor build that wrote this status (CARGO_PKG_VERSION). + /// Hosts compare it against their own build to detect app/monitor skew. + /// Absent in status files written before this field existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub monitor_version: Option, } impl FinderStatus { @@ -172,6 +177,7 @@ impl FinderStatus { org_folders: Vec::new(), monitored_roots: Vec::new(), boot_volume_aliases: Vec::new(), + monitor_version: Some(env!("CARGO_PKG_VERSION").to_string()), } } } diff --git a/crates/git-same-core/src/types/finder_status_tests.rs b/crates/git-same-core/src/types/finder_status_tests.rs index ac67669..a8563d6 100644 --- a/crates/git-same-core/src/types/finder_status_tests.rs +++ b/crates/git-same-core/src/types/finder_status_tests.rs @@ -143,6 +143,29 @@ fn test_finder_status_serialization() { assert_eq!(parsed, status); } +#[test] +fn test_new_stamps_monitor_version() { + let status = FinderStatus::new(1, "t".to_string()); + assert_eq!( + status.monitor_version.as_deref(), + Some(env!("CARGO_PKG_VERSION")), + "new() must stamp the building crate's version" + ); + // The stamped version survives a round-trip. + let json = serde_json::to_string(&status).unwrap(); + let parsed: FinderStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.monitor_version, status.monitor_version); +} + +#[test] +fn test_legacy_status_without_monitor_version_deserializes_to_none() { + // Status files written before this field existed lack the key; they must + // still parse, with monitor_version absent. + let legacy = r#"{"version":1,"timestamp":"t","daemon_pid":1,"workspaces":[],"repos":[]}"#; + let parsed: FinderStatus = serde_json::from_str(legacy).unwrap(); + assert!(parsed.monitor_version.is_none()); +} + #[test] fn test_boot_volume_aliases_serialization() { // Empty: the key is omitted entirely (skip_serializing_if). From 0bd9f6ccb37be94d5c933b526f3c99e4173442f8 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 10 Jul 2026 21:33:01 +0200 Subject: [PATCH 08/21] Auto-restart stale monitor on app launch to recover host status After an app upgrade the previously installed monitor keeps running the old build (launchd KeepAlive keeps the process alive; nothing restarts it). The old monitor only symlinks the host status.json into the container and never writes the mirror the new host reads, so the host deletes the leftover symlink and then shows stale/absent status until a manual restart. On startup, detect that leftover symlink (a reliable signal an old monitor is running) via symlink_metadata, which does not follow the link into the app-group container, and best-effort restart the installed monitor on a background thread so the on-disk build takes over and starts mirroring. Skip when no LaunchAgent is installed so a monitor is never created implicitly. This complements the stale-status guidance text by making the common upgrade case self-heal without user action. --- crates/git-same-app/src/commands.rs | 20 ++++++++++++++++++++ crates/git-same-app/src/main.rs | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index eecf908..b6c2cac 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -745,6 +745,26 @@ fn monitor_launch_agent_status_inner() -> Result Result<(), AppError> { + let controller = match monitor_agent::controller_for_current_user(false) { + Ok(controller) => controller, + Err(MonitorAgentError::Unsupported) => return Ok(()), + Err(error) => return Err(error.into()), + }; + if controller.inspect()?.state == MonitorAgentState::NotInstalled { + return Ok(()); + } + controller.restart()?; + Ok(()) +} + // `pluginkit -m -v -i ` prints one line per plugin matching the id, or // nothing if no match. Each line begins with `+` (enabled) or `-` (disabled), // followed by the plugin id and bundle path. We treat any line containing diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index c929ec2..3321de0 100644 --- a/crates/git-same-app/src/main.rs +++ b/crates/git-same-app/src/main.rs @@ -39,6 +39,30 @@ fn main() { // apps" TCC prompt). let host_ipc = git_same_core::ipc::IpcConfig::host_status_path()?; app.manage(commands::HostIpc(host_ipc.clone())); + + // A leftover symlink at the host status.json path means an old + // monitor build is still running (only pre-upgrade monitors symlink + // it into the container; the current monitor writes a real mirror + // file). Best-effort restart the installed monitor so the upgraded + // build takes over and starts mirroring, instead of the app showing + // stale status until the user restarts it by hand. symlink_metadata + // does not follow the link, so this never reaches into the app-group + // container (no "access data from other apps" TCC prompt). Run on a + // background thread so the synchronous launchctl calls do not block + // app startup. + let host_status_is_symlink = host_ipc + .status_file_path() + .symlink_metadata() + .map(|meta| meta.file_type().is_symlink()) + .unwrap_or(false); + if host_status_is_symlink { + std::thread::spawn(|| { + if let Err(error) = commands::restart_monitor_if_installed() { + eprintln!("failed to restart monitor after upgrade: {error}"); + } + }); + } + if let Err(error) = status_stream::spawn_watcher(app.handle().clone(), host_ipc) { eprintln!("failed to start status watcher: {error}"); } From 73abf11efe8ceab566f9ab95f7fbb86a92d12339 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 24 Jul 2026 13:05:19 +0200 Subject: [PATCH 09/21] Fix monitor skew pass-state and harden IPC status read failures Make the app's Monitor requirement fail its pass check on a build-version skew via a new monitor_requirement_passed helper, so the row no longer shows a green check while its message and suggestion say to restart the monitor. Log the status watcher's watcher-error and snapshot-read-error paths instead of swallowing them, so a stale dashboard leaves a diagnostic trail. Propagate non-NotFound symlink_metadata failures from remove_symlink_if_present so read_status_snapshot_with aborts rather than dereferencing a path it could not inspect, preserving the TCC-safety guarantee. --- crates/git-same-app/src/commands.rs | 19 ++++++++++- crates/git-same-app/src/commands_tests.rs | 22 +++++++++++++ crates/git-same-app/src/status_stream.rs | 13 ++++++-- crates/git-same-core/src/ipc/status_file.rs | 35 +++++++++++++++------ 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index b6c2cac..7676a6f 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -986,7 +986,11 @@ fn app_requirement_checks(ipc: &IpcConfig) -> Vec { let monitor_agent = monitor_launch_agent_status_inner().ok(); checks.push(RequirementCheckDto { name: "Monitor".to_string(), - passed: monitor_agent.as_ref().is_some_and(monitor_is_healthy), + passed: monitor_requirement_passed( + monitor_agent.as_ref(), + snapshot.as_ref(), + env!("CARGO_PKG_VERSION"), + ), message: monitor_requirement_message( monitor_agent.as_ref(), snapshot.as_ref(), @@ -1062,6 +1066,19 @@ fn monitor_version_mismatch( .filter(|version| version != app_version) } +/// Whether the Monitor requirement is satisfied. Mirrors the conditions that +/// `monitor_requirement_message`/`monitor_requirement_suggestion` treat as +/// problems, including a build-version skew, so the row's pass state never +/// contradicts its own message and suggestion. +fn monitor_requirement_passed( + agent: Option<&MonitorLaunchAgentStatusDto>, + snapshot: Option<&StatusSnapshot>, + app_version: &str, +) -> bool { + agent.is_some_and(monitor_is_healthy) + && monitor_version_mismatch(snapshot, app_version).is_none() +} + fn monitor_requirement_message( agent: Option<&MonitorLaunchAgentStatusDto>, snapshot: Option<&StatusSnapshot>, diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index 21a6c7d..8c37502 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -671,3 +671,25 @@ fn monitor_requirement_ignores_matching_version() { None ); } + +#[test] +fn monitor_requirement_fails_pass_on_version_skew() { + let agent = running_agent(); + + // A running monitor on a mismatched build must not pass, so the row's + // state agrees with its "different build" message and restart suggestion. + let skewed = snapshot_with_monitor_version(Some("3.1.0")); + assert!(!monitor_requirement_passed( + Some(&agent), + Some(&skewed), + "3.2.0" + )); + + // Matching builds still pass. + let matched = snapshot_with_monitor_version(Some("3.2.0")); + assert!(monitor_requirement_passed( + Some(&agent), + Some(&matched), + "3.2.0" + )); +} diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs index f7d647b..4825e16 100644 --- a/crates/git-same-app/src/status_stream.rs +++ b/crates/git-same-app/src/status_stream.rs @@ -184,14 +184,21 @@ pub fn spawn_watcher(app: AppHandle, ipc: IpcConfig) -> anyhow::Result<()> { .unwrap_or(Relevance::Ignore); debouncer.record(relevance, Instant::now()); } - Ok(Err(_)) => {} + Ok(Err(error)) => { + eprintln!("status watcher event error: {error}"); + } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { let Some(fired) = debouncer.take_due(Instant::now()) else { continue; }; if fired == Relevance::DataAndMonitor { - if let Ok(snapshot) = read_status_snapshot_with(&ipc) { - let _ = app.emit("status-updated", snapshot); + match read_status_snapshot_with(&ipc) { + Ok(snapshot) => { + let _ = app.emit("status-updated", snapshot); + } + Err(error) => { + eprintln!("failed to read status snapshot: {error}"); + } } } if fired != Relevance::Ignore { diff --git a/crates/git-same-core/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs index 5127c03..118987d 100644 --- a/crates/git-same-core/src/ipc/status_file.rs +++ b/crates/git-same-core/src/ipc/status_file.rs @@ -114,18 +114,35 @@ impl StatusFileWriter { /// from the removal is treated as success. The narrower race where the /// monitor renames a real file over the symlink inside that window is /// accepted: the next monitor write (at most one scan interval) restores it. +/// +/// A `symlink_metadata` failure other than `NotFound` (e.g. a permission or +/// I/O error inspecting the path) is propagated rather than swallowed as +/// "nothing to remove": callers such as `read_status_snapshot_with` abort +/// instead of continuing on to dereference a path they could not inspect, +/// which for an un-inspectable symlink would re-trigger the TCC prompt. pub fn remove_symlink_if_present(path: &Path) -> Result { - match std::fs::symlink_metadata(path) { - Ok(meta) if meta.file_type().is_symlink() => match std::fs::remove_file(path) { - Ok(()) => Ok(true), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true), - Err(e) => Err(AppError::path(format!( - "Failed to remove symlink '{}': {}", + let meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => { + return Err(AppError::path(format!( + "Failed to inspect '{}': {}", path.display(), e - ))), - }, - _ => Ok(false), + ))) + } + }; + if !meta.file_type().is_symlink() { + return Ok(false); + } + match std::fs::remove_file(path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(e) => Err(AppError::path(format!( + "Failed to remove symlink '{}': {}", + path.display(), + e + ))), } } From 0abbcf5aec0c02d9653b8a405792470997372c83 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 10 Aug 2026 21:35:39 +0200 Subject: [PATCH 10/21] Preserve watcher rescans to prevent stale app status --- crates/git-same-app/src/status_stream.rs | 29 ++++++++++----- .../git-same-app/src/status_stream_tests.rs | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs index 4825e16..a9688b7 100644 --- a/crates/git-same-app/src/status_stream.rs +++ b/crates/git-same-app/src/status_stream.rs @@ -10,7 +10,7 @@ use crate::commands::{read_status_snapshot_with, refresh_monitor_status}; use git_same_core::ipc::IpcConfig; -use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -98,6 +98,25 @@ fn file_name_of(path: &Path) -> OsString { path.file_name().unwrap_or(path.as_os_str()).to_os_string() } +/// What a watcher event means for the UI. +/// +/// A rescan event says the backend dropped kernel-side events, so the status +/// file may have changed without a path-bearing event ever arriving; the same +/// goes for a path-less event on backends that use those to signal a missed +/// update. Both are treated as new data rather than ignored, otherwise the +/// dashboard can sit on stale status indefinitely. +pub(crate) fn event_relevance(targets: &WatchTargets, event: &Event) -> Relevance { + if event.need_rescan() || event.paths.is_empty() { + return Relevance::DataAndMonitor; + } + event + .paths + .iter() + .map(|path| targets.relevance(path)) + .max() + .unwrap_or(Relevance::Ignore) +} + /// Collects a burst of events into one update. /// /// Trailing edge: each further event pushes the deadline out, so a scan no @@ -176,13 +195,7 @@ pub fn spawn_watcher(app: AppHandle, ipc: IpcConfig) -> anyhow::Result<()> { let timeout = debouncer.timeout(Instant::now()); match rx.recv_timeout(timeout) { Ok(Ok(event)) => { - let relevance = event - .paths - .iter() - .map(|path| targets.relevance(path)) - .max() - .unwrap_or(Relevance::Ignore); - debouncer.record(relevance, Instant::now()); + debouncer.record(event_relevance(&targets, &event), Instant::now()); } Ok(Err(error)) => { eprintln!("status watcher event error: {error}"); diff --git a/crates/git-same-app/src/status_stream_tests.rs b/crates/git-same-app/src/status_stream_tests.rs index b19b670..06fac6f 100644 --- a/crates/git-same-app/src/status_stream_tests.rs +++ b/crates/git-same-app/src/status_stream_tests.rs @@ -142,3 +142,38 @@ fn ignored_events_never_arm_the_debouncer() { assert_eq!(debouncer.timeout(start), Duration::from_secs(3600)); assert_eq!(debouncer.take_due(start + Duration::from_secs(10)), None); } + +#[test] +fn rescan_and_pathless_events_count_as_new_data() { + use notify::{event::Flag, EventKind}; + + let targets = targets(); + + // Path-bearing rescan: the path names the directory, not status.json. + let rescan = Event::new(EventKind::Other) + .set_flag(Flag::Rescan) + .add_path(std::path::PathBuf::from("/group")); + assert_eq!( + event_relevance(&targets, &rescan), + Relevance::DataAndMonitor + ); + + // Path-less rescan. + let pathless_rescan = Event::new(EventKind::Other).set_flag(Flag::Rescan); + assert_eq!( + event_relevance(&targets, &pathless_rescan), + Relevance::DataAndMonitor + ); + + // Path-less event without the rescan flag. + let pathless = Event::new(EventKind::Other); + assert_eq!( + event_relevance(&targets, &pathless), + Relevance::DataAndMonitor + ); + + // A normal irrelevant path is still ignored. + let temp = + Event::new(EventKind::Other).add_path(std::path::PathBuf::from("/group/monitor.lock")); + assert_eq!(event_relevance(&targets, &temp), Relevance::Ignore); +} From 6380efb2ebdc1fc1af0e19d0ae12c656ec6b0abc Mon Sep 17 00:00:00 2001 From: Manuel Date: Tue, 11 Aug 2026 03:24:08 +0200 Subject: [PATCH 11/21] Fix TUI status refresh state to prevent sync freezes Run status scans as guarded background work, recover legacy Status states, refresh requirements, and show animated feedback. Add regression coverage for manual, automatic, and post-sync refresh paths. --- crates/git-same-cli/src/tui/app.rs | 2 +- crates/git-same-cli/src/tui/backend.rs | 24 +- crates/git-same-cli/src/tui/backend_tests.rs | 100 +++++++- crates/git-same-cli/src/tui/handler.rs | 27 ++- crates/git-same-cli/src/tui/handler_tests.rs | 198 ++++++++++++++++ .../git-same-cli/src/tui/screens/dashboard.rs | 62 +++-- .../src/tui/screens/dashboard_tests.rs | 221 ++++++++++++++++++ crates/git-same-cli/src/tui/widgets/mod.rs | 1 + .../git-same-cli/src/tui/widgets/spinner.rs | 12 + .../src/tui/widgets/spinner_tests.rs | 14 ++ 10 files changed, 622 insertions(+), 39 deletions(-) create mode 100644 crates/git-same-cli/src/tui/widgets/spinner.rs create mode 100644 crates/git-same-cli/src/tui/widgets/spinner_tests.rs diff --git a/crates/git-same-cli/src/tui/app.rs b/crates/git-same-cli/src/tui/app.rs index fd2bd3d..be15cb4 100644 --- a/crates/git-same-cli/src/tui/app.rs +++ b/crates/git-same-cli/src/tui/app.rs @@ -234,7 +234,7 @@ pub struct App { /// Scroll offset for the workspace detail right pane. pub workspace_detail_scroll: u16, - /// Tick counter for driving animations on the Progress screen. + /// Tick counter for driving Sync and Dashboard animations. pub tick_count: u64, /// Structured sync log entries (enriched data). diff --git a/crates/git-same-cli/src/tui/backend.rs b/crates/git-same-cli/src/tui/backend.rs index e158a7e..2e116d8 100644 --- a/crates/git-same-cli/src/tui/backend.rs +++ b/crates/git-same-cli/src/tui/backend.rs @@ -17,7 +17,7 @@ use git_same_core::workflows::sync_workspace::{ execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, }; -use super::app::{App, Operation}; +use super::app::{App, Operation, OperationState}; use super::event::{AppEvent, BackendMessage}; // -- Progress adapters that send events to the TUI via channels -- @@ -212,6 +212,28 @@ impl SyncProgress for TuiSyncProgress { // -- Spawn functions -- +/// Start a background status refresh when it cannot conflict with another scan or sync. +pub(crate) fn try_start_status_refresh(app: &mut App, tx: &UnboundedSender) -> bool { + let sync_active = matches!( + &app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } | OperationState::Running { + operation: Operation::Sync, + .. + } + ); + + if app.active_workspace.is_none() || app.status_loading || sync_active { + return false; + } + + app.status_loading = true; + spawn_operation(Operation::Status, app, tx.clone()); + true +} + /// Spawn an async task to fetch recent commits for a repo (post-sync deep dive). pub fn spawn_commit_fetch( repo_path: std::path::PathBuf, diff --git a/crates/git-same-cli/src/tui/backend_tests.rs b/crates/git-same-cli/src/tui/backend_tests.rs index 21008c9..d7a36be 100644 --- a/crates/git-same-cli/src/tui/backend_tests.rs +++ b/crates/git-same-cli/src/tui/backend_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::tui::event::{AppEvent, BackendMessage}; -use git_same_core::config::Config; +use git_same_core::config::{Config, WorkspaceConfig}; use git_same_core::git::{FetchResult, PullResult}; use git_same_core::operations::clone::CloneProgress; use git_same_core::operations::sync::SyncProgress; @@ -21,6 +21,104 @@ fn expect_backend_event(event: AppEvent) -> BackendMessage { } } +fn app_with_temp_workspace() -> (tempfile::TempDir, App) { + let temp = tempfile::tempdir().expect("temp workspace"); + let workspace = WorkspaceConfig::new_from_root(temp.path()); + let app = App::new(Config::default(), vec![workspace], false); + (temp, app) +} + +#[tokio::test] +async fn try_start_status_refresh_starts_scan() { + let (_temp, mut app) = app_with_temp_workspace(); + let (tx, mut rx) = unbounded_channel(); + + assert!(try_start_status_refresh(&mut app, &tx)); + assert!(app.status_loading); + assert!(matches!(app.operation_state, OperationState::Idle)); + + let event = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for status results") + .expect("channel closed unexpectedly"); + assert!(matches!( + expect_backend_event(event), + BackendMessage::StatusResults(_) + )); +} + +#[test] +fn try_start_status_refresh_rejects_duplicate_scan() { + let (_temp, mut app) = app_with_temp_workspace(); + app.status_loading = true; + let (tx, mut rx) = unbounded_channel(); + + assert!(!try_start_status_refresh(&mut app, &tx)); + assert!(app.status_loading); + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); +} + +#[test] +fn try_start_status_refresh_rejects_missing_workspace() { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, mut rx) = unbounded_channel(); + + assert!(!try_start_status_refresh(&mut app, &tx)); + assert!(!app.status_loading); + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); +} + +#[test] +fn try_start_status_refresh_rejects_active_sync_states() { + let (_temp, mut app) = app_with_temp_workspace(); + let active_states = [ + OperationState::Discovering { + operation: Operation::Sync, + message: "Discovering repositories".to_string(), + }, + OperationState::Running { + operation: Operation::Sync, + total: 1, + completed: 0, + failed: 0, + skipped: 0, + current_repo: "acme/rocket".to_string(), + with_updates: 0, + cloned: 0, + synced: 0, + to_clone: 0, + to_sync: 1, + total_new_commits: 0, + started_at: std::time::Instant::now(), + active_repos: vec!["acme/rocket".to_string()], + throughput_samples: Vec::new(), + last_sample_completed: 0, + }, + ]; + + for state in active_states { + app.operation_state = state; + app.status_loading = false; + let (tx, mut rx) = unbounded_channel(); + + assert!(!try_start_status_refresh(&mut app, &tx)); + assert!(!app.status_loading); + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } | OperationState::Running { + operation: Operation::Sync, + .. + } + )); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); + } +} + #[test] fn discovery_progress_emits_expected_messages() { let (tx, mut rx) = unbounded_channel(); diff --git a/crates/git-same-cli/src/tui/handler.rs b/crates/git-same-cli/src/tui/handler.rs index 724e2b5..84cd074 100644 --- a/crates/git-same-cli/src/tui/handler.rs +++ b/crates/git-same-cli/src/tui/handler.rs @@ -34,12 +34,12 @@ pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &Unbounded } ); - // Keep sync animation/throughput sampling active even when progress popup is hidden. - if sync_in_progress { + // Keep operation animations active even when their UI is hidden. + if sync_in_progress || app.status_loading { app.tick_count = app.tick_count.wrapping_add(1); // Sample throughput every 10 ticks (1 second at 100ms tick rate) - if app.tick_count.is_multiple_of(10) { + if sync_in_progress && app.tick_count.is_multiple_of(10) { if let OperationState::Running { operation: Operation::Sync, completed, @@ -131,15 +131,11 @@ pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &Unbounded .and_then(|ws| ws.refresh_interval) .unwrap_or(app.config.refresh_interval); if app.screen == Screen::Dashboard - && app.active_workspace.is_some() - && !app.status_loading - && !sync_in_progress && app .last_status_scan .is_none_or(|t| t.elapsed().as_secs() >= refresh_interval) { - app.status_loading = true; - super::backend::spawn_operation(Operation::Status, app, backend_tx.clone()); + super::backend::try_start_status_refresh(app, backend_tx); } } AppEvent::Resize(_, _) => {} // ratatui handles resize @@ -544,9 +540,6 @@ fn handle_backend_message( let _ = manager.save(&app.sync_history); } } - - // Auto-trigger status scan so dashboard is fresh - super::backend::spawn_operation(Operation::Status, app, backend_tx.clone()); } // Default to Updated filter if there were updates, else All @@ -566,6 +559,13 @@ fn handle_backend_message( total_new_commits: tnc, duration_secs: dur, }; + + // Auto-trigger a guarded status scan after leaving the active Sync state. + // Setting status_loading in the shared helper prevents the next dashboard + // tick from launching a duplicate scan. + if op == Operation::Sync { + super::backend::try_start_status_refresh(app, backend_tx); + } } BackendMessage::OperationError(msg) => { app.operation_state = OperationState::Idle; @@ -575,7 +575,10 @@ fn handle_backend_message( app.local_repos = entries; if matches!( app.operation_state, - OperationState::Running { + OperationState::Discovering { + operation: Operation::Status, + .. + } | OperationState::Running { operation: Operation::Status, .. } diff --git a/crates/git-same-cli/src/tui/handler_tests.rs b/crates/git-same-cli/src/tui/handler_tests.rs index 369c983..246304e 100644 --- a/crates/git-same-cli/src/tui/handler_tests.rs +++ b/crates/git-same-cli/src/tui/handler_tests.rs @@ -3,8 +3,30 @@ use crate::setup::state::{OrgEntry, SetupState, SetupStep}; use crate::tui::event::{AppEvent, BackendMessage}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use git_same_core::config::{Config, WorkspaceConfig}; +use git_same_core::types::OpSummary; use tokio::sync::mpsc::unbounded_channel; +fn running_state(operation: Operation) -> OperationState { + OperationState::Running { + operation, + total: 5, + completed: 3, + failed: 0, + skipped: 0, + current_repo: String::new(), + with_updates: 0, + cloned: 0, + synced: 0, + to_clone: 0, + to_sync: 5, + total_new_commits: 0, + started_at: std::time::Instant::now(), + active_repos: Vec::new(), + throughput_samples: Vec::new(), + last_sample_completed: 0, + } +} + #[tokio::test] async fn q_quits_immediately() { let ws = WorkspaceConfig::new_from_root(std::path::Path::new("/tmp/test-ws")); @@ -136,3 +158,179 @@ async fn setup_check_results_preserve_suggestions() { Some("Run: gh auth login") ); } + +#[tokio::test] +async fn status_results_clears_discovering_status_state() { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = OperationState::Discovering { + operation: Operation::Status, + message: "Starting Status...".to_string(), + }; + app.status_loading = true; + + handle_event( + &mut app, + AppEvent::Backend(BackendMessage::StatusResults(Vec::new())), + &tx, + ) + .await; + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(!app.status_loading); + assert!(app.last_status_scan.is_some()); +} + +#[test] +fn status_results_clear_running_status_state() { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = running_state(Operation::Status); + app.status_loading = true; + + handle_backend_message(&mut app, BackendMessage::StatusResults(Vec::new()), &tx); + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(!app.status_loading); + assert!(app.last_status_scan.is_some()); +} + +#[test] +fn status_results_do_not_clear_active_sync_states() { + let states = [ + OperationState::Discovering { + operation: Operation::Sync, + message: "Discovering repositories".to_string(), + }, + running_state(Operation::Sync), + ]; + + for state in states { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = state; + app.status_loading = true; + + handle_backend_message(&mut app, BackendMessage::StatusResults(Vec::new()), &tx); + + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } | OperationState::Running { + operation: Operation::Sync, + .. + } + )); + assert!(!app.status_loading); + assert!(app.last_status_scan.is_some()); + } +} + +#[tokio::test] +async fn status_refresh_does_not_block_sync() { + let temp = tempfile::tempdir().expect("temp workspace"); + let workspace = WorkspaceConfig::new_from_root(temp.path()); + let mut app = App::new(Config::default(), vec![workspace], false); + let (tx, _rx) = unbounded_channel(); + app.screen = Screen::Dashboard; + app.checks_loading = true; + + handle_event( + &mut app, + AppEvent::Terminal(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), + &tx, + ) + .await; + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(app.status_loading); + + handle_event( + &mut app, + AppEvent::Backend(BackendMessage::StatusResults(Vec::new())), + &tx, + ) + .await; + assert!(!app.status_loading); + + // Avoid provider/network work while still exercising the full key-routing path. + app.active_workspace = None; + handle_event( + &mut app, + AppEvent::Terminal(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)), + &tx, + ) + .await; + + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } + )); + assert!(app.error_message.is_none()); +} + +#[tokio::test] +async fn status_loading_tick_advances_animation_while_operation_is_idle() { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, _rx) = unbounded_channel(); + app.screen = Screen::Dashboard; + app.checks_loading = true; + app.status_loading = true; + app.tick_count = 9; + + handle_event(&mut app, AppEvent::Tick, &tx).await; + + assert_eq!(app.tick_count, 10); + assert!(matches!(app.operation_state, OperationState::Idle)); +} + +#[tokio::test] +async fn operation_complete_starts_one_guarded_status_refresh() { + let temp = tempfile::tempdir().expect("temp directory"); + let blocked_parent = temp.path().join("not-a-directory"); + std::fs::write(&blocked_parent, b"block workspace persistence").expect("create blocking file"); + let workspace = WorkspaceConfig::new_from_root(&blocked_parent.join("workspace")); + let mut app = App::new(Config::default(), vec![workspace], false); + let (tx, mut rx) = unbounded_channel(); + app.screen = Screen::Dashboard; + app.checks_loading = true; + app.operation_state = running_state(Operation::Sync); + + handle_backend_message( + &mut app, + BackendMessage::OperationComplete(OpSummary::new()), + &tx, + ); + + assert!(matches!( + app.operation_state, + OperationState::Finished { + operation: Operation::Sync, + .. + } + )); + assert!(app.status_loading); + + let tick_before = app.tick_count; + handle_event(&mut app, AppEvent::Tick, &tx).await; + assert_eq!(app.tick_count, tick_before.wrapping_add(1)); + + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("post-sync status scan should complete promptly") + .expect("backend event"); + assert!(matches!( + event, + AppEvent::Backend(BackendMessage::StatusResults(_)) + )); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()) + .await + .is_err(), + "the following dashboard tick must not launch a duplicate status scan" + ); +} diff --git a/crates/git-same-cli/src/tui/screens/dashboard.rs b/crates/git-same-cli/src/tui/screens/dashboard.rs index 7acf9e7..4ce8f73 100644 --- a/crates/git-same-cli/src/tui/screens/dashboard.rs +++ b/crates/git-same-cli/src/tui/screens/dashboard.rs @@ -31,9 +31,15 @@ pub async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSend show_sync_progress(app); } KeyCode::Char('t') => { - app.last_status_scan = None; // Force immediate refresh - app.status_loading = true; - start_operation(app, Operation::Status, backend_tx); + if crate::tui::backend::try_start_status_refresh(app, backend_tx) { + app.last_status_scan = None; + + // An in-flight requirements check already refreshes these results. Otherwise, + // clear the completed results so the next tick starts a fresh check. + if !app.checks_loading { + app.check_results.clear(); + } + } } // Tab shortcuts KeyCode::Char('o') => { @@ -112,7 +118,13 @@ pub async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSend } } -fn start_operation(app: &mut App, operation: Operation, backend_tx: &UnboundedSender) { +pub(crate) fn start_sync_operation(app: &mut App, backend_tx: &UnboundedSender) { + if app.status_loading { + app.error_message = + Some("Status refresh is still running; try again when it completes".to_string()); + return; + } + if matches!( app.operation_state, OperationState::Discovering { .. } | OperationState::Running { .. } @@ -123,17 +135,13 @@ fn start_operation(app: &mut App, operation: Operation, backend_tx: &UnboundedSe app.tick_count = 0; app.operation_state = OperationState::Discovering { - operation, - message: format!("Starting {}...", operation), + operation: Operation::Sync, + message: "Starting Sync...".to_string(), }; app.log_lines.clear(); app.scroll_offset = 0; - crate::tui::backend::spawn_operation(operation, app, backend_tx.clone()); -} - -pub(crate) fn start_sync_operation(app: &mut App, backend_tx: &UnboundedSender) { - start_operation(app, Operation::Sync, backend_tx); + crate::tui::backend::spawn_operation(Operation::Sync, app, backend_tx.clone()); } pub(crate) fn show_sync_progress(app: &mut App) { @@ -321,34 +329,40 @@ fn render_config_reqs(app: &App, frame: &mut Frame, area: Rect) { Span::styled(" Settings ", dim), ]; - let right = if app.checks_loading || app.check_results.is_empty() { - vec![ - Span::styled(" Checking...", Style::default().fg(Color::Yellow)), - Span::raw(" "), - Span::styled("[t]", key_style), - Span::styled(" Refresh", dim), - ] + let mut right = if app.checks_loading || app.check_results.is_empty() { + vec![Span::styled( + " Checking...", + Style::default().fg(Color::Yellow), + )] } else { let all_passed = app.check_results.iter().all(|c| c.passed); if all_passed { vec![ Span::styled(" [✓]", Style::default().fg(Color::Rgb(21, 128, 61))), Span::styled(" Requirements Satisfied", dim), - Span::raw(" "), - Span::styled("[t]", key_style), - Span::styled(" Refresh", dim), ] } else { vec![ Span::styled(" [✗]", Style::default().fg(Color::Red)), Span::styled(" Requirements Not Met", dim), - Span::raw(" "), - Span::styled("[t]", key_style), - Span::styled(" Refresh", dim), ] } }; + right.push(Span::raw(" ")); + if app.status_loading { + right.push(Span::styled( + format!( + "{} Refreshing...", + crate::tui::widgets::spinner::frame(app.tick_count) + ), + Style::default().fg(Color::Yellow), + )); + } else { + right.push(Span::styled("[t]", key_style)); + right.push(Span::styled(" Refresh", dim)); + } + render_info_line(frame, area, left, right); } diff --git a/crates/git-same-cli/src/tui/screens/dashboard_tests.rs b/crates/git-same-cli/src/tui/screens/dashboard_tests.rs index 4f5cbf0..1f17e09 100644 --- a/crates/git-same-cli/src/tui/screens/dashboard_tests.rs +++ b/crates/git-same-cli/src/tui/screens/dashboard_tests.rs @@ -1,6 +1,9 @@ use super::*; +use crate::tui::app::CheckEntry; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use git_same_core::config::{Config, WorkspaceConfig}; +use ratatui::backend::TestBackend; +use ratatui::Terminal; use tokio::sync::mpsc::unbounded_channel; fn build_app() -> App { @@ -11,6 +14,224 @@ fn build_app() -> App { app } +fn build_app_in(root: &std::path::Path) -> App { + let ws = WorkspaceConfig::new_from_root(root); + let mut app = App::new(Config::default(), vec![ws], false); + app.screen = Screen::Dashboard; + app.screen_stack.clear(); + app +} + +fn completed_check() -> CheckEntry { + CheckEntry { + name: "git".to_string(), + passed: true, + message: "Git is installed".to_string(), + suggestion: None, + critical: true, + } +} + +fn running_sync_state() -> OperationState { + OperationState::Running { + operation: Operation::Sync, + total: 2, + completed: 0, + failed: 0, + skipped: 0, + current_repo: "org/repo".to_string(), + with_updates: 0, + cloned: 0, + synced: 0, + to_clone: 1, + to_sync: 1, + total_new_commits: 0, + started_at: std::time::Instant::now(), + active_repos: vec!["org/repo".to_string()], + throughput_samples: Vec::new(), + last_sample_completed: 0, + } +} + +fn render_output(app: &mut App) -> String { + let backend = TestBackend::new(110, 32); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|frame| render(app, frame)).unwrap(); + + let buffer = terminal.backend().buffer(); + let mut text = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + text.push_str(buffer[(x, y)].symbol()); + } + text.push('\n'); + } + text +} + +#[tokio::test] +async fn t_key_does_not_set_operation_state() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + let completed_at = std::time::Instant::now(); + app.last_status_scan = Some(completed_at); + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(app.status_loading); + assert!(app.last_status_scan.is_none()); +} + +#[tokio::test] +async fn t_key_is_ignored_while_status_refresh_is_loading() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + let completed_at = std::time::Instant::now(); + app.status_loading = true; + app.last_status_scan = Some(completed_at); + app.check_results = vec![completed_check()]; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(app.status_loading); + assert_eq!(app.last_status_scan, Some(completed_at)); + assert_eq!(app.check_results.len(), 1); + assert!(matches!(app.operation_state, OperationState::Idle)); +} + +#[tokio::test] +async fn t_key_clears_check_results() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + app.check_results = vec![completed_check()]; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(app.check_results.is_empty()); + assert!(!app.checks_loading); + assert!(app.status_loading); + assert!(matches!(app.operation_state, OperationState::Idle)); +} + +#[tokio::test] +async fn t_key_is_ignored_while_sync_is_discovering_or_running() { + for operation_state in [ + OperationState::Discovering { + operation: Operation::Sync, + message: "Discovering repositories".to_string(), + }, + running_sync_state(), + ] { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + let completed_at = std::time::Instant::now(); + app.operation_state = operation_state; + app.last_status_scan = Some(completed_at); + app.check_results = vec![completed_check()]; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(!app.status_loading); + assert_eq!(app.last_status_scan, Some(completed_at)); + assert_eq!(app.check_results.len(), 1); + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } | OperationState::Running { + operation: Operation::Sync, + .. + } + )); + } +} + +#[tokio::test] +async fn t_key_preserves_in_flight_requirement_checks() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + app.checks_loading = true; + app.check_results = vec![completed_check()]; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(app.status_loading); + assert!(app.checks_loading); + assert_eq!(app.check_results.len(), 1); +} + +#[tokio::test] +async fn s_key_waits_for_active_status_refresh() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + app.status_loading = true; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert_eq!( + app.error_message.as_deref(), + Some("Status refresh is still running; try again when it completes") + ); +} + +#[test] +fn status_refresh_renders_animated_spinner_instead_of_key_hint() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + app.status_loading = true; + app.tick_count = 0; + + let first_frame = render_output(&mut app); + assert!(first_frame.contains("⠋ Refreshing...")); + assert!(!first_frame.contains("[t] Refresh")); + + app.tick_count = 1; + let second_frame = render_output(&mut app); + assert!(second_frame.contains("⠙ Refreshing...")); + assert!(!second_frame.contains("[t] Refresh")); + assert_ne!(first_frame, second_frame); +} + #[tokio::test] async fn dashboard_s_starts_sync_without_opening_popup() { let mut app = build_app(); diff --git a/crates/git-same-cli/src/tui/widgets/mod.rs b/crates/git-same-cli/src/tui/widgets/mod.rs index 9273244..edc9450 100644 --- a/crates/git-same-cli/src/tui/widgets/mod.rs +++ b/crates/git-same-cli/src/tui/widgets/mod.rs @@ -1,4 +1,5 @@ //! Reusable TUI widgets. pub mod repo_table; +pub mod spinner; pub mod status_bar; diff --git a/crates/git-same-cli/src/tui/widgets/spinner.rs b/crates/git-same-cli/src/tui/widgets/spinner.rs new file mode 100644 index 0000000..ccd8fa7 --- /dev/null +++ b/crates/git-same-cli/src/tui/widgets/spinner.rs @@ -0,0 +1,12 @@ +//! Shared animated spinner frames for TUI screens. + +const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/// Return the spinner frame for the current application tick. +pub(crate) fn frame(tick_count: u64) -> char { + FRAMES[(tick_count % FRAMES.len() as u64) as usize] +} + +#[cfg(test)] +#[path = "spinner_tests.rs"] +mod tests; diff --git a/crates/git-same-cli/src/tui/widgets/spinner_tests.rs b/crates/git-same-cli/src/tui/widgets/spinner_tests.rs new file mode 100644 index 0000000..4c486d7 --- /dev/null +++ b/crates/git-same-cli/src/tui/widgets/spinner_tests.rs @@ -0,0 +1,14 @@ +use super::*; + +#[test] +fn spinner_advances_through_braille_frames() { + let rendered: String = (0..10).map(frame).collect(); + + assert_eq!(rendered, "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"); +} + +#[test] +fn spinner_wraps_after_the_last_frame() { + assert_eq!(frame(10), frame(0)); + assert_eq!(frame(11), frame(1)); +} From e1d2c1a390964f2ab0ce60a2be165acb5559e986 Mon Sep 17 00:00:00 2001 From: Manuel Date: Tue, 11 Aug 2026 10:39:29 +0200 Subject: [PATCH 12/21] Create legacy socket symlink on first start for dev fallback --- crates/git-same-core/src/ipc/status_file.rs | 12 +++------ .../src/ipc/status_file_tests.rs | 26 ++++++++----------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/crates/git-same-core/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs index 118987d..e501b45 100644 --- a/crates/git-same-core/src/ipc/status_file.rs +++ b/crates/git-same-core/src/ipc/status_file.rs @@ -197,8 +197,8 @@ fn write_atomic(path: &Path, json: &str) -> Result<(), AppError> { /// /// Idempotent. If a legacy regular file already exists at the destination, it /// is renamed aside as `.user-saved-` and a `warn` log -/// line is emitted, then the symlink is created. If the legacy directory -/// itself does not exist (fresh install), this is a no-op. +/// line is emitted, then the symlink is created. If the legacy directory does +/// not exist, it is created so fresh installs receive the socket symlink. /// /// Pre-existing 3.x users had the monitor writing to `~/.config/git-same/finder/` /// and the FinderSync extension reading from it via an absolute-path entitlement @@ -218,13 +218,9 @@ pub fn ensure_legacy_symlinks(group_dir: &Path) -> Result<(), AppError> { /// can exercise it against a controlled directory. #[cfg(target_os = "macos")] fn ensure_legacy_symlinks_in(legacy_dir: &Path, group_dir: &Path) -> Result<(), AppError> { - if !legacy_dir.exists() { - // Fresh install (no XDG config dir at all yet); nothing to migrate. - return Ok(()); - } - // Only the socket is symlinked; status.json is a real mirror file written - // by the monitor (see the doc comment on `ensure_legacy_symlinks`). + // by the monitor (see the doc comment on `ensure_legacy_symlinks`). The + // socket helper creates the legacy directory as needed. let legacy_sock = legacy_dir.join("finder.sock"); let target_sock = group_dir.join("finder.sock"); ensure_one_symlink(&legacy_sock, &target_sock) diff --git a/crates/git-same-core/src/ipc/status_file_tests.rs b/crates/git-same-core/src/ipc/status_file_tests.rs index 88e3a22..c81b36e 100644 --- a/crates/git-same-core/src/ipc/status_file_tests.rs +++ b/crates/git-same-core/src/ipc/status_file_tests.rs @@ -323,20 +323,16 @@ mod symlink_helper { } #[test] - fn ensure_legacy_symlinks_is_noop_when_legacy_dir_missing() { - // Use a non-existent legacy dir override path: we can't easily inject - // a custom legacy dir into the public helper, so we exercise the - // private one with a known-missing legacy path. - let (_root, _legacy, group) = dirs(); - let missing_legacy_file = - PathBuf::from("/nonexistent/path/that/should/not/exist/status.json"); - // ensure_one_symlink should still happily create a symlink if the - // parent can be created; we sanity-check by NOT creating the parent - // and asserting we get an error rather than a crash. - // (Linux/macOS will fail at `create_dir_all` for a path we cannot - // write to.) - let _ = ensure_one_symlink(&missing_legacy_file, &group.join("status.json")); - // No assertion about success/failure here; the point is just that - // the helper does not panic on unexpected inputs. + fn ensure_legacy_symlinks_creates_socket_when_legacy_dir_missing() { + let (_root, legacy, group) = dirs(); + let missing_legacy = legacy.join("finder"); + assert!(!missing_legacy.exists()); + + ensure_legacy_symlinks_in(&missing_legacy, &group).unwrap(); + + let sock = missing_legacy.join("finder.sock"); + let sock_meta = fs::symlink_metadata(&sock).unwrap(); + assert!(sock_meta.file_type().is_symlink()); + assert_eq!(fs::read_link(&sock).unwrap(), group.join("finder.sock")); } } From 2b7a3d110c0c44f783fec0fb22f9ebcfc429be03 Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 9 Sep 2026 10:56:06 +0200 Subject: [PATCH 13/21] Run monitor as app identity so FDA gates badges Route the monitor LaunchAgent through Git-Same.app's main executable (headless `git-same-app monitor`) so one Full Disk Access grant covers both the app and the monitor. The helper at Contents/Helpers/git-same was a separate, path-based TCC identity that the grant never reached, so the monitor kept prompting for Desktop, Documents, Downloads, and volume access no matter what the user granted. Replace the "zero repos" Full Disk Access heuristic with a real probe (opening the user TCC database; silent, never prompts). The monitor stamps its own answer into status.json, the app reports host and monitor answers, and enable_finder_extension refuses to set the pluginkit election until the gate passes. The badge checklist now runs monitor, FDA, installed, enable; permissions re-probe on window focus; a lagging monitor is restarted once after the grant lands; the app re-renders an installed agent that still execs the helper. Shared monitor shim pieces (Options::from_config, the shutdown signal) move into git-same-core so the CLI and app use the same code. The cask renders the new agent path, the bundle Info.plist gains usage strings, and the dead Banner.svelte is removed. --- .claude/CLAUDE.md | 9 +- Cargo.lock | 2 + crates/git-same-app/Cargo.toml | 2 + crates/git-same-app/src/commands.rs | 163 +++++++++++++++--- crates/git-same-app/src/commands_tests.rs | 130 ++++++++++---- crates/git-same-app/src/main.rs | 14 +- crates/git-same-app/src/monitor_mode.rs | 95 ++++++++++ crates/git-same-app/src/monitor_mode_tests.rs | 37 ++++ crates/git-same-app/ui/src/App.svelte | 22 ++- .../ui/src/lib/StatusBanner.svelte | 45 ++--- .../git-same-app/ui/src/lib/systemSettings.ts | 7 + crates/git-same-app/ui/src/lib/tauri.ts | 13 ++ crates/git-same-app/ui/src/lib/types.ts | 14 ++ .../ui/src/routes/FinderBadges.svelte | 129 ++++++++++---- .../ui/src/routes/Requirements.svelte | 6 +- .../git-same-app/ui/src/stores/status.test.ts | 5 + crates/git-same-app/ui/src/stores/status.ts | 64 ++++++- crates/git-same-cli/src/commands/monitor.rs | 107 ++---------- .../src/commands/monitor_tests.rs | 10 -- crates/git-same-core/src/api/service.rs | 9 +- .../src/macos/full_disk_access.rs | 94 ++++++++++ .../src/macos/full_disk_access_tests.rs | 53 ++++++ crates/git-same-core/src/macos/mod.rs | 10 +- .../src/macos/monitor_agent/controller.rs | 84 +++++++-- .../macos/monitor_agent/controller_tests.rs | 87 ++++++++-- .../src/macos/monitor_agent/install.rs | 52 ++++-- .../src/macos/monitor_agent/plist.rs | 10 +- .../src/macos/monitor_agent/plist_tests.rs | 36 +++- .../src/macos/monitor_agent/source.rs | 92 ++++++++-- .../src/macos/monitor_agent/source_tests.rs | 57 +++++- crates/git-same-core/src/monitor/managed.rs | 68 ++++++++ crates/git-same-core/src/monitor/mod.rs | 4 +- crates/git-same-core/src/monitor/run.rs | 41 +++++ crates/git-same-core/src/monitor/run_tests.rs | 21 +++ .../git-same-core/src/types/finder_status.rs | 10 +- .../src/types/finder_status_tests.rs | 25 +++ docs/README.md | 11 ++ .../GitSameBadges/GitSameBadges.entitlements | 16 +- toolkit/homebrew/cask.rb.tmpl | 12 +- toolkit/packaging/macos/build-app-bundle.sh | 5 + 40 files changed, 1360 insertions(+), 311 deletions(-) create mode 100644 crates/git-same-app/src/monitor_mode.rs create mode 100644 crates/git-same-app/src/monitor_mode_tests.rs create mode 100644 crates/git-same-app/ui/src/lib/systemSettings.ts create mode 100644 crates/git-same-core/src/macos/full_disk_access.rs create mode 100644 crates/git-same-core/src/macos/full_disk_access_tests.rs create mode 100644 crates/git-same-core/src/monitor/managed.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 668f198..ca39a1a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -43,7 +43,10 @@ Git-Same is a Rust CLI + TUI + macOS Tauri app that discovers GitHub org/repo st **Commands:** `init`, `setup`, `sync`, `status`, `scan`, `workspace {list,default}`, `reset`, `monitor` (alias: `daemon`), `refresh`. -**Why `monitor` is a CLI subcommand and not solely a Tauri-host responsibility:** the LaunchAgent invokes the managed helper as `git-same monitor --foreground --managed`, the cask installs it through the hidden `--install-agent` / `--remove-agent` modes, non-cask installs (`cargo install`, the homebrew formula) ship only the binary, `--status` / `--start` / `--stop` / `--uninstall` are the supported control surface, and a future Linux file-manager extension would talk to the same `gisa monitor` over the same Unix socket. The CLI handler is a thin shim (~140 lines); the loop itself lives in `git-same-core::monitor`. +**Why `monitor` is a CLI subcommand and not solely a Tauri-host responsibility:** the LaunchAgent invokes the managed program as ` monitor --foreground --managed`, the cask installs it through the hidden `--install-agent` / `--remove-agent` modes, non-cask installs (`cargo install`, the homebrew formula) ship only the binary, `--status` / `--start` / `--stop` / `--uninstall` are the supported control surface, and a future Linux file-manager extension would talk to the same `gisa monitor` over the same Unix socket. The CLI handler is a thin shim; the loop itself lives in `git-same-core::monitor`, and `monitor::Options::from_config` / `monitor::default_shutdown_signal` are shared with the app. + +**Why an app-owned LaunchAgent execs `Git-Same.app/Contents/MacOS/git-same-app` and not the helper copy:** macOS TCC attributes a launchd-spawned process to its bundle only when the executable is the bundle's `CFBundleExecutable`. A copy of `Contents/Helpers/git-same` under `~/Library/Application Support` is a separate, path-based TCC identity, so a Full Disk Access grant for "Git-Same" never reaches a monitor started through it. `monitor_agent::source` therefore classifies an app-bundle caller as an **in-place** install: nothing is copied, and the rendered plist names the bundle executable. `crates/git-same-app/src/monitor_mode.rs` runs the same core loop headlessly (no Tauri, no window, no Dock icon) when argv[1] is `monitor`. CLI owners (cargo, the formula) keep the copy-into-managed-root install and its path-based identity. The expected program is derived from `owner_kind`, not persisted, so an agent installed by an older build is re-rendered onto the bundle executable the next time the app runs its startup recovery. + ### Engine modules (`crates/git-same-core/src/`) @@ -114,7 +117,9 @@ Three non-obvious traps in `macos/GitSameBadges/`. Each one silently breaks badg 3. **Google Drive's FinderSync poisons the badge-rendering pipeline.** When `com.google.drivefs.finderhelper.findersync` is enabled, peer FinderSync extensions render no badge image even after Finder calls `setBadgeIdentifier`. Confirmed in this environment: badges only began appearing after the user disabled Google Drive in System Settings → Login Items & Extensions. Other peers (Keka, Synology, Dropbox) coexist fine. There is no code fix; document the workaround and surface it in the in-app self-check if you can. -`scan_roots` and `show_ambient`: defaults are `["~"]` / `false`. Never re-enable `show_ambient = true` with `~` in `scan_roots`: Finder refuses to call `requestBadgeIdentifier` on extensions whose `directoryURLs` contain the home folder (separate issue from the three above). +4. **Full Disk Access is per executable, and the extension is not the process that needs it.** The appex does zero I/O outside its app-group container and has never triggered a TCC prompt. The monitor is what walks workspace roots, reads `/Volumes`, watches FSEvents, and writes `Icon\r`, so it is the process that needs FDA. `git-same-core::macos::full_disk_access::probe()` (opens the user TCC.db; success proves the grant, EPERM means denied, never prompts) is stamped into `status.json` as `full_disk_access` by the monitor and read by the app, whose `enable_finder_extension` command refuses to set the pluginkit election until the gate passes. macOS applies a new grant on process start: the app must be relaunched and the monitor restarted (the app does the latter automatically). + +`scan_roots` and `show_ambient`: defaults are `["~"]` / `false`. Never re-enable `show_ambient = true` with `~` in `scan_roots`: Finder refuses to call `requestBadgeIdentifier` on extensions whose `directoryURLs` contain the home folder (separate issue from the four above). ## Workspace folder branding (macOS) diff --git a/Cargo.lock b/Cargo.lock index dc2968c..f9325fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1630,6 +1630,8 @@ dependencies = [ "time", "tokio", "toml 1.1.6+spec-1.1.0", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/crates/git-same-app/Cargo.toml b/crates/git-same-app/Cargo.toml index af8aab0..1352286 100644 --- a/crates/git-same-app/Cargo.toml +++ b/crates/git-same-app/Cargo.toml @@ -32,6 +32,8 @@ time = ">=0.3, <0.3.52" tauri-plugin-dialog = "2" tokio = { workspace = true } toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 7676a6f..fcca18b 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -11,6 +11,7 @@ use git_same_core::domain::RepoPathTemplate; use git_same_core::errors::{AppError, MonitorAgentError}; use git_same_core::ipc::{remove_symlink_if_present, IpcConfig, StatusFileWriter}; use git_same_core::macos::folder_icon; +use git_same_core::macos::full_disk_access::{self, FullDiskAccess}; use git_same_core::macos::monitor_agent::{self, MonitorAgentState, MonitorAgentStatus}; use git_same_core::progress::{ProgressEvent, ProgressReporter}; use git_same_core::provider::{create_provider, NoProgress}; @@ -23,6 +24,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, SystemTime}; @@ -235,6 +237,22 @@ pub struct ExtensionStatus { /// `git_same_core::macos::monitor_agent`; this crate only adapts it. pub type MonitorLaunchAgentStatusDto = MonitorAgentStatus; +/// Full Disk Access as seen by the host and by the monitor. TCC keys the +/// grant on the executable, so both answers are reported and `granted` is +/// the gate the badge setup flow uses (see `fda_gate_passes`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FullDiskAccessDto { + /// This app process's own probe: `granted`, `denied`, `unknown`, or + /// `not_applicable`. + pub host: String, + /// The monitor's stamped answer from `status.json`, when it wrote one. + pub monitor: Option, + /// Whether that status is fresh; a stale monitor may predate a grant. + pub monitor_fresh: bool, + /// Whether Finder badges may be enabled. + pub granted: bool, +} + #[derive(Debug, Clone, Serialize)] pub struct SyncProgressPayload { pub workspace_id: String, @@ -716,6 +734,121 @@ fn is_openable(url: &str) -> bool { .any(|scheme| lower.starts_with(scheme) && url.len() > scheme.len()) } +/// Enable the Finder badge extension, refusing until Full Disk Access is +/// granted: without it the monitor cannot read protected folders and the +/// badges would silently stay blank. The gate lives here, not only in the UI, +/// so no frontend path can bypass it. +#[tauri::command] +pub fn enable_finder_extension(ipc: tauri::State<'_, HostIpc>) -> Result { + let fda = full_disk_access_status_inner(&ipc.0); + if !fda.granted { + return Err("Grant Full Disk Access to Git-Same before enabling Finder badges".to_string()); + } + set_extension_election(ExtensionElection::Use).map_err(|error| error.to_string())?; + extension_status() +} + +#[tauri::command] +pub fn disable_finder_extension() -> Result { + set_extension_election(ExtensionElection::Ignore).map_err(|error| error.to_string())?; + extension_status() +} + +#[tauri::command] +pub fn full_disk_access_status( + ipc: tauri::State<'_, HostIpc>, +) -> Result { + Ok(full_disk_access_status_inner(&ipc.0)) +} + +fn full_disk_access_status_inner(ipc: &IpcConfig) -> FullDiskAccessDto { + let snapshot = read_status_snapshot_with(ipc).ok(); + full_disk_access_dto(full_disk_access::probe(), snapshot.as_ref()) +} + +fn full_disk_access_dto( + host: FullDiskAccess, + snapshot: Option<&StatusSnapshot>, +) -> FullDiskAccessDto { + let monitor_fresh = snapshot.is_some_and(|snapshot| !snapshot.stale); + let monitor = snapshot + .and_then(|snapshot| snapshot.status.as_ref()) + .and_then(|status| status.full_disk_access); + FullDiskAccessDto { + host: host.as_str().to_string(), + monitor, + monitor_fresh, + granted: fda_gate_passes(host, monitor, monitor_fresh), + } +} + +/// The badge-setup gate. A fresh monitor's own answer wins because TCC keys +/// the grant on the monitor executable; otherwise fall back to this process's +/// probe (the same identity once the LaunchAgent runs the app executable). +/// Only a definite "granted" passes; unknown never does. +fn fda_gate_passes(host: FullDiskAccess, monitor: Option, monitor_fresh: bool) -> bool { + match (monitor_fresh, monitor) { + (true, Some(granted)) => granted, + _ => host == FullDiskAccess::Granted, + } +} + +fn full_disk_access_message(fda: &FullDiskAccessDto) -> String { + match (fda.granted, fda.host.as_str(), fda.monitor) { + (true, _, _) => "granted to Git-Same", + (false, "granted", Some(false)) => { + "granted to the app, but the running monitor lacks it (restart the monitor)" + } + (false, "not_applicable", _) => "not applicable on this platform", + (false, "unknown", None) => "could not be determined", + _ => "not granted (required for Finder badges)", + } + .to_string() +} + +/// `pluginkit -e `: the user election macOS stores for an app +/// extension. `use` is what the System Settings toggle sets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExtensionElection { + Use, + Ignore, +} + +impl ExtensionElection { + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + fn pluginkit_arg(self) -> &'static str { + match self { + Self::Use => "use", + Self::Ignore => "ignore", + } + } +} + +fn set_extension_election(election: ExtensionElection) -> Result<(), AppError> { + #[cfg(target_os = "macos")] + { + let output = Command::new("/usr/bin/pluginkit") + .args(["-e", election.pluginkit_arg(), "-i", FINDER_EXTENSION_ID]) + .output() + .map_err(|error| AppError::config(format!("pluginkit invocation failed: {error}")))?; + if output.status.success() { + return Ok(()); + } + Err(AppError::config(format!( + "pluginkit -e {} failed: {}", + election.pluginkit_arg(), + String::from_utf8_lossy(&output.stderr).trim() + ))) + } + #[cfg(not(target_os = "macos"))] + { + let _ = election; + Err(AppError::config( + "Finder extensions are only available on macOS", + )) + } +} + #[tauri::command] pub fn open_url(url: String) -> Result<(), String> { if !is_openable(&url) { @@ -1026,17 +1159,15 @@ fn app_requirement_checks(ipc: &IpcConfig) -> Vec { critical: false, }); - let fda_needed = full_disk_access_needed(monitor_agent.as_ref(), snapshot.as_ref()); + let fda = full_disk_access_dto(full_disk_access::probe(), snapshot.as_ref()); checks.push(RequirementCheckDto { name: "Full Disk Access".to_string(), - passed: !fda_needed, - message: if fda_needed { - "no repositories visible to the monitor".to_string() - } else { - "not currently required".to_string() - }, - suggestion: fda_needed - .then(|| "Grant Full Disk Access to Git-Same in System Settings".to_string()), + passed: fda.granted, + message: full_disk_access_message(&fda), + suggestion: (!fda.granted).then(|| { + "Grant Full Disk Access to Git-Same in System Settings, then quit and reopen the app" + .to_string() + }), critical: false, }); @@ -1128,20 +1259,6 @@ fn monitor_requirement_suggestion( } } -/// An empty repository list only suggests a permission problem once the -/// current monitor process has completed a scan. Before that (first scan in -/// progress, or data left by a previous process) it means nothing. -fn full_disk_access_needed( - agent: Option<&MonitorLaunchAgentStatusDto>, - snapshot: Option<&StatusSnapshot>, -) -> bool { - let scan_completed = agent.is_some_and(|agent| agent.state == MonitorAgentState::Running); - scan_completed - && snapshot - .and_then(|snapshot| snapshot.status.as_ref()) - .is_some_and(|status| !status.workspaces.is_empty() && status.repos.is_empty()) -} - async fn read_workspace_structure_inner( workspace_id: String, ) -> Result { diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index 8c37502..bc96544 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -139,35 +139,6 @@ fn monitor_requirement_prefers_the_concrete_error_detail() { ); } -fn empty_scan_snapshot() -> StatusSnapshot { - let mut status = FinderStatus::new(1, chrono::Utc::now().to_rfc3339()); - status.workspaces = vec![git_same_core::types::FinderWorkspaceInfo { - name: "work".to_string(), - root: std::path::PathBuf::from("/tmp/work"), - orgs: Vec::new(), - }]; - StatusSnapshot { - status_path: String::new(), - updated_at: None, - stale: false, - status: Some(status), - } -} - -#[test] -fn no_permission_warning_while_the_first_scan_is_running() { - let snapshot = empty_scan_snapshot(); - assert!(!full_disk_access_needed( - Some(&agent_in(MonitorAgentState::Starting)), - Some(&snapshot) - )); - assert!(!full_disk_access_needed(None, Some(&snapshot))); - assert!(full_disk_access_needed( - Some(&agent_in(MonitorAgentState::Running)), - Some(&snapshot) - )); -} - #[test] fn read_status_snapshot_returns_none_when_status_file_is_missing() { let temp = TestDir::new("missing-status"); @@ -693,3 +664,104 @@ fn monitor_requirement_fails_pass_on_version_skew() { "3.2.0" )); } + +#[test] +fn extension_election_maps_to_pluginkit_verbs() { + assert_eq!(ExtensionElection::Use.pluginkit_arg(), "use"); + assert_eq!(ExtensionElection::Ignore.pluginkit_arg(), "ignore"); +} + +fn snapshot_with_fda(monitor: Option, stale: bool) -> StatusSnapshot { + let mut status = FinderStatus::new(4242, "2026-07-07T00:00:00Z".to_string()); + status.full_disk_access = monitor; + StatusSnapshot { + status_path: "/tmp/status.json".to_string(), + updated_at: Some("2026-07-07T00:00:00Z".to_string()), + stale, + status: Some(status), + } +} + +#[test] +fn fda_gate_prefers_a_fresh_monitor_answer() { + // The monitor holds the grant even though this process does not (for + // example a dev build): badges can render, so the gate passes. + assert!(fda_gate_passes(FullDiskAccess::Denied, Some(true), true)); + // The monitor lacks the grant even though this process has it (grant + // landed after the monitor started): badges would stay blank. + assert!(!fda_gate_passes(FullDiskAccess::Granted, Some(false), true)); +} + +#[test] +fn fda_gate_falls_back_to_the_host_probe_without_a_fresh_monitor() { + assert!(fda_gate_passes(FullDiskAccess::Granted, None, true)); + assert!(fda_gate_passes(FullDiskAccess::Granted, Some(false), false)); + assert!(!fda_gate_passes(FullDiskAccess::Denied, None, false)); + // Unknown never passes: the gate must not enable badges on a guess. + assert!(!fda_gate_passes(FullDiskAccess::Unknown, None, true)); + assert!(!fda_gate_passes(FullDiskAccess::NotApplicable, None, false)); +} + +#[test] +fn full_disk_access_dto_reports_both_identities() { + let stale_snapshot = snapshot_with_fda(Some(false), true); + + let dto = full_disk_access_dto(FullDiskAccess::Granted, Some(&stale_snapshot)); + + assert_eq!(dto.host, "granted"); + assert_eq!(dto.monitor, Some(false)); + assert!(!dto.monitor_fresh); + // Stale monitor: the host probe decides. + assert!(dto.granted); + + let fresh_snapshot = snapshot_with_fda(Some(false), false); + let dto = full_disk_access_dto(FullDiskAccess::Granted, Some(&fresh_snapshot)); + assert!(dto.monitor_fresh); + // Fresh monitor without the grant: its answer wins. + assert!(!dto.granted); + + let dto = full_disk_access_dto(FullDiskAccess::Denied, None); + assert_eq!(dto.host, "denied"); + assert_eq!(dto.monitor, None); + assert!(!dto.monitor_fresh); + assert!(!dto.granted); +} + +#[test] +fn full_disk_access_message_explains_each_state() { + let granted = full_disk_access_dto(FullDiskAccess::Granted, None); + assert_eq!(full_disk_access_message(&granted), "granted to Git-Same"); + + let stale_monitor = full_disk_access_dto( + FullDiskAccess::Granted, + Some(&snapshot_with_fda(Some(false), true)), + ); + assert!( + stale_monitor.granted, + "stale monitor must not block the host grant" + ); + + let fresh_lagging_monitor = full_disk_access_dto( + FullDiskAccess::Granted, + Some(&snapshot_with_fda(Some(false), false)), + ); + assert!(full_disk_access_message(&fresh_lagging_monitor).contains("restart the monitor")); + + let denied = full_disk_access_dto(FullDiskAccess::Denied, None); + assert_eq!( + full_disk_access_message(&denied), + "not granted (required for Finder badges)" + ); + + let unknown = full_disk_access_dto(FullDiskAccess::Unknown, None); + assert_eq!( + full_disk_access_message(&unknown), + "could not be determined" + ); + + let not_applicable = full_disk_access_dto(FullDiskAccess::NotApplicable, None); + assert_eq!( + full_disk_access_message(¬_applicable), + "not applicable on this platform" + ); +} diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index 3321de0..353d82c 100644 --- a/crates/git-same-app/src/main.rs +++ b/crates/git-same-app/src/main.rs @@ -1,9 +1,18 @@ mod commands; +mod monitor_mode; mod status_stream; use tauri::Manager; fn main() { + // Headless monitor mode: the LaunchAgent runs this executable so the + // monitor shares the app bundle's TCC identity (one Full Disk Access grant + // covers app and monitor). Must run before any Tauri/AppKit initialisation + // so no window or Dock icon appears. + if monitor_mode::is_monitor_invocation(std::env::args_os()) { + std::process::exit(monitor_mode::run()); + } + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ @@ -28,6 +37,9 @@ fn main() { commands::read_status, commands::start_sync, commands::extension_status, + commands::enable_finder_extension, + commands::disable_finder_extension, + commands::full_disk_access_status, commands::open_url, ]) .manage(commands::MonitorStatusCache::default()) @@ -56,7 +68,7 @@ fn main() { .map(|meta| meta.file_type().is_symlink()) .unwrap_or(false); if host_status_is_symlink { - std::thread::spawn(|| { + std::thread::spawn(move || { if let Err(error) = commands::restart_monitor_if_installed() { eprintln!("failed to restart monitor after upgrade: {error}"); } diff --git a/crates/git-same-app/src/monitor_mode.rs b/crates/git-same-app/src/monitor_mode.rs new file mode 100644 index 0000000..8cb2977 --- /dev/null +++ b/crates/git-same-app/src/monitor_mode.rs @@ -0,0 +1,95 @@ +//! Headless monitor mode for the app binary. +//! +//! An app-owned LaunchAgent runs `Git-Same.app/Contents/MacOS/git-same-app +//! monitor --foreground --managed` instead of a copy of the CLI helper. +//! macOS TCC attributes a launchd-spawned process to its bundle only when the +//! executable is the bundle's `CFBundleExecutable`, so running the loop here +//! is what lets one Full Disk Access grant for "Git-Same" cover the monitor +//! too. A copied helper is a separate, path-based TCC identity that the grant +//! never reaches. +//! +//! Nothing Tauri or AppKit is touched on this path: no window, no Dock icon. +//! The loop and the managed-startup rules both live in +//! `git_same_core::monitor`; this file is the same thin shim the CLI +//! `monitor` subcommand is. + +use git_same_core::config::Config; +use git_same_core::errors::{AppError, Result}; +use git_same_core::ipc::IpcConfig; +use git_same_core::monitor; +use git_same_core::output::{Output, Verbosity}; +use std::ffi::OsStr; + +/// Whether argv selects monitor mode: `git-same-app monitor [flags]`. +/// Only the first argument is inspected. +pub(crate) fn is_monitor_invocation(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter() + .nth(1) + .is_some_and(|arg| arg.as_ref() == "monitor") +} + +/// Whether argv carries `--managed`, which launchd's plist always passes. +/// Without it the process is a monitor a user started by hand. +fn is_managed(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter().any(|arg| arg.as_ref() == "--managed") +} + +/// Run the monitor loop until SIGTERM or SIGINT. Returns the process exit +/// code: launchd's `KeepAlive` restarts the agent on a non-zero exit. +pub(crate) fn run() -> i32 { + init_logging(); + match run_inner(is_managed(std::env::args_os())) { + Ok(()) => 0, + Err(error) => { + eprintln!("git-same-app monitor: {error}"); + 1 + } + } +} + +fn run_inner(managed: bool) -> Result<()> { + let output = Output::new(Verbosity::Quiet, false); + let runtime = tokio::runtime::Runtime::new() + .map_err(|error| AppError::config(format!("tokio runtime init failed: {error}")))?; + if managed { + // Identical startup rules to `gisa monitor --foreground --managed`: + // respect the stored preference, never rewrite a broken config, and + // exit successfully on anything a restart cannot fix. + return runtime.block_on(monitor::run_managed(&output)); + } + let config = Config::load()?; + let ipc_config = IpcConfig::default_path()?; + ipc_config.ensure_dir()?; + let opts = monitor::Options::from_config(&config, ipc_config, None); + runtime.block_on(monitor::run( + &config, + &output, + opts, + monitor::default_shutdown_signal(), + )) +} + +/// Same `GISA_LOG` contract as the CLI (`crates/git-same-cli/src/main.rs`): +/// the env filter selects the level, default `warn`, written to stderr so +/// launchd's `StandardErrorPath` captures it. +fn init_logging() { + use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + + let filter = EnvFilter::try_from_env("GISA_LOG").unwrap_or_else(|_| EnvFilter::new("warn")); + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().with_writer(std::io::stderr)) + .init(); +} + +#[cfg(test)] +#[path = "monitor_mode_tests.rs"] +mod tests; diff --git a/crates/git-same-app/src/monitor_mode_tests.rs b/crates/git-same-app/src/monitor_mode_tests.rs new file mode 100644 index 0000000..1910377 --- /dev/null +++ b/crates/git-same-app/src/monitor_mode_tests.rs @@ -0,0 +1,37 @@ +use super::*; + +#[test] +fn monitor_invocation_matches_first_argument() { + assert!(is_monitor_invocation(["git-same-app", "monitor"])); + assert!(is_monitor_invocation([ + "/Applications/Git-Same.app/Contents/MacOS/git-same-app", + "monitor", + "--foreground", + ])); +} + +#[test] +fn monitor_invocation_ignores_other_arguments() { + assert!(!is_monitor_invocation(["git-same-app"])); + assert!(!is_monitor_invocation([ + "git-same-app", + "--foreground", + "monitor" + ])); + assert!(!is_monitor_invocation(["git-same-app", "sync"])); + assert!(!is_monitor_invocation(Vec::<&str>::new())); +} + +#[test] +fn managed_flag_selects_the_launchd_startup_rules() { + // What the rendered plist passes. + assert!(is_managed([ + "/Applications/Git-Same.app/Contents/MacOS/git-same-app", + "monitor", + "--foreground", + "--managed", + ])); + // Started by hand: the user sees failures instead of a silent exit 0. + assert!(!is_managed(["git-same-app", "monitor", "--foreground"])); + assert!(!is_managed(["git-same-app", "monitor"])); +} diff --git a/crates/git-same-app/ui/src/App.svelte b/crates/git-same-app/ui/src/App.svelte index 74453e4..ffd5521 100644 --- a/crates/git-same-app/ui/src/App.svelte +++ b/crates/git-same-app/ui/src/App.svelte @@ -5,12 +5,28 @@ import StatusBanner from './lib/StatusBanner.svelte'; import TitleBar from './lib/TitleBar.svelte'; import { loadMonitorStatus, subscribeMonitor } from './stores/monitor'; - import { errorMessage, loading, refresh, subscribePush } from './stores/status'; + import { + errorMessage, + loading, + refresh, + refreshPermissions, + subscribePush, + } from './stores/status'; import { routes } from './routes/router'; let unsubscribe: (() => void) | undefined; let unsubscribeMonitor: (() => void) | undefined; + // The user grants Full Disk Access and enables the extension in System + // Settings, so re-probe whenever the window comes back to the front. + function handleFocus() { + void refreshPermissions(); + } + + function handleVisibility() { + if (document.visibilityState === 'visible') void refreshPermissions(); + } + onMount(() => { void (async () => { try { @@ -25,11 +41,15 @@ loading.set(false); } })(); + window.addEventListener('focus', handleFocus); + document.addEventListener('visibilitychange', handleVisibility); }); onDestroy(() => { unsubscribe?.(); unsubscribeMonitor?.(); + window.removeEventListener('focus', handleFocus); + document.removeEventListener('visibilitychange', handleVisibility); }); diff --git a/crates/git-same-app/ui/src/lib/StatusBanner.svelte b/crates/git-same-app/ui/src/lib/StatusBanner.svelte index ff102a9..722cfa5 100644 --- a/crates/git-same-app/ui/src/lib/StatusBanner.svelte +++ b/crates/git-same-app/ui/src/lib/StatusBanner.svelte @@ -1,21 +1,19 @@ @@ -87,9 +140,14 @@ {row.detail} {#if row.action && !row.passed} - {/if} @@ -264,6 +322,11 @@ font-weight: 700; } + button:disabled { + cursor: not-allowed; + opacity: 0.55; + } + .two-column { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); diff --git a/crates/git-same-app/ui/src/routes/Requirements.svelte b/crates/git-same-app/ui/src/routes/Requirements.svelte index f27c4fe..e2df6f2 100644 --- a/crates/git-same-app/ui/src/routes/Requirements.svelte +++ b/crates/git-same-app/ui/src/routes/Requirements.svelte @@ -10,11 +10,7 @@ requirementsLoading, } from '../stores/status'; import { openUrl } from '../lib/tauri'; - - const EXTENSIONS_URL = - 'x-apple.systempreferences:com.apple.LoginItems-Settings.extension'; - const FDA_URL = - 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'; + import { EXTENSIONS_URL, FDA_URL } from '../lib/systemSettings'; onMount(() => { void loadRequirements(); diff --git a/crates/git-same-app/ui/src/stores/status.test.ts b/crates/git-same-app/ui/src/stores/status.test.ts index 58213d8..e21d8c4 100644 --- a/crates/git-same-app/ui/src/stores/status.test.ts +++ b/crates/git-same-app/ui/src/stores/status.test.ts @@ -7,6 +7,7 @@ const api = vi.hoisted(() => ({ readStatus: vi.fn(), listWorkspaces: vi.fn(), readExtensionStatus: vi.fn(), + readFullDiskAccess: vi.fn(), readAppConfig: vi.fn(), })); @@ -14,6 +15,7 @@ vi.mock('../lib/tauri', () => ({ readStatus: api.readStatus, listWorkspaces: api.listWorkspaces, readExtensionStatus: api.readExtensionStatus, + readFullDiskAccess: api.readFullDiskAccess, readAppConfig: api.readAppConfig, onStatusUpdated: async (callback: (snapshot: StatusSnapshot) => void) => { api.listener = callback; @@ -36,6 +38,8 @@ vi.mock('../lib/tauri', () => ({ saveAppConfig: vi.fn(), setDefaultWorkspace: vi.fn(), startSync: vi.fn(), + enableFinderExtension: vi.fn(), + restartMonitorLaunchAgent: vi.fn(), })); const make = (updated_at: string | null, stale = false): StatusSnapshot => ({ @@ -55,6 +59,7 @@ beforeEach(() => { api.listener = undefined; api.listWorkspaces.mockResolvedValue([]); api.readExtensionStatus.mockResolvedValue(null); + api.readFullDiskAccess.mockResolvedValue(null); api.readAppConfig.mockResolvedValue(null); }); diff --git a/crates/git-same-app/ui/src/stores/status.ts b/crates/git-same-app/ui/src/stores/status.ts index 23236ea..c66606f 100644 --- a/crates/git-same-app/ui/src/stores/status.ts +++ b/crates/git-same-app/ui/src/stores/status.ts @@ -4,13 +4,16 @@ import { createStatusSequencer } from '../lib/monitorPresentation'; import { checkRequirements, deleteWorkspace, + enableFinderExtension, ensureConfig, listWorkspaces, onStatusUpdated, onSyncProgress, readAppConfig, readExtensionStatus, + readFullDiskAccess, readStatus, + restartMonitorLaunchAgent, readWorkspaceStructure, saveAppConfig, setDefaultWorkspace, @@ -20,6 +23,7 @@ import type { AppConfigDto, AppConfigInput, ExtensionStatus, + FullDiskAccessDto, ProgressEvent, RequirementCheckDto, StatusSnapshot, @@ -36,6 +40,7 @@ export const NEW_WORKSPACE_ID = '__new_workspace__'; export const snapshot = writable(null); export const workspaces = writable([]); export const extensionStatus = writable(null); +export const fullDiskAccess = writable(null); export const appConfig = writable(null); export const requirements = writable([]); export const workspaceStructure = writable(null); @@ -70,7 +75,7 @@ const snapshotSequencer = createStatusSequencer(); export async function refresh(): Promise { errorMessage.set(''); const token = snapshotSequencer.beginFetch(); - const [workspaceList, status, ext, config] = await Promise.all([ + const [workspaceList, status, ext, fda, config] = await Promise.all([ listWorkspaces().catch((err) => { errorMessage.set(String(err)); return [] as WorkspaceSummary[]; @@ -80,14 +85,71 @@ export async function refresh(): Promise { return null; }), readExtensionStatus().catch(() => null), + readFullDiskAccess().catch(() => null), readAppConfig().catch(() => null), ]); workspaces.set(workspaceList); const accepted = snapshotSequencer.acceptFetch(token, status); if (accepted !== undefined) snapshot.set(accepted); extensionStatus.set(ext); + fullDiskAccess.set(fda); appConfig.set(config); reconcileSelectedWorkspace(workspaceList); + await kickMonitorIfLagging(fda); +} + +/** + * Re-read only the permission-shaped state (Full Disk Access, extension + * election). Called when the window regains focus so the badge checklist + * reflects what the user just changed in System Settings. + */ +export async function refreshPermissions(): Promise { + const [fda, ext] = await Promise.all([ + readFullDiskAccess().catch(() => null), + readExtensionStatus().catch(() => null), + ]); + fullDiskAccess.set(fda); + extensionStatus.set(ext); + await kickMonitorIfLagging(fda); +} + +// One restart per lag episode: cleared when the monitor reports the grant, +// so a monitor that can never hold it (a helper-identity agent) is not +// restarted in a loop. +let monitorKickPending = false; + +/** + * The app holds Full Disk Access but the running monitor was started before + * the grant landed (macOS applies TCC grants on process start). Restart it + * once so its scans and watchers pick up the grant. + */ +async function kickMonitorIfLagging(fda: FullDiskAccessDto | null): Promise { + if (!fda) return; + if (fda.monitor === true) { + monitorKickPending = false; + return; + } + if (fda.host !== 'granted' || fda.monitor !== false || !fda.monitor_fresh) return; + if (monitorKickPending) return; + monitorKickPending = true; + try { + await restartMonitorLaunchAgent(); + successMessage.set('Full Disk Access granted, monitor restarted'); + } catch (err) { + errorMessage.set(String(err)); + } +} + +/** Enable Finder badges; the backend refuses until Full Disk Access is granted. */ +export async function enableExtension(): Promise { + errorMessage.set(''); + try { + extensionStatus.set(await enableFinderExtension()); + successMessage.set('Finder badges enabled'); + } catch (err) { + errorMessage.set(String(err)); + } + await refreshPermissions(); } export async function loadAppConfig(): Promise { diff --git a/crates/git-same-cli/src/commands/monitor.rs b/crates/git-same-cli/src/commands/monitor.rs index 605b115..3216379 100644 --- a/crates/git-same-cli/src/commands/monitor.rs +++ b/crates/git-same-cli/src/commands/monitor.rs @@ -2,8 +2,9 @@ //! //! The run loop lives in `git_same_core::monitor` and the service lifecycle //! in `git_same_core::macos::monitor_agent`. This file is the CLI surface: -//! pick the mode, adapt output (human text or one JSON object), and build -//! the shutdown future from `ctrl_c` + SIGTERM. +//! pick the mode and adapt output (human text or one JSON object). The +//! options and the shutdown future come from core, so the Tauri app's +//! headless monitor mode runs on exactly the same two helpers. //! //! Control modes are dispatched before any configuration is loaded: stopping //! or inspecting the service must work without a valid repository config. @@ -16,8 +17,7 @@ use git_same_core::macos::monitor_agent::{self, MonitorAgentState, MonitorAgentS use git_same_core::monitor::{self, runtime_guard, MonitorMode, RunContext}; use git_same_core::output::Output; use std::path::Path; -use std::time::Duration; -use tracing::{error, info}; +use tracing::info; /// Run a control or packaging mode. Needs no configuration. pub async fn run_control( @@ -321,7 +321,7 @@ pub async fn run_foreground( output: &Output, ) -> Result<()> { if args.managed { - return run_managed(output).await; + return monitor::run_managed(output).await; } let path = match config_path { @@ -333,99 +333,20 @@ pub async fn run_foreground( let ipc_config = IpcConfig::default_path()?; info!("Starting git-same monitor"); - let interval_secs = resolve_interval_secs(args.interval, config.monitor.fullscan_interval_secs); - let opts = monitor::Options { - interval: Duration::from_secs(interval_secs), - ipc_config, - }; + let opts = monitor::Options::from_config(&config, ipc_config, args.interval); let context = RunContext { mode: MonitorMode::Foreground, config_path: Some(path), interval_explicit: args.interval.is_some(), }; - monitor::run_with(&config, output, opts, context, shutdown_signal()).await -} - -/// launchd restarts the helper after every unsuccessful exit -/// (`KeepAlive = { SuccessfulExit = false }`). Conditions that a restart -/// cannot fix therefore exit successfully after one logged line; only -/// transient failures return an error. -async fn run_managed(output: &Output) -> Result<()> { - let prepared = tokio::task::spawn_blocking(prepare_managed) - .await - .map_err(|e| AppError::Other(anyhow::anyhow!("managed startup task failed: {e}")))?; - let (config, path, ipc_config) = match prepared { - Ok(prepared) => prepared, - Err(reason) => { - error!("{reason}"); - eprintln!("git-same monitor: {reason}"); - return Ok(()); - } - }; - - let opts = monitor::Options { - interval: Duration::from_secs(config.monitor.fullscan_interval_secs), - ipc_config, - }; - let context = RunContext { - mode: MonitorMode::Managed, - config_path: Some(path), - interval_explicit: false, - }; - match monitor::run_with(&config, output, opts, context, shutdown_signal()).await { - Err(AppError::MonitorAgent(MonitorAgentError::AlreadyRunning { pid })) => { - eprintln!("git-same monitor: another monitor is already running ({pid:?}); exiting"); - Ok(()) - } - other => other, - } -} - -/// Checks that must pass before the helper's first side effect. `Err` is a -/// reason to exit successfully without running. -fn prepare_managed() -> std::result::Result<(Config, std::path::PathBuf, IpcConfig), String> { - let controller = monitor_agent::controller_for_current_user(false) - .map_err(|e| format!("not starting: {e}"))?; - match controller.monitoring_enabled() { - Ok(true) => {} - Ok(false) => return Err("monitoring is disabled; not starting".to_string()), - Err(e) => return Err(format!("not starting: {e}")), - } - let paths = controller.paths(); - // Never rewritten, never replaced with defaults. - let config = Config::load_from(&paths.config) - .map_err(|e| format!("not starting until the configuration is fixed: {e}"))?; - Ok((config, paths.config.clone(), paths.ipc.clone())) -} - -/// Resolve the effective polling interval: an explicit `--interval` flag wins, -/// otherwise fall back to the value from `config.toml`. -fn resolve_interval_secs(cli_flag: Option, config_value: u64) -> u64 { - cli_flag.unwrap_or(config_value) -} - -/// Resolve when the user hits ctrl-c (SIGINT) or a stop request sends -/// SIGTERM. Used as the shutdown future for the monitor loop. -async fn shutdown_signal() { - #[cfg(unix)] - { - let mut sigterm = - match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { - Ok(s) => s, - Err(_) => { - let _ = tokio::signal::ctrl_c().await; - return; - } - }; - tokio::select! { - _ = tokio::signal::ctrl_c() => {}, - _ = sigterm.recv() => {}, - } - } - #[cfg(not(unix))] - { - let _ = tokio::signal::ctrl_c().await; - } + monitor::run_with( + &config, + output, + opts, + context, + monitor::default_shutdown_signal(), + ) + .await } #[cfg(test)] diff --git a/crates/git-same-cli/src/commands/monitor_tests.rs b/crates/git-same-cli/src/commands/monitor_tests.rs index e676731..cee9785 100644 --- a/crates/git-same-cli/src/commands/monitor_tests.rs +++ b/crates/git-same-cli/src/commands/monitor_tests.rs @@ -1,16 +1,6 @@ use super::*; use git_same_core::types::FinderStatus; -#[test] -fn cli_flag_overrides_config_interval() { - assert_eq!(resolve_interval_secs(Some(10), 30), 10); -} - -#[test] -fn config_interval_used_when_flag_absent() { - assert_eq!(resolve_interval_secs(None, 90), 90); -} - #[test] fn state_names_match_the_typescript_union() { assert_eq!(state_name(MonitorAgentState::NotInstalled), "not_installed"); diff --git a/crates/git-same-core/src/api/service.rs b/crates/git-same-core/src/api/service.rs index 103fcf7..9925111 100644 --- a/crates/git-same-core/src/api/service.rs +++ b/crates/git-same-core/src/api/service.rs @@ -1,7 +1,7 @@ //! Repository scanning service. //! //! `RepoScanService` is the API for scanning repositories and computing badge -//! status. It owns no state — callers construct it with references to a git +//! status. It owns no state: callers construct it with references to a git //! backend and a config, then invoke `scan_all()`, `scan_workspace()`, or //! `scan_repo()`. @@ -112,7 +112,7 @@ impl<'a> RepoScanService<'a> { orgs: org_names.clone(), }); - // Add org folder entries — scan filesystem for org directories + // Add org folder entries: scan the filesystem for org directories // If orgs list is specified, use it; otherwise discover from directory listing let org_dirs: Vec = if org_names.is_empty() { std::fs::read_dir(&base_path) @@ -183,6 +183,11 @@ impl<'a> RepoScanService<'a> { // mode, since workspace roots can also be browsed through the alias. status.boot_volume_aliases = detect_boot_volume_aliases(); + // Stamp this process's Full Disk Access state. TCC keys the grant on + // the executable, so only the monitor itself can answer whether it may + // read protected folders; hosts read the answer from status.json. + status.full_disk_access = crate::macos::full_disk_access::probe().is_granted(); + // Always publish workspace roots so the extension can register them. for ws in &status.workspaces { if !status.monitored_roots.contains(&ws.root) { diff --git a/crates/git-same-core/src/macos/full_disk_access.rs b/crates/git-same-core/src/macos/full_disk_access.rs new file mode 100644 index 0000000..93f4b28 --- /dev/null +++ b/crates/git-same-core/src/macos/full_disk_access.rs @@ -0,0 +1,94 @@ +//! Probe whether this process holds Full Disk Access (FDA). +//! +//! macOS exposes no API for the `kTCCServiceSystemPolicyAllFiles` grant, so +//! the probe opens a file that every account has and that TCC guards behind +//! FDA: the user's own TCC database. FDA is grant-only (there is no consent +//! dialog), so the open never triggers a prompt and the probe is silent. +//! +//! TCC keys the grant on the calling executable's code identity, so the result +//! describes *this* process. The monitor stamps its own result into +//! `status.json` (the authoritative answer for "can the monitor read protected +//! folders"), and the Tauri host probes its own identity. Running `gisa` from +//! a terminal reports the terminal's grant, not Git-Same's. +//! +//! On non-macOS targets the probe reports [`FullDiskAccess::NotApplicable`]. + +use std::io; + +/// Outcome of a Full Disk Access probe for the current process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FullDiskAccess { + /// The process can read TCC-protected locations without prompting. + Granted, + /// TCC silently denied the probe (EPERM): no grant for this identity. + Denied, + /// The probe could not tell (for example the probe file is missing). + Unknown, + /// Not a macOS build; TCC does not apply. + NotApplicable, +} + +impl FullDiskAccess { + /// `Some(true)` when granted, `Some(false)` when denied, `None` when the + /// state is unknown or not applicable. + pub fn is_granted(self) -> Option { + match self { + Self::Granted => Some(true), + Self::Denied => Some(false), + Self::Unknown | Self::NotApplicable => None, + } + } + + /// Stable lowercase label for serialisation to hosts. + pub fn as_str(self) -> &'static str { + match self { + Self::Granted => "granted", + Self::Denied => "denied", + Self::Unknown => "unknown", + Self::NotApplicable => "not_applicable", + } + } +} + +/// Probe the current process's Full Disk Access state. Never prompts. +pub fn probe() -> FullDiskAccess { + #[cfg(target_os = "macos")] + { + classify(open_probe_file()) + } + #[cfg(not(target_os = "macos"))] + { + FullDiskAccess::NotApplicable + } +} + +/// Open the user TCC database read-only. Success proves FDA; TCC answers with +/// EPERM otherwise. The handle is dropped immediately: nothing is read. +#[cfg(target_os = "macos")] +fn open_probe_file() -> io::Result<()> { + let home = std::env::var_os("HOME") + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?; + let path = std::path::PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("com.apple.TCC") + .join("TCC.db"); + std::fs::File::open(path).map(|_| ()) +} + +/// Map the probe's open result to a grant state. `PermissionDenied` (EPERM) +/// is TCC's silent deny. Any other failure (missing database, unset HOME) +/// cannot distinguish "no grant" from "nothing to probe", so it is reported as +/// unknown rather than denied. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn classify(result: io::Result<()>) -> FullDiskAccess { + match result { + Ok(()) => FullDiskAccess::Granted, + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => FullDiskAccess::Denied, + Err(_) => FullDiskAccess::Unknown, + } +} + +#[cfg(test)] +#[path = "full_disk_access_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/macos/full_disk_access_tests.rs b/crates/git-same-core/src/macos/full_disk_access_tests.rs new file mode 100644 index 0000000..ac9469a --- /dev/null +++ b/crates/git-same-core/src/macos/full_disk_access_tests.rs @@ -0,0 +1,53 @@ +use super::*; + +#[test] +fn classify_maps_success_to_granted() { + assert_eq!(classify(Ok(())), FullDiskAccess::Granted); +} + +#[test] +fn classify_maps_permission_denied_to_denied() { + // TCC answers EPERM, which std maps to PermissionDenied. + let denied = io::Error::from_raw_os_error(1); + assert_eq!(denied.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(classify(Err(denied)), FullDiskAccess::Denied); +} + +#[test] +fn classify_maps_other_errors_to_unknown() { + let missing = io::Error::new(io::ErrorKind::NotFound, "no TCC.db"); + assert_eq!(classify(Err(missing)), FullDiskAccess::Unknown); + let other = io::Error::other("disk on fire"); + assert_eq!(classify(Err(other)), FullDiskAccess::Unknown); +} + +#[test] +fn is_granted_only_answers_for_definite_states() { + assert_eq!(FullDiskAccess::Granted.is_granted(), Some(true)); + assert_eq!(FullDiskAccess::Denied.is_granted(), Some(false)); + assert_eq!(FullDiskAccess::Unknown.is_granted(), None); + assert_eq!(FullDiskAccess::NotApplicable.is_granted(), None); +} + +#[test] +fn as_str_labels_are_stable() { + assert_eq!(FullDiskAccess::Granted.as_str(), "granted"); + assert_eq!(FullDiskAccess::Denied.as_str(), "denied"); + assert_eq!(FullDiskAccess::Unknown.as_str(), "unknown"); + assert_eq!(FullDiskAccess::NotApplicable.as_str(), "not_applicable"); +} + +#[cfg(target_os = "macos")] +#[test] +fn probe_never_reports_not_applicable_on_macos() { + // The actual grant depends on the test runner's TCC identity, so only the + // shape of the answer is asserted: a real macOS probe is never N/A. + assert_ne!(probe(), FullDiskAccess::NotApplicable); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn probe_reports_not_applicable_off_macos() { + assert_eq!(probe(), FullDiskAccess::NotApplicable); + assert_eq!(probe().is_granted(), None); +} diff --git a/crates/git-same-core/src/macos/mod.rs b/crates/git-same-core/src/macos/mod.rs index 4dd67fe..30cd156 100644 --- a/crates/git-same-core/src/macos/mod.rs +++ b/crates/git-same-core/src/macos/mod.rs @@ -1,9 +1,11 @@ //! macOS-only host integration helpers. //! -//! These wrap Cocoa / xattr operations that the FinderSync extension cannot -//! perform from its sandbox — currently only custom workspace folder icons -//! (painted via `NSWorkspace.setIcon`). On non-macOS targets the submodules -//! expose no-op stubs so callers can stay platform-agnostic. +//! These wrap Cocoa / xattr operations and TCC probes that the FinderSync +//! extension cannot perform from its sandbox: custom workspace folder icons +//! (painted via `NSWorkspace.setIcon`) and the Full Disk Access probe. On +//! non-macOS targets the submodules expose no-op stubs so callers can stay +//! platform-agnostic. pub mod folder_icon; +pub mod full_disk_access; pub mod monitor_agent; diff --git a/crates/git-same-core/src/macos/monitor_agent/controller.rs b/crates/git-same-core/src/macos/monitor_agent/controller.rs index 5c49c01..abd3ed6 100644 --- a/crates/git-same-core/src/macos/monitor_agent/controller.rs +++ b/crates/git-same-core/src/macos/monitor_agent/controller.rs @@ -14,7 +14,7 @@ use crate::config::edit::{read_monitor_autostart, set_monitor_autostart}; use crate::errors::MonitorAgentError; use crate::ipc::StatusFileWriter; use crate::monitor::runtime_guard::{MonitorMode, RuntimeIdentity, RuntimeMonitorState}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -113,6 +113,7 @@ impl Controller { }; let launchd = self.launchd(); let record = InstallRecord::load(&self.paths.install_record); + let program = self.program(record.as_ref().ok().and_then(|r| r.as_ref())); let runtime = self.system.monitor_state(&self.paths.ipc); let active = match &runtime { RuntimeMonitorState::Active(identity) => Some(identity.clone()), @@ -121,7 +122,8 @@ impl Controller { let facts = Facts { autostart, launchd_disabled: launchd.is_disabled(LABEL)?, - installed: is_executable(&self.paths.helper) && self.paths.launch_agent.exists(), + installed: program_installed(&program, record_of(&record)) + && self.paths.launch_agent.exists(), gui_session: launchd.gui_session_available()?, service: launchd.service(LABEL)?, scan_complete: active @@ -131,15 +133,11 @@ impl Controller { active, }; let state = derive_state(&facts); - let record_ok = record.as_ref().ok().and_then(|r| r.as_ref()); + let record_ok = record_of(&record); let status = MonitorAgentStatus { label: LABEL.to_string(), plist_path: self.paths.launch_agent.display().to_string(), - binary_path: self - .paths - .helper - .exists() - .then(|| self.paths.helper.display().to_string()), + binary_path: program.exists().then(|| program.display().to_string()), installed: facts.installed, loaded: facts.service.loaded, running: facts.active.is_some(), @@ -276,7 +274,7 @@ impl Controller { } let record = InstallRecord::load(&self.paths.install_record)?; - let helper_intact = helper_matches_record(&self.paths.helper, record.as_ref()); + let helper_intact = program_matches_record(&self.program(record.as_ref()), record.as_ref()); let selection = source::select( record.as_ref(), helper_intact, @@ -296,7 +294,10 @@ impl Controller { } } Selection::Keep => { - let expected = self.render_plist(record.as_ref().map(|r| r.owner_kind))?; + let expected = self.render_plist( + &self.program(record.as_ref()), + record.as_ref().map(|r| r.owner_kind), + )?; let plist_current = std::fs::read_to_string(&self.paths.launch_agent) .is_ok_and(|current| current == expected); if !plist_current { @@ -357,11 +358,25 @@ impl Controller { // installation // ------------------------------------------------------------------ - fn render_plist(&self, owner_kind: Option) -> Result { + /// The program launchd runs for the installation described by `record`: + /// the bundle's own executable for an app owner, the managed helper copy + /// otherwise. Derived rather than persisted, so an agent written by an + /// older build that still points at a copied helper is re-rendered onto + /// the bundle executable the next time anything repairs the service. + fn program(&self, record: Option<&InstallRecord>) -> PathBuf { + match record { + Some(record) => { + source::program_for(record.owner_kind, &record.owner_path, &self.paths.helper) + } + None => self.paths.helper.clone(), + } + } + + fn render_plist(&self, program: &Path, owner_kind: Option) -> Result { let associated = owner_kind .is_some_and(OwnerKind::is_app) .then_some(APP_BUNDLE_ID); - plist::render(&self.paths, &self.user.home, associated) + plist::render(&self.paths, program, &self.user.home, associated) } /// Transactional helper replacement. On failure the previous files and @@ -434,7 +449,10 @@ impl Controller { } } self.wait_for_managed_exit()?; - let rendered = self.render_plist(Some(staged.source.owner_kind))?; + let rendered = self.render_plist( + &staged.source.program(&self.paths.helper), + Some(staged.source.owner_kind), + )?; self.installer().activate(staged, &rendered)?; let foreground_active = !matches!( self.system.monitor_state(&self.paths.ipc), @@ -728,13 +746,20 @@ impl Controller { }; if record.owner_kind != OwnerKind::HomebrewCask || record.owner_path != source.owner_path - || !helper_matches_record(&self.paths.helper, Some(&record)) + || !program_matches_record(&self.program(Some(&record)), Some(&record)) { return Ok(false); } let staged_hash = sha256_file(&source.copy_from) .map_err(|e| MonitorAgentError::io("Failed to hash the staged helper", e))?; - let expected = self.render_plist(Some(OwnerKind::HomebrewCask))?; + let expected = self.render_plist( + &source::program_for( + OwnerKind::HomebrewCask, + &record.owner_path, + &self.paths.helper, + ), + Some(OwnerKind::HomebrewCask), + )?; let plist_current = std::fs::read_to_string(&self.paths.launch_agent) .is_ok_and(|current| current == expected); Ok(staged_hash == record.binary_sha256 && plist_current) @@ -819,6 +844,35 @@ fn source_changed(record: &InstallRecord) -> bool { sha256_file(&record.source_binary).is_ok_and(|hash| hash != record.binary_sha256) } +fn record_of(record: &Result>) -> Option<&InstallRecord> { + record.as_ref().ok().and_then(|record| record.as_ref()) +} + +/// Whether the recorded installation is present. +/// +/// A copied helper has to be on disk. An in-place installation names a file +/// inside the owner's bundle that the installer never places itself: during a +/// cask install Homebrew moves the bundle in only after the installer runs, +/// so the plist plus the record is the installation. A bundle that is +/// genuinely gone surfaces as a launchd start failure, not as "not +/// installed". +fn program_installed(program: &Path, record: Option<&InstallRecord>) -> bool { + match record { + Some(record) if record.owner_kind.is_app() => true, + _ => is_executable(program), + } +} + +/// Whether the installed program still matches what was recorded. Used to +/// decide whether an installation needs repairing; see +/// [`program_installed`] for why an absent in-place program is not damage. +fn program_matches_record(program: &Path, record: Option<&InstallRecord>) -> bool { + match record { + Some(record) if record.owner_kind.is_app() && !program.exists() => true, + _ => helper_matches_record(program, record), + } +} + fn helper_matches_record(path: &Path, record: Option<&InstallRecord>) -> bool { if !is_executable(path) { return false; diff --git a/crates/git-same-core/src/macos/monitor_agent/controller_tests.rs b/crates/git-same-core/src/macos/monitor_agent/controller_tests.rs index d4c5a80..8793f83 100644 --- a/crates/git-same-core/src/macos/monitor_agent/controller_tests.rs +++ b/crates/git-same-core/src/macos/monitor_agent/controller_tests.rs @@ -85,13 +85,40 @@ impl Env { .collect() } + /// The program the installed agent runs, per the live install record: + /// the managed copy for a CLI owner, the bundle executable for an app or + /// cask owner, which is never copied anywhere. + fn installed_program(&self) -> Option { + let record = InstallRecord::load(&self.paths.install_record).ok()??; + Some(super::source::program_for( + record.owner_kind, + &record.owner_path, + &self.paths.helper, + )) + } + + /// The agent is installed and names the program the record implies. + fn program_installed(&self) -> bool { + let Some(program) = self.installed_program() else { + return false; + }; + std::fs::read_to_string(&self.paths.launch_agent) + .is_ok_and(|plist| plist.contains(program.to_str().unwrap())) + } + + /// `(staged installer, final app path, retained service tool)`, matching + /// what the cask passes: the installer is the staged bundle's CLI helper, + /// while the program that gets installed is the staged bundle's main + /// executable, run in place under the app's own TCC identity. fn cask_bundle(&self) -> (PathBuf, PathBuf, PathBuf) { - let staged = self - .dir - .path() - .join("Caskroom/git-same/3.1.2/Git-Same.app/Contents/Helpers/git-same"); + let bundle = self.dir.path().join("Caskroom/git-same/3.1.2/Git-Same.app"); + let staged = bundle.join("Contents/Helpers/git-same"); if !staged.exists() { - write_executable(&staged, b"cask helper v1"); + write_executable(&staged, b"cask cli v1"); + } + let staged_app = bundle.join("Contents/MacOS/git-same-app"); + if !staged_app.exists() { + write_executable(&staged_app, b"cask helper v1"); } let app = self.dir.path().join("Applications").join("Git-Same.app"); let tool = self @@ -687,6 +714,16 @@ fn uninstall_removes_the_payload_and_keeps_everything_else() { assert!(env.system.with(|s| s.loaded.is_empty())); } +/// The staged bundle's main executable, given its staged CLI helper. +fn staged_app(staged_cli: &Path) -> PathBuf { + staged_cli + .parent() + .unwrap() + .parent() + .unwrap() + .join("MacOS/git-same-app") +} + // ------------------------------------------------------------------ cask #[test] @@ -702,15 +739,21 @@ fn cask_install_starts_monitoring_without_the_app() { assert!(status.running); assert_eq!(status.owner_kind, Some(OwnerKind::HomebrewCask)); assert_eq!(status.source.as_deref(), Some(app.to_str().unwrap())); - assert_eq!(std::fs::read(&tool).unwrap(), b"cask helper v1"); + assert_eq!(std::fs::read(&tool).unwrap(), b"cask cli v1"); let record = InstallRecord::load(&env.paths.install_record) .unwrap() .unwrap(); assert_eq!( record.source_binary, - app.join("Contents/Helpers/git-same"), + app.join("Contents/MacOS/git-same-app"), "never the staging path" ); + // The monitor runs the bundle executable itself: a copy under the + // managed root would be a TCC identity the app's grant never reaches. + assert!(!env.paths.helper.exists()); + assert!(std::fs::read_to_string(&env.paths.launch_agent) + .unwrap() + .contains(app.join("Contents/MacOS/git-same-app").to_str().unwrap())); assert!(std::fs::read_to_string(&env.paths.launch_agent) .unwrap() .contains("AssociatedBundleIdentifiers")); @@ -724,13 +767,16 @@ fn cask_upgrade_after_a_stop_updates_the_helper_but_stays_stopped() { controller.install_for_cask(&staged, &app, &tool).unwrap(); controller.stop().unwrap(); assert!(controller.remove_for_cask(&app).unwrap()); - write_executable(&staged, b"cask helper v2"); + write_executable(&staged_app(&staged), b"cask helper v2"); env.system.with(|s| s.calls.clear()); let status = controller.install_for_cask(&staged, &app, &tool).unwrap(); assert_eq!(status.state, MonitorAgentState::Disabled); - assert_eq!(std::fs::read(&env.paths.helper).unwrap(), b"cask helper v2"); + assert_eq!( + std::fs::read(staged_app(&staged)).unwrap(), + b"cask helper v2" + ); assert!(env.system.with(|s| s.active.is_none())); let calls = env.system.mutating_calls(); assert!(!calls.iter().any(|c| c.contains("enable")), "{calls:?}"); @@ -746,12 +792,15 @@ fn cask_upgrade_while_enabled_starts_the_new_helper() { assert!(controller.remove_for_cask(&app).unwrap()); assert!(read_monitor_autostart(&env.paths.config).unwrap()); assert!(env.system.with(|s| s.disabled.is_empty())); - write_executable(&staged, b"cask helper v2"); + write_executable(&staged_app(&staged), b"cask helper v2"); let status = controller.install_for_cask(&staged, &app, &tool).unwrap(); assert!(status.running); - assert_eq!(std::fs::read(&env.paths.helper).unwrap(), b"cask helper v2"); + assert_eq!( + std::fs::read(staged_app(&staged)).unwrap(), + b"cask helper v2" + ); } #[test] @@ -762,13 +811,13 @@ fn app_launch_right_after_a_cask_install_changes_nothing() { .install_for_cask(&staged, &app, &tool) .unwrap(); // Homebrew has moved the bundle into place and reopens the app. - let installed_helper = app.join("Contents/Helpers/git-same"); - write_executable(&installed_helper, b"cask helper v1"); + let installed_executable = app.join("Contents/MacOS/git-same-app"); + write_executable(&installed_executable, b"cask helper v1"); let app_caller = HelperSource { owner_kind: OwnerKind::App, owner_path: app.clone(), - source_binary: installed_helper.clone(), - copy_from: installed_helper, + source_binary: installed_executable.clone(), + copy_from: installed_executable, }; let (pid, stamps) = (env.pid(), env.stamps()); env.system.with(|s| s.calls.clear()); @@ -822,7 +871,7 @@ fn cask_removal_preserves_preference_and_disabled_state() { assert!(controller.remove_for_cask(&app).unwrap()); - assert!(!env.paths.helper.exists() && !env.paths.launch_agent.exists()); + assert!(!env.paths.install_record.exists() && !env.paths.launch_agent.exists()); assert!(env.system.with(|s| s.active.is_none())); assert!(read_monitor_autostart(&env.paths.config).unwrap()); let calls = env.system.mutating_calls(); @@ -851,7 +900,7 @@ fn cask_removal_for_a_different_app_path_does_nothing() { let elsewhere = env.dir.path().join("Other/Git-Same.app"); assert!(!controller.remove_for_cask(&elsewhere).unwrap()); - assert!(env.paths.helper.exists()); + assert!(env.program_installed()); } #[test] @@ -872,7 +921,7 @@ fn cask_removal_refuses_to_guess_when_ownership_is_unreadable() { let error = controller.remove_for_cask(&app).unwrap_err(); assert!(matches!(error, MonitorAgentError::OwnershipMismatch(_))); - assert!(env.paths.helper.exists()); + assert!(env.paths.launch_agent.exists()); } #[test] @@ -886,7 +935,7 @@ fn cask_install_with_a_malformed_config_installs_but_does_not_start() { .install_for_cask(&staged, &app, &tool); assert!(result.is_ok(), "brew install must not fail: {result:?}"); - assert!(env.paths.helper.exists()); + assert!(env.program_installed()); assert!(env.system.with(|s| s.active.is_none())); } diff --git a/crates/git-same-core/src/macos/monitor_agent/install.rs b/crates/git-same-core/src/macos/monitor_agent/install.rs index c8464ba..5f68975 100644 --- a/crates/git-same-core/src/macos/monitor_agent/install.rs +++ b/crates/git-same-core/src/macos/monitor_agent/install.rs @@ -26,14 +26,22 @@ struct Transaction { had_helper: bool, had_plist: bool, had_record: bool, + /// The activation ran the source in place and never touched the managed + /// helper, so rollback must leave that file exactly as it found it. + /// Absent in records written before in-place installs existed, where + /// every activation replaced the helper. + #[serde(default)] + in_place: bool, } -/// A verified helper copy waiting to be activated. +/// A verified source waiting to be activated. #[derive(Debug)] pub struct Staged { pub source: HelperSource, pub sha256: String, - temp_helper: PathBuf, + /// The staged copy to move into place, or `None` for an in-place + /// installation, which runs the source where it already lives. + temp_helper: Option, } pub struct Installer<'a> { @@ -59,7 +67,8 @@ impl<'a> Installer<'a> { self.paths.managed_root.join("install.rollback.json") } - /// Copies and verifies the source without touching the live installation. + /// Verifies the source, and copies it unless the installation runs it in + /// place. Nothing live is touched either way. pub fn stage(&self, source: HelperSource) -> Result { let from = &source.copy_from; if !from.is_file() { @@ -73,6 +82,19 @@ impl<'a> Installer<'a> { } create_private_dir(&self.paths.managed_root)?; + // An in-place installation has nothing to copy or verify a copy of: + // launchd execs the source itself. Its hash still goes into the + // record so a later app upgrade is detected as a changed source. + if source.in_place() { + let sha256 = sha256_file(from) + .map_err(|e| MonitorAgentError::io("Failed to hash the monitor program", e))?; + return Ok(Staged { + source, + sha256, + temp_helper: None, + }); + } + let temp_helper = self.staged_helper(); let _ = std::fs::remove_file(&temp_helper); let staged = (|| { @@ -93,7 +115,7 @@ impl<'a> Installer<'a> { Ok(sha256) => Ok(Staged { source, sha256, - temp_helper, + temp_helper: Some(temp_helper), }), Err(e) => { let _ = std::fs::remove_file(&temp_helper); @@ -173,10 +195,12 @@ impl<'a> Installer<'a> { /// Saves rollback copies and the transaction record, then replaces the /// helper and the plist atomically. pub fn activate(&self, staged: &Staged, plist: &str) -> Result<(), MonitorAgentError> { + let in_place = staged.temp_helper.is_none(); let transaction = Transaction { - had_helper: self.paths.helper.exists(), + had_helper: !in_place && self.paths.helper.exists(), had_plist: self.paths.launch_agent.exists(), had_record: self.paths.install_record.exists(), + in_place, }; let io = |context: &str, e: std::io::Error| MonitorAgentError::io(context.to_string(), e); @@ -198,8 +222,10 @@ impl<'a> Installer<'a> { atomic_write(&self.paths.transaction_record, &json, Some(0o600)) .map_err(|e| io("Failed to write the transaction record", e))?; - std::fs::rename(&staged.temp_helper, &self.paths.helper) - .map_err(|e| io("Failed to activate the new helper", e))?; + if let Some(temp_helper) = &staged.temp_helper { + std::fs::rename(temp_helper, &self.paths.helper) + .map_err(|e| io("Failed to activate the new helper", e))?; + } atomic_write(&self.paths.launch_agent, plist.as_bytes(), Some(0o644)) .map_err(|e| io("Failed to write the LaunchAgent", e))?; Ok(()) @@ -245,11 +271,13 @@ impl<'a> Installer<'a> { failures.push(format!("{}: {e}", target.display())); } }; - restore( - transaction.had_helper, - self.helper_backup(), - &self.paths.helper, - ); + if !transaction.in_place { + restore( + transaction.had_helper, + self.helper_backup(), + &self.paths.helper, + ); + } restore( transaction.had_plist, self.plist_backup(), diff --git a/crates/git-same-core/src/macos/monitor_agent/plist.rs b/crates/git-same-core/src/macos/monitor_agent/plist.rs index 7ee6d1c..0b6929d 100644 --- a/crates/git-same-core/src/macos/monitor_agent/plist.rs +++ b/crates/git-same-core/src/macos/monitor_agent/plist.rs @@ -10,10 +10,14 @@ use std::path::Path; const TEMPLATE: &str = include_str!("../../../assets/com.zaai.git-same.monitor.plist"); -/// Renders the plist for `paths`. `associated_bundle` names the app that -/// owns the helper so System Settings attributes the login item to it. +/// Renders the plist for `paths`. `program` is the executable launchd runs: +/// the managed helper copy for a CLI owner, the app bundle's own main +/// executable for an app owner (see `source::program_for`). +/// `associated_bundle` names the app that owns the service so System Settings +/// attributes the login item to it. pub fn render( paths: &MonitorAgentPaths, + program: &Path, home: &Path, associated_bundle: Option<&str>, ) -> Result { @@ -30,7 +34,7 @@ pub fn render( Ok(TEMPLATE // A comment in the template keeps the asset itself valid XML. .replace("", &associated) - .replace("__GIT_SAME_HELPER__", &escape_path(&paths.helper)?) + .replace("__GIT_SAME_HELPER__", &escape_path(program)?) .replace("__GIT_SAME_HOME__", &escape_path(home)?) .replace("__GIT_SAME_STDOUT__", &escape_path(&paths.stdout_log)?) .replace("__GIT_SAME_STDERR__", &escape_path(&paths.stderr_log)?)) diff --git a/crates/git-same-core/src/macos/monitor_agent/plist_tests.rs b/crates/git-same-core/src/macos/monitor_agent/plist_tests.rs index 35f1bbb..035a0da 100644 --- a/crates/git-same-core/src/macos/monitor_agent/plist_tests.rs +++ b/crates/git-same-core/src/macos/monitor_agent/plist_tests.rs @@ -1,8 +1,18 @@ use super::*; +use crate::macos::monitor_agent::source::{app_main_executable, APP_BUNDLE_ID}; fn render_for(home: &str, bundle: Option<&str>) -> String { - let home = Path::new(home); - render(&MonitorAgentPaths::for_home(home), home, bundle).unwrap() + let paths = MonitorAgentPaths::for_home(Path::new(home)); + let program = paths.helper.clone(); + render(&paths, &program, Path::new(home), bundle).unwrap() +} + +/// An app-owned installation execs the bundle's own main executable, so the +/// Full Disk Access grant for "Git-Same" covers the monitor. +fn render_for_app(home: &str, bundle_path: &str) -> String { + let paths = MonitorAgentPaths::for_home(Path::new(home)); + let program = app_main_executable(Path::new(bundle_path)); + render(&paths, &program, Path::new(home), Some(APP_BUNDLE_ID)).unwrap() } // The asserted paths are POSIX: `Path::join` uses backslashes on Windows, so @@ -52,7 +62,9 @@ fn app_association_is_optional() { #[test] fn xml_control_characters_are_rejected() { let home = Path::new("/Users/a\u{1}da"); - assert!(render(&MonitorAgentPaths::for_home(home), home, None).is_err()); + let paths = MonitorAgentPaths::for_home(home); + let program = paths.helper.clone(); + assert!(render(&paths, &program, home, None).is_err()); } #[cfg(unix)] @@ -60,7 +72,9 @@ fn xml_control_characters_are_rejected() { fn non_utf8_paths_are_rejected() { use std::os::unix::ffi::OsStrExt; let home = Path::new(std::ffi::OsStr::from_bytes(b"/Users/\xff")); - assert!(render(&MonitorAgentPaths::for_home(home), home, None).is_err()); + let paths = MonitorAgentPaths::for_home(home); + let program = paths.helper.clone(); + assert!(render(&paths, &program, home, None).is_err()); } #[cfg(target_os = "macos")] @@ -80,3 +94,17 @@ fn rendered_plist_passes_plutil_lint() { .unwrap(); assert!(status.status.success(), "{status:?}"); } + +// POSIX literals again: see `renders_the_managed_helper_invocation`. +#[cfg(unix)] +#[test] +fn an_app_owned_agent_execs_the_bundle_executable() { + let plist = render_for_app("/Users/ada", "/Applications/Git-Same.app"); + let executable = "/Applications/Git-Same.app/Contents/MacOS/git-same-app"; + + assert_eq!(plist.matches(executable).count(), 2, "Program and argv[0]"); + // Never the copied helper: that path is a separate TCC identity. + assert!(!plist.contains("com.zaai.git-same/monitor/git-same<")); + assert!(plist.contains("--foreground\n --managed")); + assert!(plist.contains("com.zaai.git-same")); +} diff --git a/crates/git-same-core/src/macos/monitor_agent/source.rs b/crates/git-same-core/src/macos/monitor_agent/source.rs index c32dddf..bd83239 100644 --- a/crates/git-same-core/src/macos/monitor_agent/source.rs +++ b/crates/git-same-core/src/macos/monitor_agent/source.rs @@ -10,6 +10,12 @@ use std::path::{Component, Path, PathBuf}; /// Bundle identifier of `Git-Same.app`. pub const APP_BUNDLE_ID: &str = "com.zaai.git-same"; +/// `CFBundleExecutable` of `Git-Same.app`. The LaunchAgent of an app-owned +/// installation execs this file in place: macOS TCC attributes a +/// launchd-spawned process to the bundle only when the executable is the +/// bundle's main one, so a copy elsewhere would be a separate identity that +/// a Full Disk Access grant for "Git-Same" never reaches. +pub const APP_MAIN_EXECUTABLE: &str = "git-same-app"; const FORMULA_NAME: &str = "git-same-cli"; /// A candidate helper source. @@ -18,13 +24,57 @@ pub struct HelperSource { pub owner_kind: OwnerKind, /// Stable app bundle or CLI installation path. pub owner_path: PathBuf, - /// Stable path recorded for future updates. + /// Stable path recorded for future updates. For an app-owned source this + /// is also the program launchd runs; see [`HelperSource::in_place`]. pub source_binary: PathBuf, - /// File to copy right now. Differs from `source_binary` only while a - /// cask installer runs from its staging directory. + /// File to verify (and, for a CLI owner, copy) right now. Differs from + /// `source_binary` only while a cask installer runs from its staging + /// directory. pub copy_from: PathBuf, } +impl HelperSource { + /// Whether the installation runs the source where it already lives + /// instead of copying it into the managed root. + /// + /// True for every app bundle: only the bundle's own main executable + /// carries the bundle's TCC identity. False for CLI installs, whose + /// binary may be upgraded or removed underneath the service, so the + /// managed copy is what makes the service survive `brew upgrade`. + pub fn in_place(&self) -> bool { + self.owner_kind.is_app() + } + + /// The program the LaunchAgent execs for this source. + pub fn program(&self, managed_helper: &Path) -> PathBuf { + if self.in_place() { + self.source_binary.clone() + } else { + managed_helper.to_path_buf() + } + } +} + +/// The program a LaunchAgent execs for an installation owned by `owner_kind` +/// at `owner_path`. Derived, never persisted, so an agent installed by an +/// older build that still points at a copied helper is re-rendered onto the +/// bundle executable the next time anything inspects or repairs it. +pub fn program_for(owner_kind: OwnerKind, owner_path: &Path, managed_helper: &Path) -> PathBuf { + if owner_kind.is_app() { + app_main_executable(owner_path) + } else { + managed_helper.to_path_buf() + } +} + +/// `/Contents/MacOS/git-same-app`. +pub fn app_main_executable(bundle: &Path) -> PathBuf { + bundle + .join("Contents") + .join("MacOS") + .join(APP_MAIN_EXECUTABLE) +} + /// Resolves and classifies the running executable. pub fn invoking_source() -> Result { let invoked = std::env::current_exe() @@ -37,12 +87,12 @@ pub fn invoking_source() -> Result { /// Classifies an already canonicalized executable path. pub fn classify(real_path: &Path) -> HelperSource { if let Some(bundle) = enclosing_app_bundle(real_path) { - let helper = bundle.join("Contents").join("Helpers").join("git-same"); + let executable = app_main_executable(&bundle); return HelperSource { owner_kind: OwnerKind::App, owner_path: bundle, - source_binary: helper.clone(), - copy_from: helper, + source_binary: executable.clone(), + copy_from: executable, }; } let stable = homebrew_opt_path(real_path).unwrap_or_else(|| real_path.to_path_buf()); @@ -54,20 +104,34 @@ pub fn classify(real_path: &Path) -> HelperSource { } } -/// Source for a cask installation: copy from the staged bundle, but record -/// the final app path Homebrew is about to move it to. -pub fn cask_source(staged_executable: &Path, final_app_path: &Path) -> HelperSource { +/// Source for a cask installation. +/// +/// `staged_cli` is the installer itself: `Contents/Helpers/git-same` inside +/// the bundle Homebrew has staged but not yet moved. What the agent runs is +/// the bundle's main executable, so the file verified now is that executable +/// in the same staged bundle, and the path recorded is where Homebrew is +/// about to put it. +pub fn cask_source(staged_cli: &Path, final_app_path: &Path) -> HelperSource { + let staged_bundle = enclosing_bundle_dir(staged_cli); HelperSource { owner_kind: OwnerKind::HomebrewCask, owner_path: final_app_path.to_path_buf(), - source_binary: final_app_path - .join("Contents") - .join("Helpers") - .join("git-same"), - copy_from: staged_executable.to_path_buf(), + source_binary: app_main_executable(final_app_path), + copy_from: staged_bundle + .map(|bundle| app_main_executable(&bundle)) + .unwrap_or_else(|| staged_cli.to_path_buf()), } } +/// The `.app` directory two levels above `Contents//`. +/// Unlike [`enclosing_app_bundle`] this does not read `Info.plist`: the +/// cask installer already knows which bundle it is running from, and the +/// staged bundle may not be fully assembled. +fn enclosing_bundle_dir(executable: &Path) -> Option { + let bundle = executable.parent()?.parent()?.parent()?; + (bundle.extension().is_some_and(|ext| ext == "app")).then(|| bundle.to_path_buf()) +} + /// The `Git-Same.app` containing `path`, verified through its `Info.plist`. fn enclosing_app_bundle(path: &Path) -> Option { path.ancestors() diff --git a/crates/git-same-core/src/macos/monitor_agent/source_tests.rs b/crates/git-same-core/src/macos/monitor_agent/source_tests.rs index ffa00c7..992f7a5 100644 --- a/crates/git-same-core/src/macos/monitor_agent/source_tests.rs +++ b/crates/git-same-core/src/macos/monitor_agent/source_tests.rs @@ -13,6 +13,11 @@ fn make_bundle(root: &Path, name: &str, identifier: &str) -> PathBuf { ) .unwrap(); write_executable(&helpers.join("git-same"), b"helper"); + // The main executable is what an app-owned agent actually runs. + write_executable( + &bundle.join("Contents").join("MacOS").join("git-same-app"), + b"app", + ); std::fs::canonicalize(bundle).unwrap() } @@ -55,15 +60,55 @@ fn bundled_cli_and_app_binary_both_classify_as_the_app() { let dir = tempfile::tempdir().unwrap(); let bundle = make_bundle(dir.path(), "Git Same & Co.app", APP_BUNDLE_ID); let helper = bundle.join("Contents/Helpers/git-same"); + let executable = bundle.join("Contents/MacOS/git-same-app"); - for executable in [helper.clone(), bundle.join("Contents/MacOS/git-same-app")] { - let source = classify(&executable); + // Either entry point identifies the same owner, and either way the + // installation runs the bundle's main executable in place: only that + // file carries the bundle's TCC identity. + for invoked in [helper, executable.clone()] { + let source = classify(&invoked); assert_eq!(source.owner_kind, OwnerKind::App); assert_eq!(source.owner_path, bundle); - assert_eq!(source.source_binary, helper); + assert_eq!(source.source_binary, executable); + assert!(source.in_place()); + assert_eq!(source.program(Path::new("/managed/git-same")), executable); } } +#[test] +fn a_cli_owner_runs_the_managed_copy_not_its_own_binary() { + let source = classify(Path::new("/usr/local/bin/git-same")); + assert_eq!(source.owner_kind, OwnerKind::Cli); + assert!(!source.in_place()); + assert_eq!( + source.program(Path::new("/managed/git-same")), + Path::new("/managed/git-same") + ); +} + +#[test] +fn the_expected_program_is_derived_from_the_owner_not_the_record() { + // An agent installed by an older build recorded the copied helper as its + // source; the expected program is still the bundle executable, so the + // next repair re-renders the plist onto it. + assert_eq!( + program_for( + OwnerKind::App, + Path::new("/Applications/Git-Same.app"), + Path::new("/managed/git-same") + ), + Path::new("/Applications/Git-Same.app/Contents/MacOS/git-same-app") + ); + assert_eq!( + program_for( + OwnerKind::Cli, + Path::new("/usr/local/bin/git-same"), + Path::new("/managed/git-same") + ), + Path::new("/managed/git-same") + ); +} + #[test] fn foreign_bundle_is_not_an_app_owner() { let dir = tempfile::tempdir().unwrap(); @@ -122,9 +167,10 @@ fn cask_source_copies_from_staging_but_records_the_final_app() { assert_eq!(source.owner_kind, OwnerKind::HomebrewCask); assert_eq!( source.source_binary, - Path::new("/Users/ada/Applications/Git-Same.app/Contents/Helpers/git-same") + Path::new("/Users/ada/Applications/Git-Same.app/Contents/MacOS/git-same-app") ); assert!(source.copy_from.starts_with("/opt/homebrew/Caskroom")); + assert!(source.in_place()); } #[test] @@ -185,7 +231,8 @@ fn damaged_app_does_not_take_over_from_a_usable_recorded_cli() { let bundle = make_bundle(dir.path(), "Git-Same.app", APP_BUNDLE_ID); let app_helper = bundle.join("Contents/Helpers/git-same"); let caller = classify(&app_helper); - std::fs::remove_file(app_helper).unwrap(); + // Damage the file the agent would actually run. + std::fs::remove_file(bundle.join("Contents/MacOS/git-same-app")).unwrap(); let recorded = dir.path().join("cli/git-same"); write_executable(&recorded, b"usable cli"); let existing = record(OwnerKind::Cli, &recorded, &recorded); diff --git a/crates/git-same-core/src/monitor/managed.rs b/crates/git-same-core/src/monitor/managed.rs new file mode 100644 index 0000000..d6c5824 --- /dev/null +++ b/crates/git-same-core/src/monitor/managed.rs @@ -0,0 +1,68 @@ +//! Startup path for a monitor launched by the managed LaunchAgent. +//! +//! Shared by every host that launchd may exec: the CLI `gisa monitor +//! --foreground --managed` subcommand and, for an app-owned installation, +//! `Git-Same.app/Contents/MacOS/git-same-app monitor --foreground --managed`. +//! Both run the identical loop under the identical rules; only the binary +//! (and therefore the process's TCC identity) differs. + +use super::run::{default_shutdown_signal, run_with, Options, RunContext}; +use super::runtime_guard::MonitorMode; +use crate::config::Config; +use crate::errors::{AppError, MonitorAgentError, Result}; +use crate::ipc::IpcConfig; +use crate::macos::monitor_agent; +use crate::output::Output; +use std::path::PathBuf; +use tracing::error; + +/// Runs the monitor as the managed service. +/// +/// launchd restarts the program after every unsuccessful exit +/// (`KeepAlive = { SuccessfulExit = false }`). Conditions a restart cannot +/// fix therefore exit successfully after one logged line; only transient +/// failures return an error. +pub async fn run_managed(output: &Output) -> Result<()> { + let prepared = tokio::task::spawn_blocking(prepare) + .await + .map_err(|e| AppError::Other(anyhow::anyhow!("managed startup task failed: {e}")))?; + let (config, path, ipc_config) = match prepared { + Ok(prepared) => prepared, + Err(reason) => { + error!("{reason}"); + eprintln!("git-same monitor: {reason}"); + return Ok(()); + } + }; + + let opts = Options::from_config(&config, ipc_config, None); + let context = RunContext { + mode: MonitorMode::Managed, + config_path: Some(path), + interval_explicit: false, + }; + match run_with(&config, output, opts, context, default_shutdown_signal()).await { + Err(AppError::MonitorAgent(MonitorAgentError::AlreadyRunning { pid })) => { + eprintln!("git-same monitor: another monitor is already running ({pid:?}); exiting"); + Ok(()) + } + other => other, + } +} + +/// Checks that must pass before the first side effect. `Err` is a reason to +/// exit successfully without running. +fn prepare() -> std::result::Result<(Config, PathBuf, IpcConfig), String> { + let controller = monitor_agent::controller_for_current_user(false) + .map_err(|e| format!("not starting: {e}"))?; + match controller.monitoring_enabled() { + Ok(true) => {} + Ok(false) => return Err("monitoring is disabled; not starting".to_string()), + Err(e) => return Err(format!("not starting: {e}")), + } + let paths = controller.paths(); + // Never rewritten, never replaced with defaults. + let config = Config::load_from(&paths.config) + .map_err(|e| format!("not starting until the configuration is fixed: {e}"))?; + Ok((config, paths.config.clone(), paths.ipc.clone())) +} diff --git a/crates/git-same-core/src/monitor/mod.rs b/crates/git-same-core/src/monitor/mod.rs index 178a748..e6d5e1b 100644 --- a/crates/git-same-core/src/monitor/mod.rs +++ b/crates/git-same-core/src/monitor/mod.rs @@ -13,6 +13,7 @@ pub mod incremental; pub mod live_config; +pub mod managed; pub mod owner_classifier; pub mod process; pub mod run; @@ -20,5 +21,6 @@ pub mod runtime_guard; #[cfg(unix)] pub mod socket_handler; -pub use run::{run, run_with, Options, RunContext}; +pub use managed::run_managed; +pub use run::{default_shutdown_signal, run, run_with, Options, RunContext}; pub use runtime_guard::MonitorMode; diff --git a/crates/git-same-core/src/monitor/run.rs b/crates/git-same-core/src/monitor/run.rs index f8bf241..7e76076 100644 --- a/crates/git-same-core/src/monitor/run.rs +++ b/crates/git-same-core/src/monitor/run.rs @@ -51,6 +51,47 @@ pub struct Options { pub ipc_config: IpcConfig, } +impl Options { + /// Build options from `config.toml`. An explicit `interval_override` (the + /// CLI `--interval` flag) wins over `[monitor] fullscan_interval_secs`. + pub fn from_config( + config: &Config, + ipc_config: IpcConfig, + interval_override: Option, + ) -> Self { + let secs = interval_override.unwrap_or(config.monitor.fullscan_interval_secs); + Self { + interval: Duration::from_secs(secs), + ipc_config, + } + } +} + +/// Resolve when the process receives SIGINT (ctrl-c) or SIGTERM (`gisa +/// monitor --stop`, `launchctl bootout`). Shared by every monitor host: the +/// CLI subcommand and the app's headless monitor mode. +pub async fn default_shutdown_signal() { + #[cfg(unix)] + { + let mut sigterm = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(signal) => signal, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + /// How and from where this monitor process was started. /// /// Kept separate from [`Options`] so that struct's public shape stays stable. diff --git a/crates/git-same-core/src/monitor/run_tests.rs b/crates/git-same-core/src/monitor/run_tests.rs index 0d371b9..1fe5192 100644 --- a/crates/git-same-core/src/monitor/run_tests.rs +++ b/crates/git-same-core/src/monitor/run_tests.rs @@ -120,3 +120,24 @@ async fn managed_startup_exits_successfully_when_runtime_lock_is_unusable() { assert!(result.is_ok(), "managed helper must not restart-loop"); } + +#[test] +fn from_config_uses_config_interval_when_no_override() { + let mut config = Config::default(); + config.monitor.fullscan_interval_secs = 90; + + let opts = Options::from_config(&config, ipc_at(PathBuf::from("/tmp/from-config")), None); + + assert_eq!(opts.interval, Duration::from_secs(90)); + assert_eq!(opts.ipc_config.dir, PathBuf::from("/tmp/from-config")); +} + +#[test] +fn from_config_lets_explicit_override_win() { + let mut config = Config::default(); + config.monitor.fullscan_interval_secs = 30; + + let opts = Options::from_config(&config, ipc_at(PathBuf::from("/tmp/from-config")), Some(10)); + + assert_eq!(opts.interval, Duration::from_secs(10)); +} diff --git a/crates/git-same-core/src/types/finder_status.rs b/crates/git-same-core/src/types/finder_status.rs index 1aa9100..dbe5a84 100644 --- a/crates/git-same-core/src/types/finder_status.rs +++ b/crates/git-same-core/src/types/finder_status.rs @@ -25,7 +25,7 @@ pub enum Badge { /// Main branch is safe; other branches or worktrees have local-only data. Orange, /// Staged, unstaged, untracked, or unpushed commits. - /// DO NOT delete — uncommitted work or unpushed commits would be lost. + /// DO NOT delete: uncommitted work or unpushed commits would be lost. Red, /// Ambient git repo discovered outside any configured workspace. /// Upgraded to a semantic color on demand (right-click → REFRESH /path). @@ -159,6 +159,13 @@ pub struct FinderStatus { /// Absent in status files written before this field existed. #[serde(default, skip_serializing_if = "Option::is_none")] pub monitor_version: Option, + /// Whether the monitor process that wrote this status holds Full Disk + /// Access. TCC keys the grant on the writing executable, so this is the + /// authoritative answer for "can the monitor read protected folders"; + /// hosts gate Finder badge setup on it. `None` when the monitor could not + /// determine it or the status predates this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub full_disk_access: Option, } impl FinderStatus { @@ -178,6 +185,7 @@ impl FinderStatus { monitored_roots: Vec::new(), boot_volume_aliases: Vec::new(), monitor_version: Some(env!("CARGO_PKG_VERSION").to_string()), + full_disk_access: None, } } } diff --git a/crates/git-same-core/src/types/finder_status_tests.rs b/crates/git-same-core/src/types/finder_status_tests.rs index a8563d6..e7d85b2 100644 --- a/crates/git-same-core/src/types/finder_status_tests.rs +++ b/crates/git-same-core/src/types/finder_status_tests.rs @@ -166,6 +166,31 @@ fn test_legacy_status_without_monitor_version_deserializes_to_none() { assert!(parsed.monitor_version.is_none()); } +#[test] +fn test_full_disk_access_round_trips_and_is_omitted_when_unknown() { + // Unknown: the key is omitted entirely so older readers see no change. + let mut status = FinderStatus::new(1, "t".to_string()); + assert!(status.full_disk_access.is_none()); + let json = serde_json::to_string(&status).unwrap(); + assert!(!json.contains("full_disk_access")); + + // Stamped: survives a round-trip in both states. + for granted in [true, false] { + status.full_disk_access = Some(granted); + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains(&format!("\"full_disk_access\":{granted}"))); + let parsed: FinderStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.full_disk_access, Some(granted)); + } +} + +#[test] +fn test_legacy_status_without_full_disk_access_deserializes_to_none() { + let legacy = r#"{"version":1,"timestamp":"t","daemon_pid":1,"workspaces":[],"repos":[]}"#; + let parsed: FinderStatus = serde_json::from_str(legacy).unwrap(); + assert!(parsed.full_disk_access.is_none()); +} + #[test] fn test_boot_volume_aliases_serialization() { // Empty: the key is omitted entirely (skip_serializing_if). diff --git a/docs/README.md b/docs/README.md index e806547..d3acebd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -275,6 +275,17 @@ All examples in this README use `git-same`, but any alias works interchangeably. The cask installs `Git-Same.app`, the CLI aliases, a FinderSync badge extension, and a monitor LaunchAgent. The app reads the same config as the CLI and shows workspace status from the monitor. Finder badges use the monitor's status file, and workspace root folders get a custom Git-Same folder icon unless `[ui] custom_folder_icon = false` is set. +### Full Disk Access and Finder badges + +Finder badges need Full Disk Access. The monitor reads every repository folder, and without the grant macOS asks for each protected location (Desktop, Documents, Downloads, external and network volumes) and denies the folders you decline. The app therefore walks you through badge setup in this order, and refuses to enable the extension until the grant is in place: + +1. The monitor is running. +2. Full Disk Access is granted to `Git-Same` in System Settings > Privacy & Security > Full Disk Access. macOS applies the grant when a process starts, so quit and reopen the app afterwards; the app restarts the monitor for you once it sees the grant. +3. The Finder extension is installed. +4. Enable badges. The app sets the extension election itself; if macOS ignores that, use the Open button to toggle Git-Same Badges in Login Items & Extensions. + +One grant covers both the app and the monitor because the LaunchAgent runs the monitor through the app's own executable (`Git-Same.app/Contents/MacOS/git-same-app monitor`). Two things the grant never covers: `gisa` run from a terminal uses the terminal's permissions, and a development build under `target/` is a separate identity that macOS prompts for again. + Useful checks: ```bash diff --git a/macos/GitSameBadges/GitSameBadges.entitlements b/macos/GitSameBadges/GitSameBadges.entitlements index b1d1b3d..29cc387 100644 --- a/macos/GitSameBadges/GitSameBadges.entitlements +++ b/macos/GitSameBadges/GitSameBadges.entitlements @@ -9,13 +9,15 @@ so both processes reach the same files. No absolute-path exception is needed for the IPC files. - Workspace folders: the extension reads arbitrary user-defined - repository paths to compute badges. The shippable answer is - Full Disk Access, granted by the user once via System Settings > - Privacy & Security > Full Disk Access on first launch of - Git-Same.app. Per-workspace absolute-path entitlements would - require the user to re-sign the extension when adding a workspace, - which is impractical without an Apple Developer ID. + Workspace folders: the extension never reads repository paths itself. + It only registers the monitor's `monitored_roots` as + `directoryURLs` and answers Finder from `status.json`, so it needs + no file entitlements and triggers no TCC prompts. The process that + reads the folders is the monitor, which runs under the app's + bundle identity and needs Full Disk Access, granted once via + System Settings > Privacy & Security > Full Disk Access. Per-path + absolute-path exceptions would require re-signing the extension + for every new workspace, so they are deliberately not used. macOS 26 testing constraints (memory: feedback_finder_sync_testing.md): - Sandbox stays ON. diff --git a/toolkit/homebrew/cask.rb.tmpl b/toolkit/homebrew/cask.rb.tmpl index 7d15378..1a718a5 100644 --- a/toolkit/homebrew/cask.rb.tmpl +++ b/toolkit/homebrew/cask.rb.tmpl @@ -24,10 +24,14 @@ cask "git-same" do depends_on macos: :ventura app "Git-Same.app" - # Installs the background monitor as a separate helper in the user's - # Library and starts it when monitoring is enabled. The helper does not - # depend on the app bundle, so closing or moving Git-Same.app never stops - # monitoring. `installer script:` runs outside the cask sandbox (it has to + # Installs the background monitor LaunchAgent and starts it when monitoring + # is enabled. The agent execs the app bundle's own main executable in + # headless `monitor` mode, not the CLI helper: macOS TCC attributes a + # launchd-spawned process to its bundle only when the executable is the + # bundle's CFBundleExecutable, so this is what lets one Full Disk Access + # grant for "Git-Same" cover the monitor. Closing the app does not stop + # monitoring; moving or deleting the bundle does, which the app reports. + # `installer script:` runs outside the cask sandbox (it has to # reach launchd) and EXECUTES BEFORE `app` moves the bundle, even though # `brew style` requires it to be written after `app`. That is why the # executable is the staged copy and the final app path is passed in. diff --git a/toolkit/packaging/macos/build-app-bundle.sh b/toolkit/packaging/macos/build-app-bundle.sh index bd4f748..289b4f2 100755 --- a/toolkit/packaging/macos/build-app-bundle.sh +++ b/toolkit/packaging/macos/build-app-bundle.sh @@ -95,6 +95,11 @@ cat > "$APP/Contents/Info.plist" <LSMinimumSystemVersion13.0 LSApplicationCategoryTypepublic.app-category.developer-tools NSHighResolutionCapable + NSDesktopFolderUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. + NSDocumentsFolderUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. + NSDownloadsFolderUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. + NSRemovableVolumesUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. + NSNetworkVolumesUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. EOF From 210876fc187230260da45a363039134dd3857686 Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 9 Sep 2026 11:11:11 +0200 Subject: [PATCH 14/21] Update vulnerable dependencies to resolve Dependabot findings --- .github/workflows/S1-Test-CI.yml | 2 +- .github/workflows/S2-Release-GitHub.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index 456050d..054abda 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -192,7 +192,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Install cargo-tarpaulin - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@v2.87.8 with: tool: cargo-tarpaulin diff --git a/.github/workflows/S2-Release-GitHub.yml b/.github/workflows/S2-Release-GitHub.yml index 54501cd..c5202c7 100644 --- a/.github/workflows/S2-Release-GitHub.yml +++ b/.github/workflows/S2-Release-GitHub.yml @@ -65,7 +65,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Install cargo-tarpaulin - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@v2.87.8 with: tool: cargo-tarpaulin From 1d4a2952b05df32f8a29c1afd5007f2cc5381b1f Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 9 Sep 2026 15:00:09 +0200 Subject: [PATCH 15/21] Assert test config isolation to protect user config Core and app test env guards now fail fast if Config::default_path() resolves outside the temp home, so a broken override can no longer register temp workspaces in the developer's real ~/.config/git-same/config.toml. The ten stale my-ws entries found there were historical; the current tree does not reproduce them. --- crates/git-same-app/src/commands_tests.rs | 9 +++++++++ .../git-same-core/src/config/workspace_store_tests.rs | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index bc96544..f065899 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -13,6 +13,15 @@ impl ConfigEnvGuard { let lock = CONFIG_ENV_LOCK.lock().unwrap(); let previous = std::env::var("GIT_SAME_CONFIG_DIR").ok(); std::env::set_var("GIT_SAME_CONFIG_DIR", path); + // Fail fast if isolation ever breaks: writing through the real user + // config would leave temp workspaces registered on the developer's Mac. + let resolved = Config::default_path().expect("default_path"); + assert!( + resolved.starts_with(path), + "test config path {} escaped {}", + resolved.display(), + path.display() + ); Self { _lock: lock, previous, diff --git a/crates/git-same-core/src/config/workspace_store_tests.rs b/crates/git-same-core/src/config/workspace_store_tests.rs index 0ba0120..d492ad4 100644 --- a/crates/git-same-core/src/config/workspace_store_tests.rs +++ b/crates/git-same-core/src/config/workspace_store_tests.rs @@ -77,6 +77,16 @@ fn with_temp_home(home: &Path, f: impl FnOnce() -> T) -> T { std::fs::create_dir_all(&appdata).ok(); std::env::set_var("APPDATA", &appdata); } + // Fail fast if isolation ever breaks: a test that resolves the real + // user config would silently register temp workspaces in + // ~/.config/git-same/config.toml instead of failing. + let resolved = crate::config::Config::default_path().expect("default_path"); + assert!( + resolved.starts_with(home), + "test config path {} escaped the temp home {}", + resolved.display(), + home.display() + ); f() } From f4cc5013ad6a56ebee4fb43bd0c8a95ff261074b Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 9 Sep 2026 15:59:56 +0200 Subject: [PATCH 16/21] Fix CI failures blocking the 3.2.0 merge Make the EPERM probe test portable (raw OS error 1 is not PermissionDenied on Windows), allow clippy's beta-only double_must_use on the async_trait Provider trait, and run the Security Audit job on current stable because cargo-audit's dependency graph (kstring 2.0.4) outgrew the pinned 1.93.1 toolchain. Also correct the LaunchAgent migration comment to the 3.2 release. --- .../src/macos/full_disk_access_tests.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/git-same-core/src/macos/full_disk_access_tests.rs b/crates/git-same-core/src/macos/full_disk_access_tests.rs index ac9469a..9969da6 100644 --- a/crates/git-same-core/src/macos/full_disk_access_tests.rs +++ b/crates/git-same-core/src/macos/full_disk_access_tests.rs @@ -7,12 +7,20 @@ fn classify_maps_success_to_granted() { #[test] fn classify_maps_permission_denied_to_denied() { - // TCC answers EPERM, which std maps to PermissionDenied. - let denied = io::Error::from_raw_os_error(1); - assert_eq!(denied.kind(), io::ErrorKind::PermissionDenied); + let denied = io::Error::new(io::ErrorKind::PermissionDenied, "Operation not permitted"); assert_eq!(classify(Err(denied)), FullDiskAccess::Denied); } +// TCC answers EPERM (errno 1), which std maps to PermissionDenied on Unix. +// Raw OS error 1 means something unrelated on Windows, so this stays Unix-only. +#[cfg(unix)] +#[test] +fn classify_maps_raw_eperm_to_denied() { + let eperm = io::Error::from_raw_os_error(1); + assert_eq!(eperm.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(classify(Err(eperm)), FullDiskAccess::Denied); +} + #[test] fn classify_maps_other_errors_to_unknown() { let missing = io::Error::new(io::ErrorKind::NotFound, "no TCC.db"); From 087b51a700cc7da9c359d10701f45bfe2591becb Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 21 Sep 2026 15:13:15 +0200 Subject: [PATCH 17/21] Fix PR review findings across IPC, TUI, and app Address the unresolved review threads on PR #21, re-verified against the rebased tree. TUI: DiscoveryError no longer tears down an in-flight operation. Per-org discovery failures are non-fatal in the provider, so resetting to Idle dropped the guards that keep a status refresh, and a second concurrent sync, from starting mid-run. Also clear status_loading on OperationError and stop recording a short-circuited Status run as a sync. IPC: move aside a directory left at the host status.json path. The mirror write fails EISDIR forever otherwise, and a mirror failure is only a warning, so the monitor ran with host status permanently absent. App: withhold the host-probe fallback in the Full Disk Access gate when a fresh monitor reports no answer and the agent is not app-owned, since the grant does not reach a CLI-owned helper. Broaden startup recovery beyond the leftover symlink, which the first status read erases. Expose a guarded restart the UI can use without implicitly installing a service. UI: kickMonitorIfLagging now skips monitors it cannot restart, clears its latch on failure so transient launchctl errors retry, and re-reads Full Disk Access on success so the badge action unlocks immediately. Deps: drop the time cap. cookie 0.18.2 no longer calls Parsable::parse, so the 0.3.52 incompatibility no longer applies. Tests: assert the mirror entries are real files rather than symlinks, add a crate-wide env lock so the IPC path readers cannot race the config tests that swap HOME, and de-duplicate the TUI spinner onto one table. --- Cargo.lock | 9 +- crates/git-same-app/Cargo.toml | 4 - crates/git-same-app/src/commands.rs | 147 +++++++++++++++-- crates/git-same-app/src/commands_tests.rs | 72 +++++++-- crates/git-same-app/src/main.rs | 32 ++-- crates/git-same-app/ui/src/lib/tauri.ts | 5 + .../git-same-app/ui/src/stores/status.test.ts | 83 ++++++++++ crates/git-same-app/ui/src/stores/status.ts | 40 ++++- crates/git-same-cli/src/setup/screens/auth.rs | 8 +- crates/git-same-cli/src/setup/screens/orgs.rs | 8 +- .../src/setup/screens/requirements.rs | 4 +- crates/git-same-cli/src/tui/backend.rs | 2 - crates/git-same-cli/src/tui/handler.rs | 20 ++- crates/git-same-cli/src/tui/handler_tests.rs | 151 ++++++++++++++++++ .../src/config/workspace_store_tests.rs | 7 +- crates/git-same-core/src/ipc/mod_tests.rs | 17 +- crates/git-same-core/src/ipc/status_file.rs | 53 +++++- .../src/ipc/status_file_tests.rs | 76 ++++++++- crates/git-same-core/src/lib.rs | 18 +++ 19 files changed, 659 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9325fb..73b1ac9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1627,7 +1627,6 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tempfile", - "time", "tokio", "toml 1.1.6+spec-1.1.0", "tracing", @@ -5195,9 +5194,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -5217,9 +5216,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", diff --git a/crates/git-same-app/Cargo.toml b/crates/git-same-app/Cargo.toml index 1352286..5d6ce5a 100644 --- a/crates/git-same-app/Cargo.toml +++ b/crates/git-same-app/Cargo.toml @@ -25,10 +25,6 @@ serde = { workspace = true } serde_json = { workspace = true } shellexpand = { workspace = true } tauri = { version = "2", features = [] } -# Pin: tauri's transitive `cookie` 0.18.1 calls time's Parsable::parse with the -# pre-0.3.52 one-arg signature; time 0.3.52 made it two-arg and fails to compile. -# No fixed cookie release exists yet. Remove this cap once cookie ships a fix. -time = ">=0.3, <0.3.52" tauri-plugin-dialog = "2" tokio = { workspace = true } toml = { workspace = true } diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index fcca18b..0940792 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -488,6 +488,20 @@ pub(crate) fn refresh_monitor_status(app: tauri::AppHandle) { }); } +/// Restart an already-installed monitor that startup found stale, in the +/// background so the window stays responsive. +/// +/// Goes through `run_monitor_operation` like every other lifecycle command, so +/// it takes the same lock as `ensure_monitor_on_startup` instead of racing it, +/// and the result reaches the UI as a `monitor-agent-updated` event. +pub(crate) fn recover_monitor_on_startup(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + if let Err(error) = run_monitor_operation(app, restart_monitor_if_installed).await { + eprintln!("failed to restart monitor after upgrade: {error}"); + } + }); +} + /// One automatic recovery at app startup. Does nothing when suppressed /// (`GIT_SAME_DISABLE_MONITOR_AUTOSTART=1`, dev launches) or when this is /// not the real user's default environment, and never enables a service @@ -763,12 +777,30 @@ pub fn full_disk_access_status( fn full_disk_access_status_inner(ipc: &IpcConfig) -> FullDiskAccessDto { let snapshot = read_status_snapshot_with(ipc).ok(); - full_disk_access_dto(full_disk_access::probe(), snapshot.as_ref()) + full_disk_access_dto( + full_disk_access::probe(), + snapshot.as_ref(), + monitor_runs_as_app_identity(), + ) +} + +/// Whether the installed agent runs the monitor as this app's bundle +/// executable, the only program a Full Disk Access grant for Git-Same covers. +/// +/// A `Cli`-owned agent execs a copied helper under its own path-based TCC +/// identity, so the app's grant never reaches it. An unknown owner is reported +/// as "not the app": the one gate that consults this fails closed. +fn monitor_runs_as_app_identity() -> bool { + monitor_launch_agent_status_inner() + .ok() + .and_then(|status| status.owner_kind) + .is_some_and(|owner| owner.is_app()) } fn full_disk_access_dto( host: FullDiskAccess, snapshot: Option<&StatusSnapshot>, + monitor_is_app_identity: bool, ) -> FullDiskAccessDto { let monitor_fresh = snapshot.is_some_and(|snapshot| !snapshot.stale); let monitor = snapshot @@ -778,17 +810,32 @@ fn full_disk_access_dto( host: host.as_str().to_string(), monitor, monitor_fresh, - granted: fda_gate_passes(host, monitor, monitor_fresh), + granted: fda_gate_passes(host, monitor, monitor_fresh, monitor_is_app_identity), } } -/// The badge-setup gate. A fresh monitor's own answer wins because TCC keys -/// the grant on the monitor executable; otherwise fall back to this process's -/// probe (the same identity once the LaunchAgent runs the app executable). -/// Only a definite "granted" passes; unknown never does. -fn fda_gate_passes(host: FullDiskAccess, monitor: Option, monitor_fresh: bool) -> bool { +/// The badge-setup gate. A fresh monitor's own answer wins because TCC keys the +/// grant on the monitor executable. Only a definite "granted" passes; unknown +/// never does. +/// +/// The awkward arm is a fresh monitor that reports *no* answer: a pre-3.2 build +/// that predates the `full_disk_access` field. Falling back to this process's +/// probe is only sound when that monitor shares this app's TCC identity, so the +/// fallback is withheld unless the agent is app-owned. Without that, badges get +/// enabled against a helper-identity monitor that cannot read the workspace and +/// stay silently blank, which is exactly what this gate exists to prevent. +/// +/// A stale or absent monitor keeps the plain host-probe fallback, so a first-run +/// setup with nothing installed yet is never blocked. +fn fda_gate_passes( + host: FullDiskAccess, + monitor: Option, + monitor_fresh: bool, + monitor_is_app_identity: bool, +) -> bool { match (monitor_fresh, monitor) { (true, Some(granted)) => granted, + (true, None) => monitor_is_app_identity && host == FullDiskAccess::Granted, _ => host == FullDiskAccess::Granted, } } @@ -799,6 +846,13 @@ fn full_disk_access_message(fda: &FullDiskAccessDto) -> String { (false, "granted", Some(false)) => { "granted to the app, but the running monitor lacks it (restart the monitor)" } + // The gate withheld the host-probe fallback: a running monitor that + // reports no answer is a pre-3.2 build, and the app's grant only covers + // it once the agent runs this app's executable. + (false, "granted", None) if fda.monitor_fresh => { + "granted to the app, but the running monitor is an older build under a \ + different identity (restart the monitor to pick up the grant)" + } (false, "not_applicable", _) => "not applicable on this platform", (false, "unknown", None) => "could not be determined", _ => "not granted (required for Finder badges)", @@ -883,19 +937,73 @@ fn monitor_launch_agent_status_inner() -> Result Result<(), AppError> { +/// Called from app startup via `recover_monitor_on_startup` when +/// `monitor_needs_startup_recovery` finds evidence of an old build, and exposed +/// to the UI as `restart_monitor_if_agent_installed`. +pub(crate) fn restart_monitor_if_installed() -> Result { let controller = match monitor_agent::controller_for_current_user(false) { Ok(controller) => controller, - Err(MonitorAgentError::Unsupported) => return Ok(()), + Err(MonitorAgentError::Unsupported) => return Ok(MonitorAgentStatus::unsupported()), Err(error) => return Err(error.into()), }; - if controller.inspect()?.state == MonitorAgentState::NotInstalled { - return Ok(()); + let status = controller.inspect()?; + if status.state == MonitorAgentState::NotInstalled { + return Ok(status); + } + Ok(controller.restart()?) +} + +/// Whether an already-installed monitor should be restarted at app launch. +/// +/// Two independent signals, either sufficient: +/// +/// * A leftover **symlink** at the host status path. Only pre-3.2 monitors +/// create one, so seeing it means an old build is still running. +/// * An installed service that is **running and has completed a scan**, while +/// the host mirror is absent or stale. That is the same old build seen from +/// the other side: it scans and writes the container, but never mirrors. +/// +/// The symlink alone is not enough. `read_status_snapshot_with` unlinks it on +/// the first read, and the status watcher performs one within moments of +/// launch, so from the *second* launch onwards there is no symlink left to find +/// and the host status would stay absent indefinitely. +/// +/// Requiring a completed scan is what keeps this from restarting a healthy +/// monitor that simply has not finished its first pass yet. +pub(crate) fn monitor_needs_startup_recovery(ipc: &IpcConfig) -> bool { + // Checked before any snapshot read, which would erase the evidence. + let host_status_is_symlink = ipc + .status_file_path() + .symlink_metadata() + .map(|meta| meta.file_type().is_symlink()) + .unwrap_or(false); + if host_status_is_symlink { + return true; + } + + let Ok(agent) = monitor_launch_agent_status_inner() else { + return false; + }; + if !agent.running || agent.last_scan.is_none() { + return false; } - controller.restart()?; - Ok(()) + read_status_snapshot_with(ipc) + .map(|snapshot| snapshot.stale) + .unwrap_or(true) +} + +/// Restart the monitor only when a service is already installed. +/// +/// Unlike `restart_monitor`, this never installs one: a plain restart falls +/// back to a full install when nothing is present, which would turn a +/// background recovery attempt into a service the user never asked for. The UI +/// uses this for automatic recovery and keeps `restart_monitor` for the button +/// the user presses deliberately. +#[tauri::command] +pub async fn restart_monitor_if_agent_installed( + app: tauri::AppHandle, +) -> Result { + run_monitor_operation(app, restart_monitor_if_installed).await } // `pluginkit -m -v -i ` prints one line per plugin matching the id, or @@ -1159,7 +1267,14 @@ fn app_requirement_checks(ipc: &IpcConfig) -> Vec { critical: false, }); - let fda = full_disk_access_dto(full_disk_access::probe(), snapshot.as_ref()); + let fda = full_disk_access_dto( + full_disk_access::probe(), + snapshot.as_ref(), + monitor_agent + .as_ref() + .and_then(|status| status.owner_kind) + .is_some_and(|owner| owner.is_app()), + ); checks.push(RequirementCheckDto { name: "Full Disk Access".to_string(), passed: fda.granted, diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index f065899..a59dd87 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -694,28 +694,68 @@ fn snapshot_with_fda(monitor: Option, stale: bool) -> StatusSnapshot { #[test] fn fda_gate_prefers_a_fresh_monitor_answer() { // The monitor holds the grant even though this process does not (for - // example a dev build): badges can render, so the gate passes. - assert!(fda_gate_passes(FullDiskAccess::Denied, Some(true), true)); + // example a dev build): badges can render, so the gate passes. The monitor's + // own answer wins regardless of which agent installed it. + assert!(fda_gate_passes( + FullDiskAccess::Denied, + Some(true), + true, + false + )); + assert!(fda_gate_passes( + FullDiskAccess::Denied, + Some(true), + true, + true + )); // The monitor lacks the grant even though this process has it (grant // landed after the monitor started): badges would stay blank. - assert!(!fda_gate_passes(FullDiskAccess::Granted, Some(false), true)); + assert!(!fda_gate_passes( + FullDiskAccess::Granted, + Some(false), + true, + true + )); } #[test] fn fda_gate_falls_back_to_the_host_probe_without_a_fresh_monitor() { - assert!(fda_gate_passes(FullDiskAccess::Granted, None, true)); - assert!(fda_gate_passes(FullDiskAccess::Granted, Some(false), false)); - assert!(!fda_gate_passes(FullDiskAccess::Denied, None, false)); + assert!(fda_gate_passes( + FullDiskAccess::Granted, + Some(false), + false, + false + )); + assert!(!fda_gate_passes(FullDiskAccess::Denied, None, false, true)); // Unknown never passes: the gate must not enable badges on a guess. - assert!(!fda_gate_passes(FullDiskAccess::Unknown, None, true)); - assert!(!fda_gate_passes(FullDiskAccess::NotApplicable, None, false)); + assert!(!fda_gate_passes(FullDiskAccess::Unknown, None, true, true)); + assert!(!fda_gate_passes( + FullDiskAccess::NotApplicable, + None, + false, + true + )); +} + +#[test] +fn fda_gate_withholds_the_host_probe_from_a_foreign_identity_monitor() { + // A fresh monitor reporting no answer is a pre-3.2 build. The host probe + // only transfers to it when the agent runs this app's executable; a + // CLI-owned (or unknown) agent runs a helper under its own TCC identity, so + // enabling badges would leave them silently blank. + assert!(!fda_gate_passes(FullDiskAccess::Granted, None, true, false)); + assert!(fda_gate_passes(FullDiskAccess::Granted, None, true, true)); + + // A stale or absent monitor keeps the plain fallback, so a first-run setup + // with nothing installed yet is never blocked by this. + assert!(fda_gate_passes(FullDiskAccess::Granted, None, false, false)); } #[test] fn full_disk_access_dto_reports_both_identities() { let stale_snapshot = snapshot_with_fda(Some(false), true); - let dto = full_disk_access_dto(FullDiskAccess::Granted, Some(&stale_snapshot)); + let dto = full_disk_access_dto(FullDiskAccess::Granted, Some(&stale_snapshot), true); assert_eq!(dto.host, "granted"); assert_eq!(dto.monitor, Some(false)); @@ -724,12 +764,12 @@ fn full_disk_access_dto_reports_both_identities() { assert!(dto.granted); let fresh_snapshot = snapshot_with_fda(Some(false), false); - let dto = full_disk_access_dto(FullDiskAccess::Granted, Some(&fresh_snapshot)); + let dto = full_disk_access_dto(FullDiskAccess::Granted, Some(&fresh_snapshot), true); assert!(dto.monitor_fresh); // Fresh monitor without the grant: its answer wins. assert!(!dto.granted); - let dto = full_disk_access_dto(FullDiskAccess::Denied, None); + let dto = full_disk_access_dto(FullDiskAccess::Denied, None, true); assert_eq!(dto.host, "denied"); assert_eq!(dto.monitor, None); assert!(!dto.monitor_fresh); @@ -738,12 +778,13 @@ fn full_disk_access_dto_reports_both_identities() { #[test] fn full_disk_access_message_explains_each_state() { - let granted = full_disk_access_dto(FullDiskAccess::Granted, None); + let granted = full_disk_access_dto(FullDiskAccess::Granted, None, true); assert_eq!(full_disk_access_message(&granted), "granted to Git-Same"); let stale_monitor = full_disk_access_dto( FullDiskAccess::Granted, Some(&snapshot_with_fda(Some(false), true)), + true, ); assert!( stale_monitor.granted, @@ -753,22 +794,23 @@ fn full_disk_access_message_explains_each_state() { let fresh_lagging_monitor = full_disk_access_dto( FullDiskAccess::Granted, Some(&snapshot_with_fda(Some(false), false)), + true, ); assert!(full_disk_access_message(&fresh_lagging_monitor).contains("restart the monitor")); - let denied = full_disk_access_dto(FullDiskAccess::Denied, None); + let denied = full_disk_access_dto(FullDiskAccess::Denied, None, true); assert_eq!( full_disk_access_message(&denied), "not granted (required for Finder badges)" ); - let unknown = full_disk_access_dto(FullDiskAccess::Unknown, None); + let unknown = full_disk_access_dto(FullDiskAccess::Unknown, None, true); assert_eq!( full_disk_access_message(&unknown), "could not be determined" ); - let not_applicable = full_disk_access_dto(FullDiskAccess::NotApplicable, None); + let not_applicable = full_disk_access_dto(FullDiskAccess::NotApplicable, None, true); assert_eq!( full_disk_access_message(¬_applicable), "not applicable on this platform" diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index 353d82c..96515d2 100644 --- a/crates/git-same-app/src/main.rs +++ b/crates/git-same-app/src/main.rs @@ -32,6 +32,7 @@ fn main() { commands::monitor_launch_agent_status, commands::install_monitor_launch_agent, commands::restart_monitor_launch_agent, + commands::restart_monitor_if_agent_installed, commands::discover_provider_orgs, commands::read_workspace_structure, commands::read_status, @@ -52,27 +53,16 @@ fn main() { let host_ipc = git_same_core::ipc::IpcConfig::host_status_path()?; app.manage(commands::HostIpc(host_ipc.clone())); - // A leftover symlink at the host status.json path means an old - // monitor build is still running (only pre-upgrade monitors symlink - // it into the container; the current monitor writes a real mirror - // file). Best-effort restart the installed monitor so the upgraded - // build takes over and starts mirroring, instead of the app showing - // stale status until the user restarts it by hand. symlink_metadata - // does not follow the link, so this never reaches into the app-group - // container (no "access data from other apps" TCC prompt). Run on a - // background thread so the synchronous launchctl calls do not block - // app startup. - let host_status_is_symlink = host_ipc - .status_file_path() - .symlink_metadata() - .map(|meta| meta.file_type().is_symlink()) - .unwrap_or(false); - if host_status_is_symlink { - std::thread::spawn(move || { - if let Err(error) = commands::restart_monitor_if_installed() { - eprintln!("failed to restart monitor after upgrade: {error}"); - } - }); + // An old monitor build can still be running after an upgrade: it + // writes the container but never mirrors a real status.json into the + // host dir, so the app would show "monitor not running" until the + // user restarted it by hand. Restart the installed service so the + // upgraded build takes over. See `monitor_needs_startup_recovery` + // for the two signals. Never installs a service implicitly, and runs + // through the shared monitor-operation lock so it serializes with + // `ensure_monitor_on_startup` below and publishes its result. + if commands::monitor_needs_startup_recovery(&host_ipc) { + commands::recover_monitor_on_startup(app.handle().clone()); } if let Err(error) = status_stream::spawn_watcher(app.handle().clone(), host_ipc) { diff --git a/crates/git-same-app/ui/src/lib/tauri.ts b/crates/git-same-app/ui/src/lib/tauri.ts index 24c61fb..275c383 100644 --- a/crates/git-same-app/ui/src/lib/tauri.ts +++ b/crates/git-same-app/ui/src/lib/tauri.ts @@ -85,6 +85,11 @@ export function restartMonitorLaunchAgent(): Promise { + return invoke('restart_monitor_if_agent_installed'); +} + export function discoverProviderOrgs( provider: WorkspaceProviderDto, ): Promise { diff --git a/crates/git-same-app/ui/src/stores/status.test.ts b/crates/git-same-app/ui/src/stores/status.test.ts index e21d8c4..8a58902 100644 --- a/crates/git-same-app/ui/src/stores/status.test.ts +++ b/crates/git-same-app/ui/src/stores/status.test.ts @@ -9,6 +9,7 @@ const api = vi.hoisted(() => ({ readExtensionStatus: vi.fn(), readFullDiskAccess: vi.fn(), readAppConfig: vi.fn(), + restartMonitorIfAgentInstalled: vi.fn(), })); vi.mock('../lib/tauri', () => ({ @@ -40,6 +41,7 @@ vi.mock('../lib/tauri', () => ({ startSync: vi.fn(), enableFinderExtension: vi.fn(), restartMonitorLaunchAgent: vi.fn(), + restartMonitorIfAgentInstalled: api.restartMonitorIfAgentInstalled, })); const make = (updated_at: string | null, stale = false): StatusSnapshot => ({ @@ -61,6 +63,7 @@ beforeEach(() => { api.readExtensionStatus.mockResolvedValue(null); api.readFullDiskAccess.mockResolvedValue(null); api.readAppConfig.mockResolvedValue(null); + api.restartMonitorIfAgentInstalled.mockResolvedValue(null); }); describe('status store snapshot ordering', () => { @@ -102,3 +105,83 @@ describe('status store snapshot ordering', () => { expect(get(store.errorMessage)).toContain('unreadable'); }); }); + +// Full Disk Access is granted to the app but the running monitor predates the +// grant: the store restarts it once so its scans pick the grant up. +const laggingFda = { + host: 'granted', + monitor: false, + monitor_fresh: true, + granted: false, +}; + +const managedAgent = { installed: true, mode: 'managed' }; + +// `monitorStatus` lives in ./monitor; after resetModules it must be imported +// from the same fresh graph status.ts is bound to, or the store instances differ. +async function freshStores(agent: unknown) { + vi.resetModules(); + const monitor = await import('./monitor'); + const store = await import('./status'); + store.__resetMonitorKickForTests(); + monitor.monitorStatus.set(agent as never); + return store; +} + +describe('monitor kick on lagging Full Disk Access', () => { + it('restarts a managed monitor and re-reads Full Disk Access', async () => { + const store = await freshStores(managedAgent); + // Lagging on the first read, granted once the monitor has restarted. + api.readFullDiskAccess + .mockResolvedValueOnce(laggingFda) + .mockResolvedValueOnce({ ...laggingFda, monitor: true, granted: true }); + + await store.refreshPermissions(); + + expect(api.restartMonitorIfAgentInstalled).toHaveBeenCalledTimes(1); + expect(get(store.fullDiskAccess)).toMatchObject({ monitor: true, granted: true }); + }); + + it('never kicks a monitor the user started by hand', async () => { + // Foreground: the backend refuses to kill it, so a kick only raises an + // error on every refresh and focus. + const store = await freshStores({ installed: false, mode: 'foreground' }); + api.readFullDiskAccess.mockResolvedValue(laggingFda); + + await store.refreshPermissions(); + + expect(api.restartMonitorIfAgentInstalled).not.toHaveBeenCalled(); + expect(get(store.errorMessage)).toBe(''); + }); + + it('never kicks when no service is installed', async () => { + const store = await freshStores({ installed: false, mode: null }); + api.readFullDiskAccess.mockResolvedValue(laggingFda); + + await store.refreshPermissions(); + + expect(api.restartMonitorIfAgentInstalled).not.toHaveBeenCalled(); + }); + + it('retries after a failed restart instead of latching off recovery', async () => { + const store = await freshStores(managedAgent); + api.readFullDiskAccess.mockResolvedValue(laggingFda); + api.restartMonitorIfAgentInstalled.mockRejectedValue(new Error('launchctl busy')); + + await store.refreshPermissions(); + await store.refreshPermissions(); + + expect(api.restartMonitorIfAgentInstalled).toHaveBeenCalledTimes(2); + expect(get(store.errorMessage)).toContain('launchctl busy'); + }); + + it('kicks only once while a restart keeps succeeding', async () => { + const store = await freshStores(managedAgent); + api.readFullDiskAccess.mockResolvedValue(laggingFda); + + await store.refreshPermissions(); + await store.refreshPermissions(); + + expect(api.restartMonitorIfAgentInstalled).toHaveBeenCalledTimes(1); + }); +}); diff --git a/crates/git-same-app/ui/src/stores/status.ts b/crates/git-same-app/ui/src/stores/status.ts index c66606f..85c2c47 100644 --- a/crates/git-same-app/ui/src/stores/status.ts +++ b/crates/git-same-app/ui/src/stores/status.ts @@ -1,5 +1,5 @@ import { derived, get, writable } from 'svelte/store'; -import { runMonitorAction } from './monitor'; +import { monitorStatus, runMonitorAction } from './monitor'; import { createStatusSequencer } from '../lib/monitorPresentation'; import { checkRequirements, @@ -13,8 +13,8 @@ import { readExtensionStatus, readFullDiskAccess, readStatus, - restartMonitorLaunchAgent, readWorkspaceStructure, + restartMonitorIfAgentInstalled, saveAppConfig, setDefaultWorkspace, startSync, @@ -24,6 +24,7 @@ import type { AppConfigInput, ExtensionStatus, FullDiskAccessDto, + MonitorAgentStatusDto, ProgressEvent, RequirementCheckDto, StatusSnapshot, @@ -118,6 +119,26 @@ export async function refreshPermissions(): Promise { // restarted in a loop. let monitorKickPending = false; +/** Reset between tests; the latch is module state. */ +export function __resetMonitorKickForTests(): void { + monitorKickPending = false; +} + +/** + * Whether an automatic restart can actually help. + * + * Only a managed service may be restarted on the app's initiative. A monitor + * the user started by hand (`gisa monitor`) is foreground: the backend refuses + * to kill it, so kicking it would just raise the same error on every refresh + * and window focus. With nothing installed there is no service to restart, and + * the recovery command deliberately will not create one. + */ +function monitorIsRecoverable(status: MonitorAgentStatusDto | null): boolean { + if (!status) return false; + if (status.mode === 'foreground') return false; + return status.installed; +} + /** * The app holds Full Disk Access but the running monitor was started before * the grant landed (macOS applies TCC grants on process start). Restart it @@ -131,11 +152,24 @@ async function kickMonitorIfLagging(fda: FullDiskAccessDto | null): Promise { - let spinner_char = SPINNER[(state.tick_count as usize) % SPINNER.len()]; + let spinner_char = crate::tui::widgets::spinner::frame(state.tick_count); lines.push(Line::from(Span::styled( format!("{} Authenticating...", spinner_char), Style::default().fg(Color::Yellow), diff --git a/crates/git-same-cli/src/setup/screens/orgs.rs b/crates/git-same-cli/src/setup/screens/orgs.rs index dbd40b0..904c914 100644 --- a/crates/git-same-cli/src/setup/screens/orgs.rs +++ b/crates/git-same-cli/src/setup/screens/orgs.rs @@ -7,12 +7,6 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -/// Braille spinner frames (same as auth). -const SPINNER: [char; 10] = [ - '\u{280b}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283c}', '\u{2834}', '\u{2826}', '\u{2827}', - '\u{2807}', '\u{280f}', -]; - pub fn render(state: &SetupState, frame: &mut Frame, area: Rect) { let mut lines: Vec = Vec::new(); @@ -54,7 +48,7 @@ pub fn render(state: &SetupState, frame: &mut Frame, area: Rect) { // Content if state.org_loading { - let spinner_char = SPINNER[(state.tick_count as usize) % SPINNER.len()]; + let spinner_char = crate::tui::widgets::spinner::frame(state.tick_count); lines.push(Line::from(Span::styled( format!(" {} Discovering organizations...", spinner_char), Style::default().fg(Color::Yellow), diff --git a/crates/git-same-cli/src/setup/screens/requirements.rs b/crates/git-same-cli/src/setup/screens/requirements.rs index 5eade85..c0a2ec7 100644 --- a/crates/git-same-cli/src/setup/screens/requirements.rs +++ b/crates/git-same-cli/src/setup/screens/requirements.rs @@ -30,9 +30,7 @@ pub fn render(state: &SetupState, frame: &mut Frame, area: Rect) { // Check list or spinner if state.checks_loading { - let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let frame_idx = (state.tick_count as usize / 2) % spinner_frames.len(); - let spinner = spinner_frames[frame_idx]; + let spinner = crate::tui::widgets::spinner::frame(state.tick_count); let loading = Paragraph::new(Line::from(vec![ Span::styled( format!(" {} ", spinner), diff --git a/crates/git-same-cli/src/tui/backend.rs b/crates/git-same-cli/src/tui/backend.rs index 2e116d8..ba16e6a 100644 --- a/crates/git-same-cli/src/tui/backend.rs +++ b/crates/git-same-cli/src/tui/backend.rs @@ -309,8 +309,6 @@ pub fn spawn_operation(operation: Operation, app: &App, tx: UnboundedSender { - let workspace = app.active_workspace.clone(); - let config = app.config.clone(); tokio::spawn(async move { run_status_scan(config, workspace, tx).await; }); diff --git a/crates/git-same-cli/src/tui/handler.rs b/crates/git-same-cli/src/tui/handler.rs index 84cd074..42f91b1 100644 --- a/crates/git-same-cli/src/tui/handler.rs +++ b/crates/git-same-cli/src/tui/handler.rs @@ -311,7 +311,18 @@ fn handle_backend_message( app.all_repos = repos; } BackendMessage::DiscoveryError(msg) => { - app.operation_state = OperationState::Idle; + // Discovery errors are per-org and non-fatal: the provider logs the + // failure and keeps walking the remaining orgs, so the operation is + // still in flight. Resetting to Idle here would drop the guards that + // keep a status refresh (and a second sync) from starting mid-run, + // and the operation would then finish into an already-idle state. + // Only clear state that is not backed by a running task. + if matches!( + app.operation_state, + OperationState::Idle | OperationState::Finished { .. } + ) { + app.operation_state = OperationState::Idle; + } app.error_message = Some(msg); } BackendMessage::SetupOrgsDiscovered(orgs) => { @@ -500,6 +511,10 @@ fn handle_backend_message( *total_new_commits, started_at.elapsed().as_secs_f64(), ), + // A run that short-circuits (for example an empty repo set) + // completes while still Discovering; keep its real operation so + // a Status run is never recorded as a sync. + OperationState::Discovering { operation, .. } => (*operation, 0, 0, 0, 0, 0.0), _ => (Operation::Sync, 0, 0, 0, 0, 0.0), }; @@ -569,6 +584,9 @@ fn handle_backend_message( } BackendMessage::OperationError(msg) => { app.operation_state = OperationState::Idle; + // A failed status scan must not leave the flag latched: it gates both + // the next refresh and every sync. + app.status_loading = false; app.error_message = Some(msg); } BackendMessage::StatusResults(entries) => { diff --git a/crates/git-same-cli/src/tui/handler_tests.rs b/crates/git-same-cli/src/tui/handler_tests.rs index 246304e..c339cb6 100644 --- a/crates/git-same-cli/src/tui/handler_tests.rs +++ b/crates/git-same-cli/src/tui/handler_tests.rs @@ -334,3 +334,154 @@ async fn operation_complete_starts_one_guarded_status_refresh() { "the following dashboard tick must not launch a duplicate status scan" ); } + +#[test] +fn discovery_error_keeps_an_in_flight_operation_running() { + let ws = WorkspaceConfig::new_from_root(std::path::Path::new("/tmp/test-ws")); + let mut app = App::new(Config::default(), vec![ws], false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = running_state(Operation::Sync); + + // A per-org discovery failure is non-fatal in the provider: the sync task is + // still running, so the state must not fall back to Idle. + handle_backend_message( + &mut app, + BackendMessage::DiscoveryError("Error fetching repos for acme: 404".to_string()), + &tx, + ); + + assert!(matches!( + app.operation_state, + OperationState::Running { + operation: Operation::Sync, + .. + } + )); + assert!(app.error_message.is_some()); +} + +#[test] +fn discovery_error_keeps_a_discovering_operation_running() { + let ws = WorkspaceConfig::new_from_root(std::path::Path::new("/tmp/test-ws")); + let mut app = App::new(Config::default(), vec![ws], false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = OperationState::Discovering { + operation: Operation::Sync, + message: "Starting Sync...".to_string(), + }; + + handle_backend_message( + &mut app, + BackendMessage::DiscoveryError("Error fetching repos for acme: 404".to_string()), + &tx, + ); + + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } + )); +} + +#[test] +fn discovery_error_still_reports_when_no_operation_is_running() { + let ws = WorkspaceConfig::new_from_root(std::path::Path::new("/tmp/test-ws")); + let mut app = App::new(Config::default(), vec![ws], false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = OperationState::Idle; + + handle_backend_message( + &mut app, + BackendMessage::DiscoveryError("no orgs configured".to_string()), + &tx, + ); + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert_eq!( + app.error_message.as_deref(), + Some("no orgs configured"), + "a discovery error outside a run must still surface" + ); +} + +#[tokio::test] +async fn discovery_error_during_sync_does_not_open_a_status_refresh_window() { + let ws = WorkspaceConfig::new_from_root(std::path::Path::new("/tmp/test-ws")); + let mut app = App::new(Config::default(), vec![ws], false); + let (tx, mut rx) = unbounded_channel(); + app.screen = Screen::Dashboard; + app.operation_state = running_state(Operation::Sync); + + handle_backend_message( + &mut app, + BackendMessage::DiscoveryError("Error fetching repos for acme: 404".to_string()), + &tx, + ); + // The dashboard tick previously saw an Idle state here and started a scan + // that was still in flight when the sync completed. + handle_event(&mut app, AppEvent::Tick, &tx).await; + + assert!(!app.status_loading); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()) + .await + .is_err(), + "no status scan may start while the sync task is still running" + ); +} + +#[test] +fn operation_error_clears_the_status_refresh_flag() { + let ws = WorkspaceConfig::new_from_root(std::path::Path::new("/tmp/test-ws")); + let mut app = App::new(Config::default(), vec![ws], false); + let (tx, _rx) = unbounded_channel(); + app.status_loading = true; + + handle_backend_message( + &mut app, + BackendMessage::OperationError("scan failed".to_string()), + &tx, + ); + + assert!( + !app.status_loading, + "a latched flag would block every later refresh and sync" + ); + assert!(matches!(app.operation_state, OperationState::Idle)); +} + +#[test] +fn operation_complete_from_discovering_keeps_its_own_operation() { + let ws = WorkspaceConfig::new_from_root(std::path::Path::new("/tmp/test-ws")); + let mut app = App::new(Config::default(), vec![ws], false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = OperationState::Discovering { + operation: Operation::Status, + message: "Scanning...".to_string(), + }; + + handle_backend_message( + &mut app, + BackendMessage::OperationComplete(OpSummary::new()), + &tx, + ); + + assert!( + matches!( + app.operation_state, + OperationState::Finished { + operation: Operation::Status, + .. + } + ), + "a Status run that short-circuits must not be recorded as a sync" + ); + assert!( + app.active_workspace + .as_ref() + .is_none_or(|ws| ws.last_synced.is_none()), + "a Status completion must not stamp last_synced" + ); +} diff --git a/crates/git-same-core/src/config/workspace_store_tests.rs b/crates/git-same-core/src/config/workspace_store_tests.rs index d492ad4..125913a 100644 --- a/crates/git-same-core/src/config/workspace_store_tests.rs +++ b/crates/git-same-core/src/config/workspace_store_tests.rs @@ -1,11 +1,10 @@ use super::*; +use crate::test_support::lock_env; use std::path::Path; -use std::sync::Mutex; - -static HOME_LOCK: Mutex<()> = Mutex::new(()); fn with_temp_home(home: &Path, f: impl FnOnce() -> T) -> T { - let _lock = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Crate-wide, so the env readers in `ipc::mod_tests` are held off too. + let _lock = lock_env(); let original_home = std::env::var("HOME").ok(); let original_userprofile = std::env::var("USERPROFILE").ok(); let original_xdg_config_home = std::env::var("XDG_CONFIG_HOME").ok(); diff --git a/crates/git-same-core/src/ipc/mod_tests.rs b/crates/git-same-core/src/ipc/mod_tests.rs index af7e011..97f4f68 100644 --- a/crates/git-same-core/src/ipc/mod_tests.rs +++ b/crates/git-same-core/src/ipc/mod_tests.rs @@ -49,9 +49,11 @@ fn test_app_group_id_has_team_prefix() { #[cfg(target_os = "macos")] #[test] fn test_macos_group_container_dir_includes_app_group_segment() { - // We don't mutate HOME (env mutation races with parallel tests); instead - // we just assert that, when HOME is set in the inherited environment, the - // function returns a path under Library/Group Containers/. + // We never mutate HOME here; we assert that, when HOME is set in the + // inherited environment, the function returns a path under + // Library/Group Containers/. The lock keeps the tests that do + // swap HOME from changing it underneath us. + let _env = crate::test_support::lock_env(); if let Some(dir) = macos_group_container_dir() { let dir_str = dir.to_string_lossy(); assert!( @@ -71,6 +73,7 @@ fn test_macos_group_container_dir_includes_app_group_segment() { #[cfg(target_os = "macos")] #[test] fn test_default_path_uses_group_container_on_macos() { + let _env = crate::test_support::lock_env(); if std::env::var_os("HOME").is_none() { return; } @@ -90,7 +93,9 @@ fn test_default_path_uses_group_container_on_macos() { #[test] fn test_legacy_default_path_ends_in_finder() { // legacy_default_path leans on Config::default_path which respects XDG - // env vars; we just sanity-check the suffix. + // env vars; we just sanity-check the suffix. The lock holds off the tests + // that swap HOME/XDG_CONFIG_HOME process-wide while we read them. + let _env = crate::test_support::lock_env(); if let Ok(cfg) = IpcConfig::legacy_default_path() { assert!( cfg.dir.ends_with("git-same/finder"), @@ -104,6 +109,9 @@ fn test_legacy_default_path_ends_in_finder() { fn test_host_status_path_matches_legacy_default_path() { // The host reads from the non-container host path; it must resolve to the // same directory as legacy_default_path (a distinct name for clarity). + // Both calls read the environment, so they must see the same one: without + // the lock a concurrent HOME swap between them fails the comparison below. + let _env = crate::test_support::lock_env(); let host = IpcConfig::host_status_path(); let legacy = IpcConfig::legacy_default_path(); match (host, legacy) { @@ -136,6 +144,7 @@ fn test_status_writer_has_no_mirrors_for_custom_dir() { #[cfg(target_os = "macos")] #[test] fn test_status_writer_mirrors_host_status_for_group_container() { + let _env = crate::test_support::lock_env(); if std::env::var_os("HOME").is_none() { return; } diff --git a/crates/git-same-core/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs index e501b45..f7b67a4 100644 --- a/crates/git-same-core/src/ipc/status_file.rs +++ b/crates/git-same-core/src/ipc/status_file.rs @@ -220,10 +220,59 @@ pub fn ensure_legacy_symlinks(group_dir: &Path) -> Result<(), AppError> { fn ensure_legacy_symlinks_in(legacy_dir: &Path, group_dir: &Path) -> Result<(), AppError> { // Only the socket is symlinked; status.json is a real mirror file written // by the monitor (see the doc comment on `ensure_legacy_symlinks`). The - // socket helper creates the legacy directory as needed. + // socket helper creates the legacy directory as needed. Done first so a + // problem with the status entry can never block the socket migration. let legacy_sock = legacy_dir.join("finder.sock"); let target_sock = group_dir.join("finder.sock"); - ensure_one_symlink(&legacy_sock, &target_sock) + ensure_one_symlink(&legacy_sock, &target_sock)?; + + // status.json is not symlinked, but an unusable entry there still has to be + // cleared or every mirror write fails forever. + clear_unusable_status_entry(&legacy_dir.join("status.json")) +} + +/// Renames aside anything at the host `status.json` path that the mirror write +/// cannot replace. +/// +/// A regular file and a symlink are both fine: `write_atomic` renames over them. +/// A directory (or any other node) is not; `fs::rename` fails with `EISDIR` / +/// `ENOTDIR`, and because a mirror failure is only a warning the monitor keeps +/// running with the host status permanently absent, with no way back short of +/// deleting the entry by hand. Moving it aside at startup keeps the migration +/// recoverable, matching how `ensure_one_symlink` treats the socket path. +#[cfg(target_os = "macos")] +fn clear_unusable_status_entry(status_path: &Path) -> Result<(), AppError> { + let meta = match std::fs::symlink_metadata(status_path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(AppError::path(format!( + "Failed to inspect '{}': {}", + status_path.display(), + e + ))); + } + }; + let file_type = meta.file_type(); + if file_type.is_file() || file_type.is_symlink() { + return Ok(()); + } + + let aside = aside_path(status_path); + std::fs::rename(status_path, &aside).map_err(|e| { + AppError::path(format!( + "Failed to rename legacy status entry '{}' to '{}': {}", + status_path.display(), + aside.display(), + e + )) + })?; + tracing::warn!( + legacy = %status_path.display(), + aside = %aside.display(), + "Renamed unusable legacy status.json entry aside so the mirror write can recover" + ); + Ok(()) } /// Non-macOS no-op so the monitor can call this unconditionally without `cfg` diff --git a/crates/git-same-core/src/ipc/status_file_tests.rs b/crates/git-same-core/src/ipc/status_file_tests.rs index c81b36e..2bd7c2a 100644 --- a/crates/git-same-core/src/ipc/status_file_tests.rs +++ b/crates/git-same-core/src/ipc/status_file_tests.rs @@ -114,9 +114,17 @@ fn test_write_produces_primary_and_every_mirror() { let status = sample_status(); writer.write(&status).unwrap(); - // Both files exist as real files with identical content. - assert!(primary.exists()); - assert!(mirror.exists()); + // Both entries must be REAL FILES, not symlinks. `exists()` follows links, + // so it cannot catch a regression that reinstates the cross-container + // symlink this whole layout exists to avoid. + assert!(std::fs::symlink_metadata(&primary) + .unwrap() + .file_type() + .is_file()); + assert!(std::fs::symlink_metadata(&mirror) + .unwrap() + .file_type() + .is_file()); assert_eq!( std::fs::read_to_string(&primary).unwrap(), std::fs::read_to_string(&mirror).unwrap() @@ -335,4 +343,66 @@ mod symlink_helper { assert!(sock_meta.file_type().is_symlink()); assert_eq!(fs::read_link(&sock).unwrap(), group.join("finder.sock")); } + #[test] + fn migration_moves_aside_a_directory_at_the_legacy_status_path() { + let (_root, legacy, group) = dirs(); + let status = legacy.join("status.json"); + fs::create_dir_all(status.join("nested")).unwrap(); + + ensure_legacy_symlinks_in(&legacy, &group).unwrap(); + + // The directory is gone from the mirror path, so write_atomic's rename + // can land there instead of failing EISDIR on every write. + assert!( + fs::symlink_metadata(&status).is_err(), + "the blocking directory must be moved out of the way" + ); + let aside: Vec<_> = fs::read_dir(&legacy) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with("status.json.user-saved-") + }) + .collect(); + assert_eq!(aside.len(), 1, "contents must be preserved, not deleted"); + assert!(aside[0].path().join("nested").is_dir()); + + // And a real mirror write now succeeds at that path. + let writer = + StatusFileWriter::new_with_mirrors(group.join("status.json"), vec![status.clone()]); + writer.write(&sample_status()).unwrap(); + assert!(fs::symlink_metadata(&status).unwrap().file_type().is_file()); + } + + #[test] + fn migration_leaves_a_real_legacy_status_file_alone() { + let (_root, legacy, group) = dirs(); + let status = legacy.join("status.json"); + fs::write(&status, "{\"keep\":true}").unwrap(); + + ensure_legacy_symlinks_in(&legacy, &group).unwrap(); + + assert_eq!(fs::read_to_string(&status).unwrap(), "{\"keep\":true}"); + assert!(fs::symlink_metadata(&status).unwrap().file_type().is_file()); + } + + #[test] + fn migration_leaves_a_symlinked_legacy_status_alone() { + let (_root, legacy, group) = dirs(); + let status = legacy.join("status.json"); + let target = group.join("status.json"); + fs::write(&target, "{}").unwrap(); + symlink(&target, &status).unwrap(); + + ensure_legacy_symlinks_in(&legacy, &group).unwrap(); + + // The mirror write and read_status_snapshot_with already handle links; + // moving them aside would lose the recovery path they encode. + assert!(fs::symlink_metadata(&status) + .unwrap() + .file_type() + .is_symlink()); + } } diff --git a/crates/git-same-core/src/lib.rs b/crates/git-same-core/src/lib.rs index 47fc16d..ced9065 100644 --- a/crates/git-same-core/src/lib.rs +++ b/crates/git-same-core/src/lib.rs @@ -34,6 +34,24 @@ pub mod setup; pub mod types; pub mod workflows; +/// Shared helpers for this crate's unit tests. +#[cfg(test)] +pub(crate) mod test_support { + /// Serializes tests that read or write process-wide environment variables. + /// + /// `HOME`, `XDG_CONFIG_HOME` and `GIT_SAME_CONFIG_DIR` are process state, so + /// a test that swaps them races every test that resolves a config or IPC + /// path. Both sides must take this lock: a per-file lock only disciplines + /// the writers and still lets readers in other modules observe a temp home + /// mid-flight. Poisoning is ignored so one failing test does not cascade. + pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Acquire [`ENV_LOCK`], ignoring poisoning from an unrelated failure. + pub(crate) fn lock_env() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } +} + /// Re-export commonly used types for convenience. pub mod prelude { pub use crate::auth::{get_auth, get_auth_for_provider, AuthResult}; From eaf1dc43b7b080c0120f5e9a29c2621d60ae7b19 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 21 Sep 2026 22:48:39 +0200 Subject: [PATCH 18/21] Gate macOS-only items so clippy passes off macOS The app-identity commit left `use std::process::Command` and the `render_for_app` plist test helper reachable only from macOS- and unix-gated code, so `-D warnings` failed the ubuntu and windows S1 legs while macOS stayed green. Spell the one bare `Command` call site out in full and drop the import, and gate `render_for_app` plus its `source` import with `#[cfg(unix)]` to match their only caller. Verified with a containerized Linux `cargo +stable clippy --workspace --all-targets --all-features -- -D warnings`, which is the exact S1 command and cannot be run natively on macOS. --- crates/git-same-app/src/commands.rs | 3 +-- crates/git-same-core/src/macos/monitor_agent/plist_tests.rs | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 0940792..35200b8 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -24,7 +24,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, SystemTime}; @@ -881,7 +880,7 @@ impl ExtensionElection { fn set_extension_election(election: ExtensionElection) -> Result<(), AppError> { #[cfg(target_os = "macos")] { - let output = Command::new("/usr/bin/pluginkit") + let output = std::process::Command::new("/usr/bin/pluginkit") .args(["-e", election.pluginkit_arg(), "-i", FINDER_EXTENSION_ID]) .output() .map_err(|error| AppError::config(format!("pluginkit invocation failed: {error}")))?; diff --git a/crates/git-same-core/src/macos/monitor_agent/plist_tests.rs b/crates/git-same-core/src/macos/monitor_agent/plist_tests.rs index 035a0da..f5e15fb 100644 --- a/crates/git-same-core/src/macos/monitor_agent/plist_tests.rs +++ b/crates/git-same-core/src/macos/monitor_agent/plist_tests.rs @@ -1,4 +1,6 @@ use super::*; +// Only the `#[cfg(unix)]` app-owned render below needs these. +#[cfg(unix)] use crate::macos::monitor_agent::source::{app_main_executable, APP_BUNDLE_ID}; fn render_for(home: &str, bundle: Option<&str>) -> String { @@ -9,6 +11,7 @@ fn render_for(home: &str, bundle: Option<&str>) -> String { /// An app-owned installation execs the bundle's own main executable, so the /// Full Disk Access grant for "Git-Same" covers the monitor. +#[cfg(unix)] fn render_for_app(home: &str, bundle_path: &str) -> String { let paths = MonitorAgentPaths::for_home(Path::new(home)); let program = app_main_executable(Path::new(bundle_path)); From 643ab4e6468668042a2f46b439549136c4ab3d7d Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 21 Sep 2026 23:05:22 +0200 Subject: [PATCH 19/21] Derive plist path in test so Windows separators match The cask install test compared the rendered LaunchAgent against `app.join("Contents/MacOS/git-same-app")`, a slash literal, while the plist carries the three-join form that renders as backslashes on Windows, so the only failing test of the S1 windows leg was a separator mismatch rather than a behaviour difference. Build the expectation with `source::app_main_executable` so the assertion keeps its meaning on every platform instead of being gated off. --- .../src/macos/monitor_agent/controller_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/git-same-core/src/macos/monitor_agent/controller_tests.rs b/crates/git-same-core/src/macos/monitor_agent/controller_tests.rs index 8793f83..e5f3c88 100644 --- a/crates/git-same-core/src/macos/monitor_agent/controller_tests.rs +++ b/crates/git-same-core/src/macos/monitor_agent/controller_tests.rs @@ -751,9 +751,12 @@ fn cask_install_starts_monitoring_without_the_app() { // The monitor runs the bundle executable itself: a copy under the // managed root would be a TCC identity the app's grant never reaches. assert!(!env.paths.helper.exists()); + // Built with `join`, not a slash literal: the rendered plist carries the + // platform separator, so a POSIX literal never matches on Windows. + let executable = source::app_main_executable(&app); assert!(std::fs::read_to_string(&env.paths.launch_agent) .unwrap() - .contains(app.join("Contents/MacOS/git-same-app").to_str().unwrap())); + .contains(executable.to_str().unwrap())); assert!(std::fs::read_to_string(&env.paths.launch_agent) .unwrap() .contains("AssociatedBundleIdentifiers")); From 12ada567ee3f54199cb0e6d25a5d39d31f4e2031 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 21 Sep 2026 23:07:50 +0200 Subject: [PATCH 20/21] Update acceptance matrix for the app-identity agent Section 8 still described the pre-3.2 helper model, so the release gate never checked the behaviour this version exists to deliver. The LaunchAgent now execs the bundle's own main executable in place, which is what makes one Full Disk Access grant cover the monitor, so the matrix gains rows for the pre-3.2 agent re-render, build skew after an app upgrade, the Full Disk Access gate in both directions, and a moved or deleted bundle. Rows that still assume a copied helper are reworded for CLI owners, where that copy and its separate TCC identity remain. Also record the two hand-maintained versions (ui/package.json and tauri.conf.json) in section 1, which no CI job gates. --- toolkit/packaging/release-checklist.md | 27 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/toolkit/packaging/release-checklist.md b/toolkit/packaging/release-checklist.md index 057621a..bd3673a 100644 --- a/toolkit/packaging/release-checklist.md +++ b/toolkit/packaging/release-checklist.md @@ -8,6 +8,7 @@ manual `workflow_dispatch` workflows under `.github/workflows/`. - [ ] Working tree clean on `main`, all PRs merged. - [ ] Bump `version` in `Cargo.toml` (and confirm `Cargo.lock` regenerates clean: `cargo build`). - [ ] Bump `CFBundleShortVersionString` and `CFBundleVersion` in `macos/GitSameBadges/Info.plist` to the same version (hand-maintained; S1 gates the match, `plutil -lint` does not). +- [ ] Bump `version` in `crates/git-same-app/ui/package.json` and `crates/git-same-app/tauri.conf.json` to the same version (both hand-maintained). - [ ] Update `CHANGELOG` / release notes draft if applicable. - [ ] Smoke-render the Homebrew artifacts locally: ```sh @@ -61,16 +62,24 @@ manual `workflow_dispatch` workflows under `.github/workflows/`. Never run these against a developer's own login: they install, start, stop, and remove the real LaunchAgent. `brew reinstall` of the cask needs a terminal with sudo access. -After every step check `gisa monitor --status`, `launchctl print gui/$(id -u)/com.zaai.git-same.monitor`, and that exactly one `git-same monitor` process exists. +Since 3.2.0 an app-owned agent execs the bundle's own main executable in place (`/Git-Same.app/Contents/MacOS/git-same-app monitor --foreground --managed`) and copies nothing into the managed root. macOS attributes a launchd-spawned process to its bundle only when the executable is the bundle's `CFBundleExecutable`, which is what lets a single Full Disk Access grant for "Git-Same" cover the monitor. CLI owners (`cargo install`, the formula) still install the copied helper under `~/Library/Application Support/com.zaai.git-same/monitor/`, and that path keeps its own TCC identity. + +After every step check `gisa monitor --status`, `launchctl print gui/$(id -u)/com.zaai.git-same.monitor`, and that exactly one monitor process exists: `git-same-app monitor` for an app-owned agent, `git-same monitor` for a CLI-owned one. For an app-owned agent also confirm the plist's `Program` and `argv[0]` are the bundle executable and that nothing was copied to `~/Library/Application Support/com.zaai.git-same/monitor/git-same`. | Scenario | Required result | |---|---| -| Fresh signed cask install | Monitor active without opening the app | -| Upgrade from the 3.1.1 cask | Legacy cleanup, new helper running, no second monitor | -| Upgrade between new versions | Preference preserved, helper updated | -| `brew upgrade` of the cask | Homebrew runs the *old* cask's uninstall stanza first, so the helper, plist, and `install.json` are removed and recreated: expect a new monitor PID and a new `install.json`. Required result: monitoring is `running` again once the upgrade returns, the helper reports the new version, and badges refresh within one scan. A brief badge gap during the swap is expected, not a defect. See `docs/plans/monitor-continuity-across-cask-upgrades.md` for the deferred fix | +| Fresh signed cask install | Monitor active without opening the app. The plist `Program` is `/Git-Same.app/Contents/MacOS/git-same-app` and the managed root holds no helper copy | +| Upgrade from the 3.1.2 cask | Legacy helper copy removed, the agent re-rendered onto the bundle executable, no second monitor | +| Pre-3.2 agent still installed, app launched once | Startup recovery re-renders the plist onto the bundle executable and restarts the monitor exactly once; `install.json` records the app as owner | +| App upgraded while the old monitor keeps running | The app flags the build skew and restarts the installed agent on launch; `status.json` `monitor_version` matches the app afterwards | +| Full Disk Access not yet granted | The badge checklist stops at step 2, `enable_finder_extension` refuses to set the pluginkit election, and `status.json` reports `full_disk_access` denied or unknown | +| Full Disk Access granted to Git-Same | After quitting and reopening the app (macOS applies a grant at process start) the app restarts the monitor by itself, `status.json` reports the monitor as granted, and the enable step unlocks | +| Full Disk Access revoked while running | The next probe reports denied, the app surfaces the gate again, and badges stop claiming coverage they no longer have | +| App bundle moved or deleted with an agent installed | Monitoring stops, because the agent execs the bundle executable, and the app reports the broken source instead of silently falling back to a copy | +| Upgrade between new versions | Preference preserved, agent updated | +| `brew upgrade` of the cask | Homebrew runs the *old* cask's uninstall stanza first, so the helper, plist, and `install.json` are removed and recreated: expect a new monitor PID and a new `install.json`. Required result: monitoring is `running` again once the upgrade returns, the monitor reports the new version, and badges refresh within one scan. A brief badge gap during the swap is expected, not a defect. See `docs/plans/monitor-continuity-across-cask-upgrades.md` for the deferred fix | | Reinstall while enabled | Exactly one monitor | -| Reinstall after `gisa monitor --stop` | Helper updated, no monitor started | +| Reinstall after `gisa monitor --stop` | Agent updated, no monitor started | | Custom `--appdir` | `install.json` owner and source paths point at the custom location | | App deleted before `brew uninstall` | Retained service tool removes the monitor | | Helper deleted before `brew uninstall` | Removal still completes | @@ -79,13 +88,13 @@ After every step check `gisa monitor --status`, `launchctl print gui/$(id -u)/co | Logout/login and full restart | Enabled monitor returns | | Stop, then restart the Mac | Monitor stays stopped | | Direct DMG app launch (no cask) | Monitor installs and starts on first launch | -| Formula CLI first eligible use (`gisa sync`) | Standalone managed installation works | +| Formula CLI first eligible use (`gisa sync`) | Standalone managed installation works, running the copied helper under its own TCC identity | | Cargo CLI first eligible use | Works, subject to normal macOS permissions | | App and CLI started simultaneously | One process, one status writer | | Helper with no config file | Runs idle, writes an empty status, no respawn loop | | Helper with a malformed config | Exits 0 once, file untouched, one line in `~/Library/Logs/git-same/monitor.err.log` | | Headless (SSH-only) install | State `deferred`, exit 0, starts at next GUI login | | 321-repository workspace | `starting` for the whole first scan, then `running`, no restarts | -| Signed helper outside the app bundle | Writes the app-group container; Finder badges render; note whether macOS asks for Full Disk Access again (the helper path changed, approvals are not carried over) | -| Roll back to the 3.1.1 cask | Old cask restores an app-bundle LaunchAgent; the next app or CLI run repairs it to the managed helper | +| CLI-owned helper outside the app bundle | Writes the app-group container; Finder badges render; Full Disk Access has to be granted to that helper path separately, because the app's grant never reaches it | +| Roll back to the 3.1.2 cask | The old cask restores its copied-helper LaunchAgent; a later 3.2 app launch re-renders it back onto the bundle executable | | Dev `toolkit/conductor/run.sh` with a cask monitor present | plist, PID, and `install.json` byte-identical afterwards | From 01bb2b945f684f48f2164b0b24383a2d43e314b1 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 21 Sep 2026 23:08:51 +0200 Subject: [PATCH 21/21] Bump vitest to 4.1.11 to clear Dependabot alerts All three open alerts are the same advisory: vitest >= 2.1.0 < 4.1.11 flagged against package.json and pnpm-lock.yaml, plus its transitive @vitest/mocker. The bump crosses a major, so it was verified rather than assumed: installed with the pinned pnpm 11.0.9 in a container, then svelte-check (0 errors), the 31 unit tests, and vite build all pass, and a second `--frozen-lockfile` install reproduces it exactly as CI does. The lockfile shrinks because vitest 3 carried its own vite 7 and vite-node, while vitest 4 reuses the vite 8 already pinned here. Only the vitest entry moves in package.json. Dev dependency only: nothing in a shipped artifact changes. --- crates/git-same-app/ui/package.json | 2 +- crates/git-same-app/ui/pnpm-lock.yaml | 905 ++++---------------------- 2 files changed, 113 insertions(+), 794 deletions(-) diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json index 15a69ea..0abc4e1 100644 --- a/crates/git-same-app/ui/package.json +++ b/crates/git-same-app/ui/package.json @@ -23,7 +23,7 @@ "svelte-check": "^4.7.1", "typescript": "^6.0.3", "vite": "^8.1.2", - "vitest": "^3.2.4" + "vitest": "^4.1.11" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/crates/git-same-app/ui/pnpm-lock.yaml b/crates/git-same-app/ui/pnpm-lock.yaml index ef6543a..50fcfb3 100644 --- a/crates/git-same-app/ui/pnpm-lock.yaml +++ b/crates/git-same-app/ui/pnpm-lock.yaml @@ -26,181 +26,25 @@ importers: devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^7.1.2 - version: 7.1.2(svelte@5.56.4)(vite@8.3.0(esbuild@0.28.2)) + version: 7.1.2(svelte@5.56.4)(vite@8.3.0) '@tauri-apps/cli': specifier: ^2.11.4 version: 2.11.5 svelte-check: specifier: ^4.7.1 - version: 4.7.1(picomatch@4.0.4)(svelte@5.56.4)(typescript@6.0.3) + version: 4.7.1(picomatch@4.0.7)(svelte@5.56.4)(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 vite: specifier: ^8.1.2 - version: 8.3.0(esbuild@0.28.2) + version: 8.3.0 vitest: - specifier: ^3.2.4 - version: 3.2.7(lightningcss@1.33.0) + specifier: ^4.1.11 + version: 4.1.11(vite@8.3.0) packages: - '@esbuild/aix-ppc64@0.28.2': - resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.2': - resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.2': - resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.2': - resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.2': - resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.2': - resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.2': - resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.2': - resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.2': - resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.2': - resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.2': - resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.2': - resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.2': - resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.2': - resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.2': - resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.2': - resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.2': - resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.2': - resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.2': - resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.2': - resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.2': - resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.2': - resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.2': - resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.2': - resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.2': - resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.2': - resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -222,13 +66,6 @@ packages: peerDependencies: svelte: ^5 - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} - engines: {node: ^22.20 || ^24.12 || >=25} - cpu: [x64] - os: [linux] - libc: [glibc] - '@oxc-project/types@0.150.0': resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} @@ -331,143 +168,8 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.63.4': - resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.63.4': - resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.63.4': - resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.63.4': - resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.63.4': - resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.63.4': - resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.63.4': - resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.63.4': - resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.63.4': - resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.63.4': - resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.63.4': - resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.63.4': - resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.63.4': - resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.63.4': - resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.63.4': - resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.63.4': - resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.63.4': - resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.63.4': - resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.63.4': - resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.63.4': - resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.63.4': - resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.63.4': - resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.63.4': - resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.63.4': - resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.63.4': - resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} - cpu: [x64] - os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@sveltejs/acorn-typescript@1.0.10': resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} @@ -579,34 +281,34 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@vitest/expect@3.2.7': - resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@3.2.7': - resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.2.7': - resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@3.2.7': - resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@3.2.7': - resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@3.2.7': - resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@3.2.7': - resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} @@ -625,18 +327,10 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -645,18 +339,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} @@ -669,13 +353,8 @@ packages: devalue@5.9.2: resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - esbuild@0.28.2: - resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} - engines: {node: '>=18'} - hasBin: true + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} @@ -712,9 +391,6 @@ packages: is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -792,9 +468,6 @@ packages: locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -802,9 +475,6 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.19: resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -817,17 +487,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -849,11 +511,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.63.4: - resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -868,11 +525,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} svelte-check@4.7.1: resolution: {integrity: sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==} @@ -894,23 +548,16 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@4.0.6: - resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} typescript@6.0.3: @@ -918,51 +565,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - - vite@7.3.6: - resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@8.3.0: resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1014,26 +616,39 @@ packages: vite: optional: true - vitest@3.2.7: - resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.7 - '@vitest/ui': 3.2.7 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -1052,84 +667,6 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.28.2': - optional: true - - '@esbuild/android-arm64@0.28.2': - optional: true - - '@esbuild/android-arm@0.28.2': - optional: true - - '@esbuild/android-x64@0.28.2': - optional: true - - '@esbuild/darwin-arm64@0.28.2': - optional: true - - '@esbuild/darwin-x64@0.28.2': - optional: true - - '@esbuild/freebsd-arm64@0.28.2': - optional: true - - '@esbuild/freebsd-x64@0.28.2': - optional: true - - '@esbuild/linux-arm64@0.28.2': - optional: true - - '@esbuild/linux-arm@0.28.2': - optional: true - - '@esbuild/linux-ia32@0.28.2': - optional: true - - '@esbuild/linux-loong64@0.28.2': - optional: true - - '@esbuild/linux-mips64el@0.28.2': - optional: true - - '@esbuild/linux-ppc64@0.28.2': - optional: true - - '@esbuild/linux-riscv64@0.28.2': - optional: true - - '@esbuild/linux-s390x@0.28.2': - optional: true - - '@esbuild/linux-x64@0.28.2': - optional: true - - '@esbuild/netbsd-arm64@0.28.2': - optional: true - - '@esbuild/netbsd-x64@0.28.2': - optional: true - - '@esbuild/openbsd-arm64@0.28.2': - optional: true - - '@esbuild/openbsd-x64@0.28.2': - optional: true - - '@esbuild/openharmony-arm64@0.28.2': - optional: true - - '@esbuild/sunos-x64@0.28.2': - optional: true - - '@esbuild/win32-arm64@0.28.2': - optional: true - - '@esbuild/win32-ia32@0.28.2': - optional: true - - '@esbuild/win32-x64@0.28.2': - optional: true - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1153,9 +690,6 @@ snapshots: dependencies: svelte: 5.56.4 - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - optional: true - '@oxc-project/types@0.150.0': {} '@rolldown/binding-android-arm-eabi@1.2.9': @@ -1205,80 +739,7 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.63.4': - optional: true - - '@rollup/rollup-android-arm64@4.63.4': - optional: true - - '@rollup/rollup-darwin-arm64@4.63.4': - optional: true - - '@rollup/rollup-darwin-x64@4.63.4': - optional: true - - '@rollup/rollup-freebsd-arm64@4.63.4': - optional: true - - '@rollup/rollup-freebsd-x64@4.63.4': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.63.4': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.63.4': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.63.4': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.63.4': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.63.4': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.63.4': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.63.4': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.63.4': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.63.4': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.63.4': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.63.4': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.63.4': - optional: true - - '@rollup/rollup-linux-x64-musl@4.63.4': - optional: true - - '@rollup/rollup-openbsd-x64@4.63.4': - optional: true - - '@rollup/rollup-openharmony-arm64@4.63.4': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.63.4': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.63.4': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.63.4': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.63.4': - optional: true + '@standard-schema/spec@1.1.0': {} '@sveltejs/acorn-typescript@1.0.10(acorn@8.17.0)': dependencies: @@ -1286,14 +747,14 @@ snapshots: '@sveltejs/load-config@0.2.0': {} - '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.3.0(esbuild@0.28.2))': + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.3.0)': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.3 svelte: 5.56.4 - vite: 8.3.0(esbuild@0.28.2) - vitefu: 1.1.3(vite@8.3.0(esbuild@0.28.2)) + vite: 8.3.0 + vitefu: 1.1.3(vite@8.3.0) '@tauri-apps/api@2.11.1': {} @@ -1359,47 +820,46 @@ snapshots: '@types/trusted-types@2.0.7': {} - '@vitest/expect@3.2.7': + '@vitest/expect@4.1.11': dependencies: + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 3.2.7 - '@vitest/utils': 3.2.7 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 - '@vitest/mocker@3.2.7(vite@7.3.6(lightningcss@1.33.0))': + '@vitest/mocker@4.1.11(vite@8.3.0)': dependencies: - '@vitest/spy': 3.2.7 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(lightningcss@1.33.0) + vite: 8.3.0 - '@vitest/pretty-format@3.2.7': + '@vitest/pretty-format@4.1.11': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.1 - '@vitest/runner@3.2.7': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 3.2.7 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - strip-literal: 3.1.0 - '@vitest/snapshot@3.2.7': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 3.2.7 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.7': - dependencies: - tinyspy: 4.0.6 + '@vitest/spy@4.1.11': {} - '@vitest/utils@3.2.7': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 3.2.7 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 acorn@8.17.0: {} @@ -1409,17 +869,7 @@ snapshots: axobject-query@4.1.0: {} - cac@6.7.14: {} - - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - check-error@2.1.3: {} + chai@6.2.2: {} chokidar@4.0.3: dependencies: @@ -1427,11 +877,7 @@ snapshots: clsx@2.1.1: {} - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@5.0.2: {} + convert-source-map@2.0.0: {} deepmerge@4.3.1: {} @@ -1439,36 +885,7 @@ snapshots: devalue@5.9.2: {} - es-module-lexer@1.7.0: {} - - esbuild@0.28.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.2 - '@esbuild/android-arm': 0.28.2 - '@esbuild/android-arm64': 0.28.2 - '@esbuild/android-x64': 0.28.2 - '@esbuild/darwin-arm64': 0.28.2 - '@esbuild/darwin-x64': 0.28.2 - '@esbuild/freebsd-arm64': 0.28.2 - '@esbuild/freebsd-x64': 0.28.2 - '@esbuild/linux-arm': 0.28.2 - '@esbuild/linux-arm64': 0.28.2 - '@esbuild/linux-ia32': 0.28.2 - '@esbuild/linux-loong64': 0.28.2 - '@esbuild/linux-mips64el': 0.28.2 - '@esbuild/linux-ppc64': 0.28.2 - '@esbuild/linux-riscv64': 0.28.2 - '@esbuild/linux-s390x': 0.28.2 - '@esbuild/linux-x64': 0.28.2 - '@esbuild/netbsd-arm64': 0.28.2 - '@esbuild/netbsd-x64': 0.28.2 - '@esbuild/openbsd-arm64': 0.28.2 - '@esbuild/openbsd-x64': 0.28.2 - '@esbuild/openharmony-arm64': 0.28.2 - '@esbuild/sunos-x64': 0.28.2 - '@esbuild/win32-arm64': 0.28.2 - '@esbuild/win32-ia32': 0.28.2 - '@esbuild/win32-x64': 0.28.2 + es-module-lexer@2.3.2: {} esm-env@1.2.2: {} @@ -1482,9 +899,9 @@ snapshots: expect-type@1.4.0: {} - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 fsevents@2.3.3: optional: true @@ -1493,8 +910,6 @@ snapshots: dependencies: '@types/estree': 1.0.9 - js-tokens@9.0.1: {} - lightningcss-android-arm64@1.33.0: optional: true @@ -1546,28 +961,20 @@ snapshots: locate-character@3.0.0: {} - loupe@3.2.1: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 mri@1.2.0: {} - ms@2.1.3: {} - nanoid@3.3.19: {} obug@2.1.3: {} pathe@2.0.3: {} - pathval@2.0.1: {} - picocolors@1.1.1: {} - picomatch@4.0.4: {} - picomatch@4.0.7: {} postcss@8.5.28: @@ -1601,38 +1008,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.9 '@rolldown/binding-win32-x64-msvc': 1.2.9 - rollup@4.63.4: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.63.4 - '@rollup/rollup-android-arm64': 4.63.4 - '@rollup/rollup-darwin-arm64': 4.63.4 - '@rollup/rollup-darwin-x64': 4.63.4 - '@rollup/rollup-freebsd-arm64': 4.63.4 - '@rollup/rollup-freebsd-x64': 4.63.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 - '@rollup/rollup-linux-arm-musleabihf': 4.63.4 - '@rollup/rollup-linux-arm64-gnu': 4.63.4 - '@rollup/rollup-linux-arm64-musl': 4.63.4 - '@rollup/rollup-linux-loong64-gnu': 4.63.4 - '@rollup/rollup-linux-loong64-musl': 4.63.4 - '@rollup/rollup-linux-ppc64-gnu': 4.63.4 - '@rollup/rollup-linux-ppc64-musl': 4.63.4 - '@rollup/rollup-linux-riscv64-gnu': 4.63.4 - '@rollup/rollup-linux-riscv64-musl': 4.63.4 - '@rollup/rollup-linux-s390x-gnu': 4.63.4 - '@rollup/rollup-linux-x64-gnu': 4.63.4 - '@rollup/rollup-linux-x64-musl': 4.63.4 - '@rollup/rollup-openbsd-x64': 4.63.4 - '@rollup/rollup-openharmony-arm64': 4.63.4 - '@rollup/rollup-win32-arm64-msvc': 4.63.4 - '@rollup/rollup-win32-ia32-msvc': 4.63.4 - '@rollup/rollup-win32-x64-gnu': 4.63.4 - '@rollup/rollup-win32-x64-msvc': 4.63.4 - fsevents: 2.3.3 - sade@1.8.1: dependencies: mri: 1.2.0 @@ -1643,18 +1018,14 @@ snapshots: stackback@0.0.2: {} - std-env@3.10.0: {} - - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 + std-env@4.2.0: {} - svelte-check@4.7.1(picomatch@4.0.4)(svelte@5.56.4)(typescript@6.0.3): + svelte-check@4.7.1(picomatch@4.0.7)(svelte@5.56.4)(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 '@sveltejs/load-config': 0.2.0 chokidar: 4.0.3 - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.7) picocolors: 1.1.1 sade: 1.8.1 svelte: 5.56.4 @@ -1690,55 +1061,18 @@ snapshots: tinybench@2.9.0: {} - tinyexec@0.3.2: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 - tinyspy@4.0.6: {} + tinyrainbow@3.1.1: {} typescript@6.0.3: {} - vite-node@3.2.4(lightningcss@1.33.0): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.6(lightningcss@1.33.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vite@7.3.6(lightningcss@1.33.0): - dependencies: - esbuild: 0.28.2 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.28 - rollup: 4.63.4 - tinyglobby: 0.2.17 - optionalDependencies: - fsevents: 2.3.3 - lightningcss: 1.33.0 - - vite@8.3.0(esbuild@0.28.2): + vite@8.3.0: dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1746,51 +1080,36 @@ snapshots: rolldown: 1.2.9 tinyglobby: 0.2.17 optionalDependencies: - esbuild: 0.28.2 fsevents: 2.3.3 - vitefu@1.1.3(vite@8.3.0(esbuild@0.28.2)): + vitefu@1.1.3(vite@8.3.0): optionalDependencies: - vite: 8.3.0(esbuild@0.28.2) + vite: 8.3.0 - vitest@3.2.7(lightningcss@1.33.0): + vitest@4.1.11(vite@8.3.0): dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(lightningcss@1.33.0)) - '@vitest/pretty-format': 3.2.7 - '@vitest/runner': 3.2.7 - '@vitest/snapshot': 3.2.7 - '@vitest/spy': 3.2.7 - '@vitest/utils': 3.2.7 - chai: 5.3.3 - debug: 4.4.3 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.3.0) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 + obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 3.10.0 + picomatch: 4.0.7 + std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.6(lightningcss@1.33.0) - vite-node: 3.2.4(lightningcss@1.33.0) + tinyrainbow: 3.1.1 + vite: 8.3.0 why-is-node-running: 2.3.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml why-is-node-running@2.3.0: dependencies: