From 0a6e4f0d07fd4f44fbc2634b899d735c5c791b68 Mon Sep 17 00:00:00 2001 From: Manuel Date: Sat, 27 Jun 2026 19:31:18 +0200 Subject: [PATCH 01/19] 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 | 56 ++++++-- 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 | 12 +- 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 | 128 ++++++++++++------ .../src/ipc/status_file_tests.rs | 79 +++++++++++ crates/git-same-core/src/monitor/run.rs | 34 ++++- .../src/monitor/socket_handler.rs | 16 +-- .../src/monitor/socket_handler_tests.rs | 5 +- 11 files changed, 332 insertions(+), 76 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 0af19b6..857e365 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -42,6 +42,14 @@ const MONITOR_PLIST_TEMPLATE: &str = include_str!("../../../macos/com.zaai.git-s #[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, @@ -368,13 +376,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) } @@ -429,15 +442,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)?; @@ -479,8 +496,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 { @@ -957,7 +973,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) => { @@ -983,7 +999,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(), @@ -1240,14 +1256,10 @@ fn requirement_check_dto(check: CheckResult) -> RequirementCheckDto { } } -pub(crate) fn read_status_snapshot() -> Result { - let ipc = IpcConfig::default_path()?; - read_status_snapshot_with(&ipc) -} - -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 metadata = fs::metadata(&status_path).ok(); let updated_at = metadata @@ -1289,6 +1301,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 4aa19fa..a2fe4cd 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -207,6 +207,45 @@ fn read_status_snapshot_returns_last_known_status_when_monitor_pid_is_stale() { 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 211f05e..fa91aa4 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()) @@ -25,7 +27,14 @@ fn main() { commands::open_url, ]) .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}"); } Ok(()) diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs index 8006a0b..54688a0 100644 --- a/crates/git-same-app/src/status_stream.rs +++ b/crates/git-same-app/src/status_stream.rs @@ -1,10 +1,14 @@ -use crate::commands::read_status_snapshot; +use crate::commands::read_status_snapshot_with; use git_same_core::ipc::IpcConfig; use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use tauri::{AppHandle, Emitter}; -pub fn spawn_watcher(app: AppHandle) -> anyhow::Result<()> { - let ipc = IpcConfig::default_path()?; +/// Watches the host-facing IPC directory for `status.json` changes and emits a +/// `status-updated` event with a fresh snapshot. `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()?; let watch_path = ipc.dir.clone(); @@ -32,7 +36,7 @@ pub fn spawn_watcher(app: AppHandle) -> anyhow::Result<()> { if event.is_err() { continue; } - 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 c4e9322..8da3d73 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 81ad9b7..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,43 +41,21 @@ 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)))?; - let temp_path = self.path.with_extension("json.tmp"); - - // Ensure parent directory exists - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - AppError::path(format!( - "Failed to create directory '{}': {}", - parent.display(), - e - )) - })?; + write_atomic(&self.path, &json)?; + for mirror in &self.mirrors { + write_atomic(mirror, &json)?; } - // 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, &self.path).map_err(|e| { - AppError::path(format!( - "Failed to rename '{}' → '{}': {}", - temp_path.display(), - self.path.display(), - e - )) - })?; - Ok(()) } @@ -85,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 @@ -96,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> { @@ -104,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 430a307..0ad56ee 100644 --- a/crates/git-same-core/src/monitor/run.rs +++ b/crates/git-same-core/src/monitor/run.rs @@ -60,7 +60,11 @@ where info!("Starting git-same monitor"); output.info("Starting git-same monitor..."); - 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)); @@ -150,7 +154,7 @@ where match result { Ok((stream, _)) => { let config_clone = config.clone(); - let writer_path = status_writer.path().to_path_buf(); + let writer = status_writer.clone(); let owner_clone = service.owner_types_clone(); let ambient_clone = service.ambient_upgrades_clone(); let status_clone = shared_status.clone(); @@ -159,7 +163,7 @@ where stream, &config_clone, pid, - &writer_path, + writer, status_clone, owner_clone, ambient_clone, @@ -211,6 +215,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() + } +} + fn flush_pending( service: &RepoScanService<'_>, shared_status: &Arc>, diff --git a/crates/git-same-core/src/monitor/socket_handler.rs b/crates/git-same-core/src/monitor/socket_handler.rs index fdb20c3..02f23ce 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::unix_socket::DaemonCommand; use crate::ipc::StatusFileWriter; use crate::monitor::incremental::rescan_and_merge; use crate::types::FinderStatus; -use std::path::Path; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -23,7 +22,7 @@ pub async fn handle_socket_connection( mut stream: UnixStream, config: &Config, pid: u32, - status_path: &Path, + status_writer: StatusFileWriter, shared_status: Arc>, owner_types: Option, ambient_upgrades: Option, @@ -59,8 +58,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"); } } @@ -76,8 +74,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() @@ -87,7 +84,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) } @@ -101,9 +98,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 8cc5621d5e00636fe3381b4c8c75c5020902a436 Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 1 Jul 2026 01:08:25 +0200 Subject: [PATCH 02/19] Bump transitive deps in Cargo.lock to latest in-range patches Picks up patch-level updates cargo resolved: anyhow 1.0.103, aws-lc-rs 1.17.1, aws-lc-sys 0.42.0, camino 1.2.4, among others. --- Cargo.lock | 77 +++++++++++++++++++++++++++--------------------------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 222f6d3..480a174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,9 +179,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -189,14 +189,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -364,9 +365,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] @@ -502,9 +503,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "97bf4965940c2382204c0ded6dd3dd48c0c4e872f1e76fb1bf94f45991a2cb6a" dependencies = [ "clap", ] @@ -2059,9 +2060,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "993f007684f2e9727160da8b960ec161264703bfd1af084fd2e34d040c9a0dd4" dependencies = [ "console", "portable-atomic", @@ -2257,9 +2258,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -2444,9 +2445,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -3377,9 +3378,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3433,9 +3434,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3789,9 +3790,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", @@ -3815,9 +3816,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -4752,9 +4753,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.3" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -5502,9 +5503,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "atomic", "getrandom 0.4.3", @@ -5596,9 +5597,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5609,9 +5610,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5619,9 +5620,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5629,9 +5630,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -5642,9 +5643,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -5664,9 +5665,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", From 657c45d4c45ec86409bed80c22f06baa0a4743d3 Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 1 Jul 2026 10:56:36 +0200 Subject: [PATCH 03/19] 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 480a174..be56833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1561,6 +1561,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "time", "tokio", "toml 1.1.2+spec-1.1.0", ] @@ -4976,9 +4977,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", "libc", @@ -4998,9 +4999,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", diff --git a/crates/git-same-app/Cargo.toml b/crates/git-same-app/Cargo.toml index 53478ca..5ad1593 100644 --- a/crates/git-same-app/Cargo.toml +++ b/crates/git-same-app/Cargo.toml @@ -24,6 +24,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 f3f593fe00d9014aa9540a099ea0ce572b055567 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 2 Jul 2026 00:52:41 +0200 Subject: [PATCH 04/19] 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 be56833..3249b8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1524,7 +1524,7 @@ dependencies = [ [[package]] name = "git-same" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anyhow", "chrono", @@ -1549,7 +1549,7 @@ dependencies = [ [[package]] name = "git-same-app" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anyhow", "chrono", @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "git-same-core" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 384aab8..3638049 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.1.0" +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 cdb336f..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.0", + "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 d37e928..dedd643 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.0", + "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 75ac847..abc3db9 100644 --- a/crates/git-same-cli/Cargo.toml +++ b/crates/git-same-cli/Cargo.toml @@ -39,7 +39,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.0" } +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 b019946..a55e641 100644 --- a/macos/GitSameBadges/Info.plist +++ b/macos/GitSameBadges/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 3.1.0 + 3.2.0 CFBundleVersion - 3.1.0 + 3.2.0 NSExtension NSExtensionPointIdentifier From 8f7bce59d1a40e24da215e46ae80c0f35f485ff6 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 2 Jul 2026 01:06:09 +0200 Subject: [PATCH 05/19] 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. --- Cargo.lock | 24 ++--- crates/git-same-app/ui/package.json | 6 +- crates/git-same-app/ui/pnpm-lock.yaml | 146 +++++++++++++------------- 3 files changed, 88 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3249b8a..d5d2ce4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -503,9 +503,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97bf4965940c2382204c0ded6dd3dd48c0c4e872f1e76fb1bf94f45991a2cb6a" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" dependencies = [ "clap", ] @@ -588,9 +588,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -2061,9 +2061,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.5" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993f007684f2e9727160da8b960ec161264703bfd1af084fd2e34d040c9a0dd4" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -2103,9 +2103,9 @@ dependencies = [ [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "6fda9741ca16536952da2ecaa6105c2f4653fa6f0724681df6d2414c4106d0b0" dependencies = [ "libc", ] @@ -2401,9 +2401,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -4558,9 +4558,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.3" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json index dedd643..f84befa 100644 --- a/crates/git-same-app/ui/package.json +++ b/crates/git-same-app/ui/package.json @@ -10,7 +10,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "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", @@ -18,10 +18,10 @@ }, "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" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/crates/git-same-app/ui/pnpm-lock.yaml b/crates/git-same-app/ui/pnpm-lock.yaml index 7b3fa1b..9378c5f 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.22.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) + version: 7.1.2(svelte@5.56.4)(vite@8.1.2) '@tauri-apps/cli': - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.4 + version: 2.11.4 svelte-check: specifier: ^4.7.1 version: 4.7.1(picomatch@4.0.4)(svelte@5.56.4)(typescript@6.0.3) @@ -37,8 +37,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.1.0 - version: 8.1.0 + specifier: ^8.1.2 + version: 8.1.2 packages: @@ -67,8 +67,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.22.0': + resolution: {integrity: sha512-eaNC3GGu9ma7mviB9vPL6OnawXqxdvRnoAQSq5l15mBlsuwD7kozZ7pzPXSlT6OwSl7hz4qTk+ZU3OEewwi5gQ==} peerDependencies: svelte: ^5 @@ -198,79 +198,79 @@ 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.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} 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.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} 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.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} 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.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} 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.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} 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.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} 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.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} 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.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} 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.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} 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.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} 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.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} 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.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} engines: {node: '>= 10'} hasBin: true @@ -321,8 +321,8 @@ packages: esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - esrap@2.2.12: - resolution: {integrity: sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==} + esrap@2.2.13: + resolution: {integrity: sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==} peerDependencies: '@typescript-eslint/types': ^8.2.0 peerDependenciesMeta: @@ -446,8 +446,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} readdirp@4.1.2: @@ -500,8 +500,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - vite@8.1.0: - resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + vite@8.1.2: + resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -591,7 +591,7 @@ 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.22.0(svelte@5.56.4)': dependencies: svelte: 5.56.4 @@ -661,63 +661,63 @@ snapshots: '@sveltejs/load-config@0.2.0': {} - '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.1.0)': + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.1.2)': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.3 svelte: 5.56.4 - vite: 8.1.0 - vitefu: 1.1.3(vite@8.1.0) + vite: 8.1.2 + vitefu: 1.1.3(vite@8.1.2) '@tauri-apps/api@2.11.1': {} - '@tauri-apps/cli-darwin-arm64@2.11.3': + '@tauri-apps/cli-darwin-arm64@2.11.4': optional: true - '@tauri-apps/cli-darwin-x64@2.11.3': + '@tauri-apps/cli-darwin-x64@2.11.4': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.11.3': + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.11.3': + '@tauri-apps/cli-linux-arm64-musl@2.11.4': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.11.3': + '@tauri-apps/cli-linux-x64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-musl@2.11.3': + '@tauri-apps/cli-linux-x64-musl@2.11.4': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.11.3': + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.11.3': + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.11.3': + '@tauri-apps/cli-win32-x64-msvc@2.11.4': optional: true - '@tauri-apps/cli@2.11.3': + '@tauri-apps/cli@2.11.4': 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.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 '@tauri-apps/plugin-dialog@2.7.1': dependencies: @@ -752,7 +752,7 @@ snapshots: esm-env@1.2.2: {} - esrap@2.2.12: + esrap@2.2.13: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -832,7 +832,7 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.15: + postcss@8.5.16: dependencies: nanoid: 3.3.15 picocolors: 1.1.1 @@ -900,7 +900,7 @@ snapshots: clsx: 2.1.1 devalue: 5.8.1 esm-env: 1.2.2 - esrap: 2.2.12 + esrap: 2.2.13 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -918,18 +918,18 @@ snapshots: typescript@6.0.3: {} - vite@8.1.0: + vite@8.1.2: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.16 rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 - vitefu@1.1.3(vite@8.1.0): + vitefu@1.1.3(vite@8.1.2): optionalDependencies: - vite: 8.1.0 + vite: 8.1.2 zimmerframe@1.1.4: {} From 5f726498f956f7f819ca58b514e1be4c2e91d4be Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 2 Jul 2026 22:25:03 +0200 Subject: [PATCH 06/19] 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 857e365..d2c7254 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -1259,7 +1259,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 metadata = fs::metadata(&status_path).ok(); let updated_at = metadata @@ -1309,12 +1309,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 a2fe4cd..ea4b691 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -246,6 +246,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 83728f15e97a2ffcec95f305051cc4e3aaadd36f Mon Sep 17 00:00:00 2001 From: Manuel Date: Tue, 7 Jul 2026 08:15:22 +0200 Subject: [PATCH 07/19] 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 | 83 +++++-------------- crates/git-same-app/src/commands_tests.rs | 40 +++------ crates/git-same-app/src/status_stream.rs | 27 +++++- .../git-same-app/src/status_stream_tests.rs | 36 ++++++++ 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 +------ 9 files changed, 281 insertions(+), 132 deletions(-) create mode 100644 crates/git-same-app/src/status_stream_tests.rs diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index d2c7254..9484fc2 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; -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::progress::{ProgressEvent, ProgressReporter}; use git_same_core::provider::{create_provider, NoProgress}; @@ -379,15 +379,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) } @@ -452,9 +449,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)?; @@ -496,7 +490,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 { @@ -1259,16 +1253,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()); - let metadata = fs::metadata(&status_path).ok(); - let updated_at = metadata - .as_ref() - .and_then(|meta| meta.modified().ok()) - .map(system_time_to_rfc3339); - let stale_by_age = metadata - .as_ref() - .and_then(|meta| meta.modified().ok()) + // 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 updated_at = modified.map(system_time_to_rfc3339); + let stale_by_age = modified .map(|modified| { modified .elapsed() @@ -1276,22 +1272,11 @@ pub(crate) fn read_status_snapshot_with(ipc: &IpcConfig) -> Result Duration::from_secs(DAEMON_STALE_AFTER_SECS) }) .unwrap_or(true); - let monitor_alive = if writer.exists() { - writer - .read() - .map(|status| is_process_alive(status.daemon_pid)) - .unwrap_or(false) - } else { - false - }; + let monitor_alive = status + .as_ref() + .map(|status| is_process_alive(status.daemon_pid)) + .unwrap_or(false); let stale = stale_by_age || !monitor_alive; - let status = if writer.exists() && !stale { - Some(writer.read()?) - } else if writer.exists() { - writer.read().ok() - } else { - None - }; Ok(StatusSnapshot { status_path: status_path.display().to_string(), @@ -1301,36 +1286,6 @@ 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() { - 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 ea4b691..a6f7032 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -246,37 +246,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-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs index 54688a0..9736e78 100644 --- a/crates/git-same-app/src/status_stream.rs +++ b/crates/git-same-app/src/status_stream.rs @@ -1,6 +1,7 @@ use crate::commands::read_status_snapshot_with; use git_same_core::ipc::IpcConfig; -use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use std::ffi::OsStr; use tauri::{AppHandle, Emitter}; /// Watches the host-facing IPC directory for `status.json` changes and emits a @@ -33,7 +34,10 @@ pub fn spawn_watcher(app: AppHandle, ipc: IpcConfig) -> anyhow::Result<()> { } for event in rx { - if event.is_err() { + let Ok(event) = event else { + continue; + }; + if !event_touches_status_file(&event) { continue; } if let Ok(snapshot) = read_status_snapshot_with(&ipc) { @@ -44,3 +48,22 @@ pub fn spawn_watcher(app: AppHandle, ipc: IpcConfig) -> anyhow::Result<()> { Ok(()) } + +/// Whether a watcher event concerns the final `status.json` rather than, for +/// example, the sibling `status.json.tmp` the atomic write creates first. +/// Without this filter every monitor write (tmp create + rename) triggers +/// several full snapshot reads and duplicate `status-updated` emits. +/// +/// Events with no paths are kept: notify emits path-less rescan/flag events +/// after kernel-side queue drops, and skipping those could miss an update. +fn event_touches_status_file(event: &Event) -> bool { + event.paths.is_empty() + || event + .paths + .iter() + .any(|path| path.file_name() == Some(OsStr::new("status.json"))) +} + +#[cfg(test)] +#[path = "status_stream_tests.rs"] +mod tests; diff --git a/crates/git-same-app/src/status_stream_tests.rs b/crates/git-same-app/src/status_stream_tests.rs new file mode 100644 index 0000000..52fb33c --- /dev/null +++ b/crates/git-same-app/src/status_stream_tests.rs @@ -0,0 +1,36 @@ +use super::*; +use std::path::PathBuf; + +fn event_with_paths(paths: Vec) -> Event { + Event { + paths, + ..Default::default() + } +} + +#[test] +fn keeps_events_for_the_final_status_file() { + let event = event_with_paths(vec![PathBuf::from("/ipc/status.json")]); + assert!(event_touches_status_file(&event)); +} + +#[test] +fn skips_events_for_the_temp_file() { + let event = event_with_paths(vec![PathBuf::from("/ipc/status.json.tmp")]); + assert!(!event_touches_status_file(&event)); +} + +#[test] +fn keeps_events_when_any_path_is_the_status_file() { + let event = event_with_paths(vec![ + PathBuf::from("/ipc/status.json.tmp"), + PathBuf::from("/ipc/status.json"), + ]); + assert!(event_touches_status_file(&event)); +} + +#[test] +fn keeps_pathless_rescan_events() { + let event = event_with_paths(Vec::new()); + assert!(event_touches_status_file(&event)); +} diff --git a/crates/git-same-core/src/ipc/mod.rs b/crates/git-same-core/src/ipc/mod.rs index 8da3d73..34ea297 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 0ad56ee..b4a13e9 100644 --- a/crates/git-same-core/src/monitor/run.rs +++ b/crates/git-same-core/src/monitor/run.rs @@ -60,11 +60,7 @@ where info!("Starting git-same monitor"); output.info("Starting git-same monitor..."); - 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)); @@ -215,30 +211,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() - } -} - fn flush_pending( service: &RepoScanService<'_>, shared_status: &Arc>, From 4804d90eeffbd699d26d18fee43f55f273ab9748 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 10 Jul 2026 01:40:59 +0200 Subject: [PATCH 08/19] Guide users to restart the monitor after an upgrade when stale The stale-status requirement suggestion said only to restart or wait, which never resolves an app/monitor version skew (a newer app reading an older monitor's status). Mention that a restart picks up the new monitor build so upgraders are not stuck waiting for a scan that cannot help. --- crates/git-same-app/src/commands.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 9484fc2..b0fc59f 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -1077,9 +1077,11 @@ fn monitor_requirement_suggestion( Some(agent) if !agent.loaded || !agent.running => { Some("Restart the Git-Same monitor LaunchAgent".to_string()) } - Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => { - Some("Restart the monitor or wait for the next scan".to_string()) - } + Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => Some( + "Restart the monitor (Settings) or wait for the next scan; \ + if you just upgraded, a restart picks up the new monitor build" + .to_string(), + ), Some(_) => None, None => Some("Check LaunchAgent permissions and the git-same binary path".to_string()), } From d59f5f86db6acc9fe84ea4846764ac627dd46940 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 10 Jul 2026 10:38:56 +0200 Subject: [PATCH 09/19] 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 | 38 +++++++++++- crates/git-same-app/src/commands_tests.rs | 59 ++++++++++++++++++- 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, 123 insertions(+), 4 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index b0fc59f..ec9aa94 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -999,8 +999,16 @@ fn app_requirement_checks(ipc: &IpcConfig) -> Vec { name: "Monitor".to_string(), passed: monitor_agent.as_ref().is_some_and(|agent| agent.running) && snapshot.as_ref().is_some_and(|snapshot| !snapshot.stale), - message: monitor_requirement_message(monitor_agent.as_ref(), snapshot.as_ref()), - suggestion: monitor_requirement_suggestion(monitor_agent.as_ref(), snapshot.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, }); @@ -1046,10 +1054,27 @@ fn app_requirement_checks(ipc: &IpcConfig) -> Vec { checks } +/// 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 stale +/// arm covers 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 { + let skew = monitor_version_mismatch(snapshot, app_version); match agent { Some(agent) if !agent.installed => "LaunchAgent plist missing".to_string(), Some(agent) if !agent.loaded => "LaunchAgent installed but not loaded".to_string(), @@ -1059,6 +1084,11 @@ fn monitor_requirement_message( Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => { "Monitor running but status file is stale".to_string() } + Some(_) if skew.is_some() => format!( + "Monitor is running a different build ({}) than the app ({})", + skew.as_deref().unwrap_or_default(), + app_version + ), Some(_) => snapshot .and_then(|snapshot| snapshot.updated_at.clone()) .unwrap_or_else(|| "Monitor running".to_string()), @@ -1069,6 +1099,7 @@ fn monitor_requirement_message( fn monitor_requirement_suggestion( agent: Option<&MonitorLaunchAgentStatusDto>, snapshot: Option<&StatusSnapshot>, + app_version: &str, ) -> Option { match agent { Some(agent) if !agent.installed => { @@ -1082,6 +1113,9 @@ fn monitor_requirement_suggestion( if you just upgraded, a restart picks up the new monitor build" .to_string(), ), + Some(_) if monitor_version_mismatch(snapshot, app_version).is_some() => { + Some("Restart the monitor so it runs the same build as the app".to_string()) + } Some(_) => None, None => Some("Check LaunchAgent permissions and the git-same binary path".to_string()), } diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index a6f7032..735cb4f 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -157,15 +157,70 @@ fn monitor_requirement_message_distinguishes_missing_plist() { }; assert_eq!( - monitor_requirement_message(Some(&agent), None), + monitor_requirement_message(Some(&agent), None, "3.2.0"), "LaunchAgent plist missing" ); assert_eq!( - monitor_requirement_suggestion(Some(&agent), None), + monitor_requirement_suggestion(Some(&agent), None, "3.2.0"), Some("Install the Git-Same monitor LaunchAgent".to_string()) ); } +fn running_agent() -> MonitorLaunchAgentStatusDto { + MonitorLaunchAgentStatusDto { + label: MONITOR_LAUNCH_AGENT_LABEL.to_string(), + plist_path: "/tmp/agent.plist".to_string(), + binary_path: Some("/usr/local/bin/git-same".to_string()), + installed: true, + loaded: true, + running: true, + state: "running".to_string(), + message: "Monitor running".to_string(), + } +} + +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 surface the healthy updated_at message and no skew hint. + assert_eq!( + monitor_requirement_message(Some(&agent), Some(&snapshot), "3.2.0"), + "2026-07-07T00:00:00Z" + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), Some(&snapshot), "3.2.0"), + None + ); +} + #[test] fn read_status_snapshot_returns_none_when_status_file_is_missing() { let temp = TestDir::new("missing-status"); diff --git a/crates/git-same-app/ui/src/lib/types.ts b/crates/git-same-app/ui/src/lib/types.ts index ed3753a..c51778d 100644 --- a/crates/git-same-app/ui/src/lib/types.ts +++ b/crates/git-same-app/ui/src/lib/types.ts @@ -214,6 +214,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 9edcf797b33906dc8f889417a782b585b36bee05 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 10 Jul 2026 21:33:01 +0200 Subject: [PATCH 10/19] 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 | 15 +++++++++++++++ crates/git-same-app/src/main.rs | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index ec9aa94..204d13e 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -590,6 +590,21 @@ fn install_monitor_launch_agent_inner() -> Result Result<(), AppError> { + if !monitor_launch_agent_path()?.exists() { + return Ok(()); + } + restart_monitor_launch_agent_inner()?; + Ok(()) +} + fn restart_monitor_launch_agent_inner() -> Result { let plist_path = monitor_launch_agent_path()?; if !plist_path.exists() { diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index fa91aa4..b1fc203 100644 --- a/crates/git-same-app/src/main.rs +++ b/crates/git-same-app/src/main.rs @@ -34,6 +34,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 2f774be448a5761cff6e32c82283cb057dc114c0 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 24 Jul 2026 13:05:19 +0200 Subject: [PATCH 11/19] 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 | 21 +++++++++++-- crates/git-same-app/src/commands_tests.rs | 22 +++++++++++++ crates/git-same-app/src/status_stream.rs | 17 +++++++--- crates/git-same-core/src/ipc/status_file.rs | 35 +++++++++++++++------ 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 204d13e..5f0c639 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -1012,8 +1012,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(|agent| agent.running) - && snapshot.as_ref().is_some_and(|snapshot| !snapshot.stale), + 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(), @@ -1084,6 +1087,20 @@ 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(|agent| agent.running) + && snapshot.is_some_and(|snapshot| !snapshot.stale) + && 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 735cb4f..9df1525 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -205,6 +205,28 @@ fn monitor_requirement_flags_version_skew() { ); } +#[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" + )); +} + #[test] fn monitor_requirement_ignores_matching_version() { let agent = running_agent(); diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs index 9736e78..a2ce3f7 100644 --- a/crates/git-same-app/src/status_stream.rs +++ b/crates/git-same-app/src/status_stream.rs @@ -34,14 +34,23 @@ pub fn spawn_watcher(app: AppHandle, ipc: IpcConfig) -> anyhow::Result<()> { } for event in rx { - let Ok(event) = event else { - continue; + let event = match event { + Ok(event) => event, + Err(error) => { + eprintln!("status watcher event error: {error}"); + continue; + } }; if !event_touches_status_file(&event) { continue; } - 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}"); + } } } })?; 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 9447688ef909feb5c8196ae031f4ba7f73bd81e2 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 10 Aug 2026 21:35:39 +0200 Subject: [PATCH 12/19] Preserve watcher rescans to prevent stale app status --- crates/git-same-app/src/status_stream.rs | 9 ++++++--- crates/git-same-app/src/status_stream_tests.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs index a2ce3f7..7730a5d 100644 --- a/crates/git-same-app/src/status_stream.rs +++ b/crates/git-same-app/src/status_stream.rs @@ -63,10 +63,13 @@ pub fn spawn_watcher(app: AppHandle, ipc: IpcConfig) -> anyhow::Result<()> { /// Without this filter every monitor write (tmp create + rename) triggers /// several full snapshot reads and duplicate `status-updated` emits. /// -/// Events with no paths are kept: notify emits path-less rescan/flag events -/// after kernel-side queue drops, and skipping those could miss an update. +/// Rescan events are always kept: after a kernel-side queue drop, notify's +/// macOS FSEvents backend marks the event for rescan but still attaches the +/// watched-directory path. Pathless events are also kept defensively because +/// other backends may use them to signal that an update was missed. fn event_touches_status_file(event: &Event) -> bool { - event.paths.is_empty() + event.need_rescan() + || event.paths.is_empty() || event .paths .iter() diff --git a/crates/git-same-app/src/status_stream_tests.rs b/crates/git-same-app/src/status_stream_tests.rs index 52fb33c..db4f480 100644 --- a/crates/git-same-app/src/status_stream_tests.rs +++ b/crates/git-same-app/src/status_stream_tests.rs @@ -1,4 +1,5 @@ use super::*; +use notify::{event::Flag, EventKind}; use std::path::PathBuf; fn event_with_paths(paths: Vec) -> Event { @@ -29,8 +30,22 @@ fn keeps_events_when_any_path_is_the_status_file() { assert!(event_touches_status_file(&event)); } +#[test] +fn keeps_path_bearing_rescan_events() { + let event = Event::new(EventKind::Other) + .set_flag(Flag::Rescan) + .add_path(PathBuf::from("/ipc")); + assert!(event_touches_status_file(&event)); +} + #[test] fn keeps_pathless_rescan_events() { + let event = Event::new(EventKind::Other).set_flag(Flag::Rescan); + assert!(event_touches_status_file(&event)); +} + +#[test] +fn keeps_pathless_events_without_rescan_flag() { let event = event_with_paths(Vec::new()); assert!(event_touches_status_file(&event)); } From d08ec1187ff13fbeea255db8365d8ff8a69c52e2 Mon Sep 17 00:00:00 2001 From: Manuel Date: Tue, 11 Aug 2026 03:24:08 +0200 Subject: [PATCH 13/19] 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 ef35abb7584d51b941afb7533ae8968d4a926561 Mon Sep 17 00:00:00 2001 From: Manuel Date: Tue, 11 Aug 2026 10:39:29 +0200 Subject: [PATCH 14/19] 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 65a96e40e0c77e3d7585aa6023745ca466a62fda Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 9 Sep 2026 10:56:06 +0200 Subject: [PATCH 15/19] 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 | 8 +- Cargo.lock | 2 + crates/git-same-app/Cargo.toml | 2 + crates/git-same-app/src/commands.rs | 247 +++++++++++++++++- crates/git-same-app/src/commands_tests.rs | 174 ++++++++++++ crates/git-same-app/src/main.rs | 30 ++- crates/git-same-app/src/monitor_mode.rs | 78 ++++++ crates/git-same-app/src/monitor_mode_tests.rs | 23 ++ crates/git-same-app/ui/src/App.svelte | 22 +- crates/git-same-app/ui/src/lib/Banner.svelte | 198 -------------- .../ui/src/lib/StatusBanner.svelte | 43 ++- .../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 | 15 ++ .../ui/src/routes/FinderBadges.svelte | 125 ++++++--- .../ui/src/routes/Requirements.svelte | 13 +- crates/git-same-app/ui/src/stores/status.ts | 63 ++++- crates/git-same-cli/src/commands/monitor.rs | 45 +--- .../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 +- crates/git-same-core/src/monitor/mod.rs | 2 +- crates/git-same-core/src/monitor/run.rs | 45 ++++ crates/git-same-core/src/monitor/run_tests.rs | 28 ++ .../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 | 6 +- toolkit/packaging/macos/build-app-bundle.sh | 5 + 32 files changed, 1088 insertions(+), 344 deletions(-) create mode 100644 crates/git-same-app/src/monitor_mode.rs create mode 100644 crates/git-same-app/src/monitor_mode_tests.rs delete mode 100644 crates/git-same-app/ui/src/lib/Banner.svelte 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/run_tests.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c32b256..5af3380 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -43,7 +43,9 @@ 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 `gisa monitor --foreground`, non-cask installs (`cargo install`, the homebrew formula) ship only the binary, `--status` / `--stop` are the supported debugging 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:** non-cask installs (`cargo install`, the homebrew formula) ship only the binary, `--status` / `--stop` are the supported debugging 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 the LaunchAgent runs `Git-Same.app/Contents/MacOS/git-same-app monitor --foreground` and not the helper:** macOS TCC attributes a launchd-spawned process to its bundle only when the executable is the bundle's `CFBundleExecutable`. `Contents/Helpers/git-same` is a separate, path-based TCC identity, so a Full Disk Access grant for "Git-Same" never reaches a monitor started through it. `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`. `monitor_binary_candidates()` in `commands.rs` prefers the bundled app executable, the app re-renders an installed agent that still points elsewhere on launch, and the cask renders the same path. Dev builds outside an `.app` fall back to the helper and keep a path-based identity. ### Engine modules (`crates/git-same-core/src/`) @@ -112,7 +114,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 d5d2ce4..9c4a700 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1564,6 +1564,8 @@ dependencies = [ "time", "tokio", "toml 1.1.2+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 5ad1593..b9d197f 100644 --- a/crates/git-same-app/Cargo.toml +++ b/crates/git-same-app/Cargo.toml @@ -31,3 +31,5 @@ time = ">=0.3, <0.3.52" tauri-plugin-dialog = "2" tokio = { workspace = true } toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 5f0c639..d6671c0 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; 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::progress::{ProgressEvent, ProgressReporter}; use git_same_core::provider::{create_provider, NoProgress}; use git_same_core::setup::{authenticate_provider, discover_org_entries}; @@ -233,10 +234,30 @@ pub struct MonitorLaunchAgentStatusDto { pub installed: bool, pub loaded: bool, pub running: bool, + /// The installed plist execs something other than this app executable + /// (typically the CLI helper), so the monitor runs under a TCC identity + /// that a Full Disk Access grant for Git-Same does not reach. + pub needs_reinstall: bool, pub state: String, pub message: String, } +/// 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, @@ -525,6 +546,121 @@ pub fn extension_status() -> Result { } } +/// 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> { #[cfg(target_os = "macos")] @@ -556,6 +692,7 @@ fn monitor_launch_agent_status_inner() -> Result Result Result<(), AppError> { Ok(()) } +/// Re-render and restart the monitor LaunchAgent *only if it is already +/// installed*, so an agent that still execs the CLI helper (a separate, +/// path-based TCC identity) moves onto this app executable and the user's +/// Full Disk Access grant reaches the monitor. Never installs implicitly. +pub(crate) fn reinstall_monitor_if_installed() -> Result<(), AppError> { + if !monitor_launch_agent_path()?.exists() { + return Ok(()); + } + install_monitor_launch_agent_inner()?; + Ok(()) +} + +/// True when a LaunchAgent is installed but execs something other than this +/// bundled app executable. Only the bundle's main executable gives the monitor +/// the bundle's TCC identity, so any other program (the CLI helper, a cargo +/// install) must be re-rendered before Full Disk Access can cover the monitor. +/// Dev builds (not inside an `.app`) never report this, so they leave the +/// user's installed agent alone. +pub(crate) fn monitor_launch_agent_needs_reinstall() -> bool { + let Some(expected) = bundled_app_executable() else { + return false; + }; + let Ok(plist_path) = monitor_launch_agent_path() else { + return false; + }; + let Ok(plist) = fs::read_to_string(&plist_path) else { + return false; + }; + monitor_launch_agent_program(&plist).is_some_and(|program| Path::new(&program) != expected) +} + +/// The program a rendered monitor plist execs (`ProgramArguments[0]`), with +/// XML escapes undone, or `None` when the plist has no program. +fn monitor_launch_agent_program(plist: &str) -> Option { + let start = plist.find("ProgramArguments")?; + let rest = &plist[start..]; + let open = rest.find("")? + "".len(); + let close = rest[open..].find("")? + open; + let raw = rest[open..close].trim(); + (!raw.is_empty()).then(|| unescape_xml(raw)) +} + +/// This executable when it is an app bundle's main binary +/// (`.app/Contents/MacOS/`), else `None` (dev builds, cargo runs). +fn bundled_app_executable() -> Option { + let exe = env::current_exe().ok()?; + is_bundle_main_executable(&exe).then_some(exe) +} + +fn is_bundle_main_executable(exe: &Path) -> bool { + let macos_dir = exe.parent(); + let contents_dir = macos_dir.and_then(Path::parent); + let app_dir = contents_dir.and_then(Path::parent); + macos_dir + .and_then(Path::file_name) + .is_some_and(|name| name == "MacOS") + && contents_dir + .and_then(Path::file_name) + .is_some_and(|name| name == "Contents") + && app_dir + .and_then(Path::extension) + .is_some_and(|extension| extension == "app") +} + fn restart_monitor_launch_agent_inner() -> Result { let plist_path = monitor_launch_agent_path()?; if !plist_path.exists() { @@ -641,8 +843,15 @@ fn monitor_binary_path() -> Result { )) } +/// Candidate programs for the monitor LaunchAgent, best first. The bundled +/// app executable leads because it is the only program that runs the monitor +/// under the bundle's TCC identity; the CLI helper and cargo installs are +/// fallbacks for unbundled dev builds. fn monitor_binary_candidates() -> Vec { let mut candidates = Vec::new(); + if let Some(exe) = bundled_app_executable() { + candidates.push(exe); + } if let Ok(exe) = env::current_exe() { if let Some(contents) = exe.ancestors().find(|path| { path.file_name() @@ -691,6 +900,15 @@ fn escape_xml(value: &str) -> String { .replace('\'', "'") } +fn unescape_xml(value: &str) -> String { + value + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + fn is_executable(path: &Path) -> bool { #[cfg(unix)] { @@ -1052,20 +1270,15 @@ fn app_requirement_checks(ipc: &IpcConfig) -> Vec { critical: false, }); - let fda_needed = snapshot - .as_ref() - .and_then(|snapshot| snapshot.status.as_ref()) - .is_some_and(|status| !status.workspaces.is_empty() && status.repos.is_empty()); + 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, }); @@ -1096,7 +1309,7 @@ fn monitor_requirement_passed( snapshot: Option<&StatusSnapshot>, app_version: &str, ) -> bool { - agent.is_some_and(|agent| agent.running) + agent.is_some_and(|agent| agent.running && !agent.needs_reinstall) && snapshot.is_some_and(|snapshot| !snapshot.stale) && monitor_version_mismatch(snapshot, app_version).is_none() } @@ -1109,6 +1322,11 @@ fn monitor_requirement_message( let skew = monitor_version_mismatch(snapshot, app_version); match agent { Some(agent) if !agent.installed => "LaunchAgent plist missing".to_string(), + Some(agent) if agent.needs_reinstall => { + "LaunchAgent needs reinstall: it runs the CLI helper, not the app, \ + so Full Disk Access cannot reach the monitor" + .to_string() + } Some(agent) if !agent.loaded => "LaunchAgent installed but not loaded".to_string(), Some(agent) if !agent.running => { "LaunchAgent loaded but monitor process is not running".to_string() @@ -1137,6 +1355,9 @@ fn monitor_requirement_suggestion( Some(agent) if !agent.installed => { Some("Install the Git-Same monitor LaunchAgent".to_string()) } + Some(agent) if agent.needs_reinstall => { + Some("Reinstall the monitor LaunchAgent so it runs under the app identity".to_string()) + } Some(agent) if !agent.loaded || !agent.running => { Some("Restart the Git-Same monitor LaunchAgent".to_string()) } diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index 9df1525..eedb0b6 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -152,6 +152,7 @@ fn monitor_requirement_message_distinguishes_missing_plist() { installed: false, loaded: false, running: false, + needs_reinstall: false, state: "missing_plist".to_string(), message: "LaunchAgent plist is missing".to_string(), }; @@ -174,6 +175,7 @@ fn running_agent() -> MonitorLaunchAgentStatusDto { installed: true, loaded: true, running: true, + needs_reinstall: false, state: "running".to_string(), message: "Monitor running".to_string(), } @@ -589,3 +591,175 @@ fn parse_pluginkit_output_ignores_other_extensions() { } ); } + +#[test] +fn monitor_requirement_flags_agent_that_runs_outside_the_app_identity() { + let agent = MonitorLaunchAgentStatusDto { + needs_reinstall: true, + ..running_agent() + }; + let snapshot = snapshot_with_monitor_version(Some("3.2.0")); + + // A running monitor under the helper's path-based TCC identity must fail + // the row and steer the user to a reinstall, not a plain restart. + assert!(!monitor_requirement_passed( + Some(&agent), + Some(&snapshot), + "3.2.0" + )); + assert!( + monitor_requirement_message(Some(&agent), Some(&snapshot), "3.2.0") + .contains("needs reinstall") + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), Some(&snapshot), "3.2.0"), + Some("Reinstall the monitor LaunchAgent so it runs under the app identity".to_string()) + ); +} + +#[test] +fn monitor_launch_agent_program_reads_first_program_argument() { + let rendered = MONITOR_PLIST_TEMPLATE.replace( + "__GIT_SAME_MONITOR_BINARY__", + "/Applications/Git-Same.app/Contents/MacOS/git-same-app", + ); + + assert_eq!( + monitor_launch_agent_program(&rendered).as_deref(), + Some("/Applications/Git-Same.app/Contents/MacOS/git-same-app") + ); +} + +#[test] +fn monitor_launch_agent_program_unescapes_xml_and_rejects_empty() { + let rendered = MONITOR_PLIST_TEMPLATE.replace( + "__GIT_SAME_MONITOR_BINARY__", + &escape_xml("/Users/m/Tom & Jerry's/Git-Same.app/Contents/MacOS/git-same-app"), + ); + assert_eq!( + monitor_launch_agent_program(&rendered).as_deref(), + Some("/Users/m/Tom & Jerry's/Git-Same.app/Contents/MacOS/git-same-app") + ); + + let empty = MONITOR_PLIST_TEMPLATE.replace("__GIT_SAME_MONITOR_BINARY__", ""); + assert_eq!(monitor_launch_agent_program(&empty), None); + assert_eq!(monitor_launch_agent_program(""), None); +} + +#[test] +fn bundle_main_executable_detection_requires_app_contents_macos_layout() { + assert!(is_bundle_main_executable(Path::new( + "/Applications/Git-Same.app/Contents/MacOS/git-same-app" + ))); + // The CLI helper lives in Contents/Helpers and is a separate TCC identity. + assert!(!is_bundle_main_executable(Path::new( + "/Applications/Git-Same.app/Contents/Helpers/git-same" + ))); + assert!(!is_bundle_main_executable(Path::new( + "/Users/m/repo/target/debug/git-same-app" + ))); + assert!(!is_bundle_main_executable(Path::new( + "/Users/m/Git-Same.bundle/Contents/MacOS/git-same-app" + ))); +} + +#[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 b1fc203..e56f1f9 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![ @@ -24,6 +33,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, ]) .setup(|app| { @@ -50,9 +62,21 @@ fn main() { .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() { + // A LaunchAgent that still execs the CLI helper (pre-3.3 layout) + // runs the monitor under a separate path-based TCC identity, so a + // Full Disk Access grant for Git-Same never reaches it. Re-render + // the plist against this app executable and restart. Only a + // bundled build ever reports this; dev builds leave the user's + // agent alone. + let agent_needs_reinstall = commands::monitor_launch_agent_needs_reinstall(); + if host_status_is_symlink || agent_needs_reinstall { + std::thread::spawn(move || { + let result = if agent_needs_reinstall { + commands::reinstall_monitor_if_installed() + } else { + commands::restart_monitor_if_installed() + }; + if let Err(error) = result { 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..82844ac --- /dev/null +++ b/crates/git-same-app/src/monitor_mode.rs @@ -0,0 +1,78 @@ +//! Headless monitor mode for the app binary. +//! +//! The monitor LaunchAgent runs `Git-Same.app/Contents/MacOS/git-same-app +//! monitor --foreground` instead 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. Running the +//! loop through `Contents/Helpers/git-same` gives the monitor 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 itself lives in `git_same_core::monitor`; this file is the same +//! thin shim the CLI `monitor` subcommand uses. + +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 [--foreground]`. +/// Only the first argument is inspected; trailing legacy flags are ignored. +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") +} + +/// 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() { + Ok(()) => 0, + Err(error) => { + eprintln!("git-same-app monitor: {error}"); + 1 + } + } +} + +fn run_inner() -> Result<()> { + let config = Config::load()?; + let ipc_config = IpcConfig::default_path()?; + ipc_config.ensure_dir()?; + let output = Output::new(Verbosity::Quiet, false); + let opts = monitor::Options::from_config(&config, ipc_config, None); + let runtime = tokio::runtime::Runtime::new() + .map_err(|error| AppError::config(format!("tokio runtime init failed: {error}")))?; + 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..feac331 --- /dev/null +++ b/crates/git-same-app/src/monitor_mode_tests.rs @@ -0,0 +1,23 @@ +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())); +} diff --git a/crates/git-same-app/ui/src/App.svelte b/crates/git-same-app/ui/src/App.svelte index 98b68f8..b31e550 100644 --- a/crates/git-same-app/ui/src/App.svelte +++ b/crates/git-same-app/ui/src/App.svelte @@ -4,11 +4,27 @@ import Sidebar from './lib/Sidebar.svelte'; import StatusBanner from './lib/StatusBanner.svelte'; import TitleBar from './lib/TitleBar.svelte'; - 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; + // 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 { @@ -20,10 +36,14 @@ loading.set(false); } })(); + window.addEventListener('focus', handleFocus); + document.addEventListener('visibilitychange', handleVisibility); }); onDestroy(() => { unsubscribe?.(); + window.removeEventListener('focus', handleFocus); + document.removeEventListener('visibilitychange', handleVisibility); }); diff --git a/crates/git-same-app/ui/src/lib/Banner.svelte b/crates/git-same-app/ui/src/lib/Banner.svelte deleted file mode 100644 index 80af50d..0000000 --- a/crates/git-same-app/ui/src/lib/Banner.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - -{#if showError} - -{:else if showProgress} - -{:else if showStale} - -{:else if showAllowExt} - -{:else if showFda} - -{/if} - - diff --git a/crates/git-same-app/ui/src/lib/StatusBanner.svelte b/crates/git-same-app/ui/src/lib/StatusBanner.svelte index 481534a..3b1f58a 100644 --- a/crates/git-same-app/ui/src/lib/StatusBanner.svelte +++ b/crates/git-same-app/ui/src/lib/StatusBanner.svelte @@ -1,8 +1,10 @@ @@ -76,9 +127,14 @@ {row.detail} {#if row.action && !row.passed} - {/if} @@ -253,6 +309,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 3473a8c..9127656 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 @@ restartMonitor, } 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(); @@ -28,7 +24,12 @@ if (name === 'Finder extension') return 'extensions'; if (name === 'Full Disk Access') return 'fda'; if (name === 'Monitor') { - return message.includes('missing') ? 'monitor-install' : 'monitor-restart'; + // "needs reinstall" re-renders the plist against the app executable so + // the monitor shares the app's TCC identity; a plain restart would keep + // the old program. + return message.includes('missing') || message.includes('needs reinstall') + ? 'monitor-install' + : 'monitor-restart'; } return null; } diff --git a/crates/git-same-app/ui/src/stores/status.ts b/crates/git-same-app/ui/src/stores/status.ts index 9b3b686..eddbfef 100644 --- a/crates/git-same-app/ui/src/stores/status.ts +++ b/crates/git-same-app/ui/src/stores/status.ts @@ -2,6 +2,7 @@ import { derived, get, writable } from 'svelte/store'; import { checkRequirements, deleteWorkspace, + enableFinderExtension, ensureConfig, installMonitorLaunchAgent, listWorkspaces, @@ -9,6 +10,7 @@ import { onSyncProgress, readAppConfig, readExtensionStatus, + readFullDiskAccess, readStatus, readWorkspaceStructure, restartMonitorLaunchAgent, @@ -20,6 +22,7 @@ import type { AppConfigDto, AppConfigInput, ExtensionStatus, + FullDiskAccessDto, ProgressEvent, RequirementCheckDto, StatusSnapshot, @@ -36,6 +39,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); @@ -61,7 +65,7 @@ export const currentWorkspace = derived( export async function refresh(): Promise { errorMessage.set(''); - 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[]; @@ -71,13 +75,70 @@ export async function refresh(): Promise { return null; }), readExtensionStatus().catch(() => null), + readFullDiskAccess().catch(() => null), readAppConfig().catch(() => null), ]); workspaces.set(workspaceList); snapshot.set(status); 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 4fcb359..e0a329c 100644 --- a/crates/git-same-cli/src/commands/monitor.rs +++ b/crates/git-same-cli/src/commands/monitor.rs @@ -1,8 +1,9 @@ //! `gisa monitor`: start, stop, or query the long-running monitor process. //! //! The actual run-loop lives in `git_same_core::monitor`. This file is the -//! CLI surface only: parse args, handle `--status` / `--stop` locally, build -//! the shutdown future from `ctrl_c` + SIGTERM, and call into core. +//! CLI surface only: parse args, handle `--status` / `--stop` locally, and +//! call into core with the shared options and shutdown signal (the Tauri +//! app's headless monitor mode uses the same two helpers). //! //! `--status` and `--stop` stay in the CLI because they don't need the loop: //! they just read the status file or send a kill signal to the recorded PID. @@ -13,7 +14,6 @@ use git_same_core::errors::Result; use git_same_core::ipc::{IpcConfig, StatusFileWriter}; use git_same_core::monitor; use git_same_core::output::Output; -use std::time::Duration; use tracing::info; /// Run the `monitor` subcommand. @@ -30,43 +30,8 @@ pub async fn run(args: &MonitorArgs, config: &Config, output: &Output) -> Result 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, - }; - - monitor::run(config, output, opts, shutdown_signal()).await -} - -/// 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 `gisa monitor --stop` -/// 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; - } + let opts = monitor::Options::from_config(config, ipc_config, args.interval); + monitor::run(config, output, opts, monitor::default_shutdown_signal()).await } /// Show monitor status. diff --git a/crates/git-same-cli/src/commands/monitor_tests.rs b/crates/git-same-cli/src/commands/monitor_tests.rs index 492682b..e4b6c61 100644 --- a/crates/git-same-cli/src/commands/monitor_tests.rs +++ b/crates/git-same-cli/src/commands/monitor_tests.rs @@ -23,13 +23,3 @@ fn test_is_process_alive_rejects_out_of_range_pid() { assert!(!is_process_alive(u32::MAX)); assert!(!is_process_alive(0)); } - -#[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); -} 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 f222374..72fef55 100644 --- a/crates/git-same-core/src/macos/mod.rs +++ b/crates/git-same-core/src/macos/mod.rs @@ -1,8 +1,10 @@ //! 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; diff --git a/crates/git-same-core/src/monitor/mod.rs b/crates/git-same-core/src/monitor/mod.rs index 448c8c8..6795318 100644 --- a/crates/git-same-core/src/monitor/mod.rs +++ b/crates/git-same-core/src/monitor/mod.rs @@ -17,4 +17,4 @@ pub mod run; #[cfg(unix)] pub mod socket_handler; -pub use run::{run, Options}; +pub use run::{default_shutdown_signal, run, Options}; diff --git a/crates/git-same-core/src/monitor/run.rs b/crates/git-same-core/src/monitor/run.rs index b4a13e9..95860a0 100644 --- a/crates/git-same-core/src/monitor/run.rs +++ b/crates/git-same-core/src/monitor/run.rs @@ -41,6 +41,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; + } +} + /// Run the monitor loop until `shutdown` resolves. pub async fn run(config: &Config, output: &Output, opts: Options, shutdown: S) -> Result<()> where @@ -331,3 +372,7 @@ fn enclosing_repo(path: &Path, watched_roots: &[PathBuf]) -> Option { current = current.parent()?; } } + +#[cfg(test)] +#[path = "run_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/monitor/run_tests.rs b/crates/git-same-core/src/monitor/run_tests.rs new file mode 100644 index 0000000..6444d28 --- /dev/null +++ b/crates/git-same-core/src/monitor/run_tests.rs @@ -0,0 +1,28 @@ +use super::*; + +fn ipc_config() -> IpcConfig { + IpcConfig { + dir: std::env::temp_dir().join("git-same-monitor-run-tests"), + } +} + +#[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_config(), None); + + assert_eq!(opts.interval, Duration::from_secs(90)); + assert_eq!(opts.ipc_config.dir, ipc_config().dir); +} + +#[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_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 532355f..4bcf4a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -244,6 +244,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 46c3973..934c1ed 100644 --- a/toolkit/homebrew/cask.rb.tmpl +++ b/toolkit/homebrew/cask.rb.tmpl @@ -38,7 +38,11 @@ cask "git-same" do plist_src = "#{appdir}/Git-Same.app/Contents/Resources/com.zaai.git-same.monitor.plist" plist_dst = "#{Dir.home}/Library/LaunchAgents/com.zaai.git-same.monitor.plist" - monitor_binary = "#{appdir}/Git-Same.app/Contents/Helpers/git-same" + # The agent execs the app's main executable (headless `monitor` mode), not + # the CLI helper: macOS TCC attributes a launchd-spawned process to its + # bundle only through the bundle's own executable, so this is what lets a + # single Full Disk Access grant for Git-Same cover the monitor. + monitor_binary = "#{appdir}/Git-Same.app/Contents/MacOS/git-same-app" FileUtils.mkdir_p(File.dirname(plist_dst)) rendered = File.read(plist_src).gsub("__GIT_SAME_MONITOR_BINARY__", monitor_binary) diff --git a/toolkit/packaging/macos/build-app-bundle.sh b/toolkit/packaging/macos/build-app-bundle.sh index 4bebb4b..3eefdfe 100755 --- a/toolkit/packaging/macos/build-app-bundle.sh +++ b/toolkit/packaging/macos/build-app-bundle.sh @@ -96,6 +96,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 bd28f84f73984f896e304b338c54ba4be9945681 Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 9 Sep 2026 11:11:11 +0200 Subject: [PATCH 16/19] Update vulnerable dependencies to resolve Dependabot findings --- .github/workflows/S1-Test-CI.yml | 4 ++-- .github/workflows/S2-Release-GitHub.yml | 6 +++--- crates/git-same-app/ui/pnpm-lock.yaml | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index 097c016..5c3a8ae 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -114,7 +114,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 @@ -161,7 +161,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 aa54467..b9cbb79 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 @@ -423,7 +423,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 @@ -502,7 +502,7 @@ jobs: find artifacts -type f -exec cp {} release-assets/ \; - name: Create/update release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: files: release-assets/* env: diff --git a/crates/git-same-app/ui/pnpm-lock.yaml b/crates/git-same-app/ui/pnpm-lock.yaml index 9378c5f..424ab31 100644 --- a/crates/git-same-app/ui/pnpm-lock.yaml +++ b/crates/git-same-app/ui/pnpm-lock.yaml @@ -430,8 +430,8 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -446,8 +446,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} readdirp@4.1.2: @@ -824,7 +824,7 @@ snapshots: mri@1.2.0: {} - nanoid@3.3.15: {} + nanoid@3.3.18: {} obug@2.1.3: {} @@ -832,9 +832,9 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.16: + postcss@8.5.28: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -922,7 +922,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.16 + postcss: 8.5.28 rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: From e81649466fcc71fb83838785b656c0fcce4130ae Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 9 Sep 2026 15:00:09 +0200 Subject: [PATCH 17/19] 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 eedb0b6..897716e 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 da0d8b5..194259d 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 0bf5510218ca748956c1166f00636fd40e94c499 Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 9 Sep 2026 15:59:56 +0200 Subject: [PATCH 18/19] 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. --- .github/workflows/S1-Test-CI.yml | 9 ++++++++- crates/git-same-app/src/main.rs | 2 +- .../src/macos/full_disk_access_tests.rs | 14 +++++++++++--- crates/git-same-core/src/provider/traits.rs | 3 +++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index 5c3a8ae..e9231da 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -204,7 +204,7 @@ jobs: # Exactly 1 default [[bin]] in Cargo.toml. [[bin]] entries gated by # `required-features` (e.g. release-tools helpers gen-completions and - # gen-manpage) are excluded — they don't ship in normal builds. + # gen-manpage) are excluded: they don't ship in normal builds. read DEFAULT_COUNT CARGO_BIN <<<"$(awk ' function flush() { if (in_bin && !has_req) { @@ -305,10 +305,17 @@ jobs: permissions: contents: read checks: write + # audit-check builds cargo-audit from source with whatever toolchain cargo + # resolves. rust-toolchain.toml pins 1.93.1 for the crate itself, but + # cargo-audit's dependency graph now requires a newer rustc (kstring 2.0.4 + # needs 1.96), so the audit job overrides the pin and uses current stable. + env: + RUSTUP_TOOLCHAIN: stable steps: - uses: actions/checkout@v7 with: persist-credentials: false + - uses: dtolnay/rust-toolchain@stable - uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index e56f1f9..c7432c2 100644 --- a/crates/git-same-app/src/main.rs +++ b/crates/git-same-app/src/main.rs @@ -62,7 +62,7 @@ fn main() { .symlink_metadata() .map(|meta| meta.file_type().is_symlink()) .unwrap_or(false); - // A LaunchAgent that still execs the CLI helper (pre-3.3 layout) + // A LaunchAgent that still execs the CLI helper (pre-3.2 layout) // runs the monitor under a separate path-based TCC identity, so a // Full Disk Access grant for Git-Same never reaches it. Re-render // the plist against this app executable and restart. Only a 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"); diff --git a/crates/git-same-core/src/provider/traits.rs b/crates/git-same-core/src/provider/traits.rs index 4d018d1..582319f 100644 --- a/crates/git-same-core/src/provider/traits.rs +++ b/crates/git-same-core/src/provider/traits.rs @@ -172,6 +172,9 @@ impl DiscoveryProgress for NoProgress { /// /// This trait defines the interface for interacting with Git hosting providers /// like GitHub, GitLab, and Bitbucket. +// async_trait expands each async fn with a #[must_use] attribute on a future +// that is already must_use; newer clippy flags that as double_must_use. +#[allow(clippy::double_must_use)] #[async_trait] pub trait Provider: Send + Sync { /// Returns the provider kind (GitHub, GitLab, etc.). From 3c90d6d067a2df4279a7605dffb54f440ad60f6d Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 10 Sep 2026 00:55:12 +0200 Subject: [PATCH 19/19] Bump h2 and plist to clear audit advisories In-range lockfile updates: h2 0.4.19 fixes RUSTSEC-2026-0258, and plist 1.10.1 moves quick-xml to 0.42.0, which fixes RUSTSEC-2026-0194 and RUSTSEC-2026-0195. No source changes. --- Cargo.lock | 48 +++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c4a700..12447b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -94,7 +94,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -212,6 +212,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.5.3" @@ -559,7 +565,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -911,7 +917,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1100,7 +1106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1715,9 +1721,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2595,7 +2601,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2684,7 +2690,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3235,11 +3241,11 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "indexmap 2.14.0", "quick-xml", "serde", @@ -3372,9 +3378,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] @@ -3788,7 +3794,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3845,7 +3851,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4294,7 +4300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4839,7 +4845,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4862,7 +4868,7 @@ dependencies = [ "parking_lot", "rustix", "signal-hook", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5352,7 +5358,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5881,7 +5887,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]]