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