From 0a6e4f0d07fd4f44fbc2634b899d735c5c791b68 Mon Sep 17 00:00:00 2001 From: Manuel Date: Sat, 27 Jun 2026 19:31:18 +0200 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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}"); }