diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c32b256..5af3380 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -43,7 +43,9 @@ Git-Same is a Rust CLI + TUI + macOS Tauri app that discovers GitHub org/repo st **Commands:** `init`, `setup`, `sync`, `status`, `scan`, `workspace {list,default}`, `reset`, `monitor` (alias: `daemon`), `refresh`. -**Why `monitor` is a CLI subcommand and not solely a Tauri-host responsibility:** the LaunchAgent invokes `gisa monitor --foreground`, non-cask installs (`cargo install`, the homebrew formula) ship only the binary, `--status` / `--stop` are the supported debugging surface, and a future Linux file-manager extension would talk to the same `gisa monitor` over the same Unix socket. The CLI handler is a thin shim (~140 lines); the loop itself lives in `git-same-core::monitor`. +**Why `monitor` is a CLI subcommand and not solely a Tauri-host responsibility:** non-cask installs (`cargo install`, the homebrew formula) ship only the binary, `--status` / `--stop` are the supported debugging surface, and a future Linux file-manager extension would talk to the same `gisa monitor` over the same Unix socket. The CLI handler is a thin shim; the loop itself lives in `git-same-core::monitor`, and `monitor::Options::from_config` / `monitor::default_shutdown_signal` are shared with the app. + +**Why the LaunchAgent runs `Git-Same.app/Contents/MacOS/git-same-app monitor --foreground` and not the helper:** macOS TCC attributes a launchd-spawned process to its bundle only when the executable is the bundle's `CFBundleExecutable`. `Contents/Helpers/git-same` is a separate, path-based TCC identity, so a Full Disk Access grant for "Git-Same" never reaches a monitor started through it. `crates/git-same-app/src/monitor_mode.rs` runs the same core loop headlessly (no Tauri, no window, no Dock icon) when argv[1] is `monitor`. `monitor_binary_candidates()` in `commands.rs` prefers the bundled app executable, the app re-renders an installed agent that still points elsewhere on launch, and the cask renders the same path. Dev builds outside an `.app` fall back to the helper and keep a path-based identity. ### Engine modules (`crates/git-same-core/src/`) @@ -112,7 +114,9 @@ Three non-obvious traps in `macos/GitSameBadges/`. Each one silently breaks badg 3. **Google Drive's FinderSync poisons the badge-rendering pipeline.** When `com.google.drivefs.finderhelper.findersync` is enabled, peer FinderSync extensions render no badge image even after Finder calls `setBadgeIdentifier`. Confirmed in this environment: badges only began appearing after the user disabled Google Drive in System Settings → Login Items & Extensions. Other peers (Keka, Synology, Dropbox) coexist fine. There is no code fix; document the workaround and surface it in the in-app self-check if you can. -`scan_roots` and `show_ambient`: defaults are `["~"]` / `false`. Never re-enable `show_ambient = true` with `~` in `scan_roots`: Finder refuses to call `requestBadgeIdentifier` on extensions whose `directoryURLs` contain the home folder (separate issue from the three above). +4. **Full Disk Access is per executable, and the extension is not the process that needs it.** The appex does zero I/O outside its app-group container and has never triggered a TCC prompt. The monitor is what walks workspace roots, reads `/Volumes`, watches FSEvents, and writes `Icon\r`, so it is the process that needs FDA. `git-same-core::macos::full_disk_access::probe()` (opens the user TCC.db; success proves the grant, EPERM means denied, never prompts) is stamped into `status.json` as `full_disk_access` by the monitor and read by the app, whose `enable_finder_extension` command refuses to set the pluginkit election until the gate passes. macOS applies a new grant on process start: the app must be relaunched and the monitor restarted (the app does the latter automatically). + +`scan_roots` and `show_ambient`: defaults are `["~"]` / `false`. Never re-enable `show_ambient = true` with `~` in `scan_roots`: Finder refuses to call `requestBadgeIdentifier` on extensions whose `directoryURLs` contain the home folder (separate issue from the four above). ## Workspace folder branding (macOS) diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index 097c016..e9231da 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -114,7 +114,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 @@ -161,7 +161,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Install cargo-tarpaulin - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@v2.87.8 with: tool: cargo-tarpaulin @@ -204,7 +204,7 @@ jobs: # Exactly 1 default [[bin]] in Cargo.toml. [[bin]] entries gated by # `required-features` (e.g. release-tools helpers gen-completions and - # gen-manpage) are excluded — they don't ship in normal builds. + # gen-manpage) are excluded: they don't ship in normal builds. read DEFAULT_COUNT CARGO_BIN <<<"$(awk ' function flush() { if (in_bin && !has_req) { @@ -305,10 +305,17 @@ jobs: permissions: contents: read checks: write + # audit-check builds cargo-audit from source with whatever toolchain cargo + # resolves. rust-toolchain.toml pins 1.93.1 for the crate itself, but + # cargo-audit's dependency graph now requires a newer rustc (kstring 2.0.4 + # needs 1.96), so the audit job overrides the pin and uses current stable. + env: + RUSTUP_TOOLCHAIN: stable steps: - uses: actions/checkout@v7 with: persist-credentials: false + - uses: dtolnay/rust-toolchain@stable - uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/S2-Release-GitHub.yml b/.github/workflows/S2-Release-GitHub.yml index aa54467..b9cbb79 100644 --- a/.github/workflows/S2-Release-GitHub.yml +++ b/.github/workflows/S2-Release-GitHub.yml @@ -65,7 +65,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Install cargo-tarpaulin - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@v2.87.8 with: tool: cargo-tarpaulin @@ -423,7 +423,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 @@ -502,7 +502,7 @@ jobs: find artifacts -type f -exec cp {} release-assets/ \; - name: Create/update release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: files: release-assets/* env: diff --git a/Cargo.lock b/Cargo.lock index 222f6d3..12447b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -94,7 +94,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -179,9 +179,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -189,14 +189,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -211,6 +212,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.5.3" @@ -364,9 +371,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] @@ -502,9 +509,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" dependencies = [ "clap", ] @@ -558,7 +565,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -587,9 +594,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -910,7 +917,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1099,7 +1106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1523,7 +1530,7 @@ dependencies = [ [[package]] name = "git-same" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anyhow", "chrono", @@ -1548,7 +1555,7 @@ dependencies = [ [[package]] name = "git-same-app" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anyhow", "chrono", @@ -1560,13 +1567,16 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "time", "tokio", "toml 1.1.2+spec-1.1.0", + "tracing", + "tracing-subscriber", ] [[package]] name = "git-same-core" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anyhow", "async-trait", @@ -1711,9 +1721,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2059,9 +2069,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -2101,9 +2111,9 @@ dependencies = [ [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "6fda9741ca16536952da2ecaa6105c2f4653fa6f0724681df6d2414c4106d0b0" dependencies = [ "libc", ] @@ -2257,9 +2267,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -2399,9 +2409,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -2444,9 +2454,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -2591,7 +2601,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2680,7 +2690,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3231,11 +3241,11 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "indexmap 2.14.0", "quick-xml", "serde", @@ -3368,18 +3378,18 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3433,9 +3443,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3784,14 +3794,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", @@ -3815,9 +3825,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -3841,7 +3851,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4290,7 +4300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4556,9 +4566,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.3" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -4752,9 +4762,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.3" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -4835,7 +4845,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4858,7 +4868,7 @@ dependencies = [ "parking_lot", "rustix", "signal-hook", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4975,9 +4985,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", "libc", @@ -4997,9 +5007,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -5348,7 +5358,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5502,9 +5512,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "atomic", "getrandom 0.4.3", @@ -5596,9 +5606,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5609,9 +5619,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5619,9 +5629,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5629,9 +5639,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -5642,9 +5652,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -5664,9 +5674,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -5877,7 +5887,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 384aab8..3638049 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.1.0" +version = "3.2.0" edition = "2021" authors = ["Manuel Gruber"] license = "MIT" diff --git a/crates/git-same-app/Cargo.toml b/crates/git-same-app/Cargo.toml index 53478ca..b9d197f 100644 --- a/crates/git-same-app/Cargo.toml +++ b/crates/git-same-app/Cargo.toml @@ -24,6 +24,12 @@ serde = { workspace = true } serde_json = { workspace = true } shellexpand = { workspace = true } tauri = { version = "2", features = [] } +# Pin: tauri's transitive `cookie` 0.18.1 calls time's Parsable::parse with the +# pre-0.3.52 one-arg signature; time 0.3.52 made it two-arg and fails to compile. +# No fixed cookie release exists yet. Remove this cap once cookie ships a fix. +time = ">=0.3, <0.3.52" tauri-plugin-dialog = "2" tokio = { workspace = true } toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 0af19b6..d6671c0 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; -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::progress::{ProgressEvent, ProgressReporter}; use git_same_core::provider::{create_provider, NoProgress}; use git_same_core::setup::{authenticate_provider, discover_org_entries}; @@ -42,6 +43,14 @@ const MONITOR_PLIST_TEMPLATE: &str = include_str!("../../../macos/com.zaai.git-s #[path = "commands_tests.rs"] mod tests; +/// Resolved host-facing IPC config, shared across Tauri command handlers via +/// `tauri::State`. Resolved once in `main.rs` `setup()` so handlers read live +/// status from `~/.config/git-same/finder/` (where the monitor mirrors a real +/// `status.json`) instead of reaching into the app-group container, which would +/// trigger the "access data from other apps" TCC prompt on the non-sandboxed +/// host. +pub struct HostIpc(pub IpcConfig); + #[derive(Debug, Clone, Serialize)] pub struct WorkspaceSummary { pub id: String, @@ -225,10 +234,30 @@ pub struct MonitorLaunchAgentStatusDto { pub installed: bool, pub loaded: bool, pub running: bool, + /// The installed plist execs something other than this app executable + /// (typically the CLI helper), so the monitor runs under a TCC identity + /// that a Full Disk Access grant for Git-Same does not reach. + pub needs_reinstall: bool, pub state: String, pub message: String, } +/// Full Disk Access as seen by the host and by the monitor. TCC keys the +/// grant on the executable, so both answers are reported and `granted` is +/// the gate the badge setup flow uses (see `fda_gate_passes`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FullDiskAccessDto { + /// This app process's own probe: `granted`, `denied`, `unknown`, or + /// `not_applicable`. + pub host: String, + /// The monitor's stamped answer from `status.json`, when it wrote one. + pub monitor: Option, + /// Whether that status is fresh; a stale monitor may predate a grant. + pub monitor_fresh: bool, + /// Whether Finder badges may be enabled. + pub granted: bool, +} + #[derive(Debug, Clone, Serialize)] pub struct SyncProgressPayload { pub workspace_id: String, @@ -368,13 +397,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) } @@ -429,14 +460,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 = @@ -479,8 +511,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 { @@ -515,6 +546,121 @@ pub fn extension_status() -> Result { } } +/// Enable the Finder badge extension, refusing until Full Disk Access is +/// granted: without it the monitor cannot read protected folders and the +/// badges would silently stay blank. The gate lives here, not only in the UI, +/// so no frontend path can bypass it. +#[tauri::command] +pub fn enable_finder_extension(ipc: tauri::State<'_, HostIpc>) -> Result { + let fda = full_disk_access_status_inner(&ipc.0); + if !fda.granted { + return Err("Grant Full Disk Access to Git-Same before enabling Finder badges".to_string()); + } + set_extension_election(ExtensionElection::Use).map_err(|error| error.to_string())?; + extension_status() +} + +#[tauri::command] +pub fn disable_finder_extension() -> Result { + set_extension_election(ExtensionElection::Ignore).map_err(|error| error.to_string())?; + extension_status() +} + +#[tauri::command] +pub fn full_disk_access_status( + ipc: tauri::State<'_, HostIpc>, +) -> Result { + Ok(full_disk_access_status_inner(&ipc.0)) +} + +fn full_disk_access_status_inner(ipc: &IpcConfig) -> FullDiskAccessDto { + let snapshot = read_status_snapshot_with(ipc).ok(); + full_disk_access_dto(full_disk_access::probe(), snapshot.as_ref()) +} + +fn full_disk_access_dto( + host: FullDiskAccess, + snapshot: Option<&StatusSnapshot>, +) -> FullDiskAccessDto { + let monitor_fresh = snapshot.is_some_and(|snapshot| !snapshot.stale); + let monitor = snapshot + .and_then(|snapshot| snapshot.status.as_ref()) + .and_then(|status| status.full_disk_access); + FullDiskAccessDto { + host: host.as_str().to_string(), + monitor, + monitor_fresh, + granted: fda_gate_passes(host, monitor, monitor_fresh), + } +} + +/// The badge-setup gate. A fresh monitor's own answer wins because TCC keys +/// the grant on the monitor executable; otherwise fall back to this process's +/// probe (the same identity once the LaunchAgent runs the app executable). +/// Only a definite "granted" passes; unknown never does. +fn fda_gate_passes(host: FullDiskAccess, monitor: Option, monitor_fresh: bool) -> bool { + match (monitor_fresh, monitor) { + (true, Some(granted)) => granted, + _ => host == FullDiskAccess::Granted, + } +} + +fn full_disk_access_message(fda: &FullDiskAccessDto) -> String { + match (fda.granted, fda.host.as_str(), fda.monitor) { + (true, _, _) => "granted to Git-Same", + (false, "granted", Some(false)) => { + "granted to the app, but the running monitor lacks it (restart the monitor)" + } + (false, "not_applicable", _) => "not applicable on this platform", + (false, "unknown", None) => "could not be determined", + _ => "not granted (required for Finder badges)", + } + .to_string() +} + +/// `pluginkit -e `: the user election macOS stores for an app +/// extension. `use` is what the System Settings toggle sets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExtensionElection { + Use, + Ignore, +} + +impl ExtensionElection { + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + fn pluginkit_arg(self) -> &'static str { + match self { + Self::Use => "use", + Self::Ignore => "ignore", + } + } +} + +fn set_extension_election(election: ExtensionElection) -> Result<(), AppError> { + #[cfg(target_os = "macos")] + { + let output = Command::new("/usr/bin/pluginkit") + .args(["-e", election.pluginkit_arg(), "-i", FINDER_EXTENSION_ID]) + .output() + .map_err(|error| AppError::config(format!("pluginkit invocation failed: {error}")))?; + if output.status.success() { + return Ok(()); + } + Err(AppError::config(format!( + "pluginkit -e {} failed: {}", + election.pluginkit_arg(), + String::from_utf8_lossy(&output.stderr).trim() + ))) + } + #[cfg(not(target_os = "macos"))] + { + let _ = election; + Err(AppError::config( + "Finder extensions are only available on macOS", + )) + } +} + #[tauri::command] pub fn open_url(url: String) -> Result<(), String> { #[cfg(target_os = "macos")] @@ -546,6 +692,7 @@ fn monitor_launch_agent_status_inner() -> Result Result Result Result<(), AppError> { + if !monitor_launch_agent_path()?.exists() { + return Ok(()); + } + restart_monitor_launch_agent_inner()?; + Ok(()) +} + +/// Re-render and restart the monitor LaunchAgent *only if it is already +/// installed*, so an agent that still execs the CLI helper (a separate, +/// path-based TCC identity) moves onto this app executable and the user's +/// Full Disk Access grant reaches the monitor. Never installs implicitly. +pub(crate) fn reinstall_monitor_if_installed() -> Result<(), AppError> { + if !monitor_launch_agent_path()?.exists() { + return Ok(()); + } + install_monitor_launch_agent_inner()?; + Ok(()) +} + +/// True when a LaunchAgent is installed but execs something other than this +/// bundled app executable. Only the bundle's main executable gives the monitor +/// the bundle's TCC identity, so any other program (the CLI helper, a cargo +/// install) must be re-rendered before Full Disk Access can cover the monitor. +/// Dev builds (not inside an `.app`) never report this, so they leave the +/// user's installed agent alone. +pub(crate) fn monitor_launch_agent_needs_reinstall() -> bool { + let Some(expected) = bundled_app_executable() else { + return false; + }; + let Ok(plist_path) = monitor_launch_agent_path() else { + return false; + }; + let Ok(plist) = fs::read_to_string(&plist_path) else { + return false; + }; + monitor_launch_agent_program(&plist).is_some_and(|program| Path::new(&program) != expected) +} + +/// The program a rendered monitor plist execs (`ProgramArguments[0]`), with +/// XML escapes undone, or `None` when the plist has no program. +fn monitor_launch_agent_program(plist: &str) -> Option { + let start = plist.find("ProgramArguments")?; + let rest = &plist[start..]; + let open = rest.find("")? + "".len(); + let close = rest[open..].find("")? + open; + let raw = rest[open..close].trim(); + (!raw.is_empty()).then(|| unescape_xml(raw)) +} + +/// This executable when it is an app bundle's main binary +/// (`.app/Contents/MacOS/`), else `None` (dev builds, cargo runs). +fn bundled_app_executable() -> Option { + let exe = env::current_exe().ok()?; + is_bundle_main_executable(&exe).then_some(exe) +} + +fn is_bundle_main_executable(exe: &Path) -> bool { + let macos_dir = exe.parent(); + let contents_dir = macos_dir.and_then(Path::parent); + let app_dir = contents_dir.and_then(Path::parent); + macos_dir + .and_then(Path::file_name) + .is_some_and(|name| name == "MacOS") + && contents_dir + .and_then(Path::file_name) + .is_some_and(|name| name == "Contents") + && app_dir + .and_then(Path::extension) + .is_some_and(|extension| extension == "app") +} + fn restart_monitor_launch_agent_inner() -> Result { let plist_path = monitor_launch_agent_path()?; if !plist_path.exists() { @@ -616,8 +843,15 @@ fn monitor_binary_path() -> Result { )) } +/// Candidate programs for the monitor LaunchAgent, best first. The bundled +/// app executable leads because it is the only program that runs the monitor +/// under the bundle's TCC identity; the CLI helper and cargo installs are +/// fallbacks for unbundled dev builds. fn monitor_binary_candidates() -> Vec { let mut candidates = Vec::new(); + if let Some(exe) = bundled_app_executable() { + candidates.push(exe); + } if let Ok(exe) = env::current_exe() { if let Some(contents) = exe.ancestors().find(|path| { path.file_name() @@ -666,6 +900,15 @@ fn escape_xml(value: &str) -> String { .replace('\'', "'") } +fn unescape_xml(value: &str) -> String { + value + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + fn is_executable(path: &Path) -> bool { #[cfg(unix)] { @@ -957,7 +1200,7 @@ fn sync_mode_label(sync_mode: SyncMode) -> String { .to_string() } -fn app_requirement_checks() -> Vec { +fn app_requirement_checks(ipc: &IpcConfig) -> Vec { let config_path = match Config::default_path() { Ok(path) => path, Err(error) => { @@ -983,14 +1226,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(|agent| agent.running) - && snapshot.as_ref().is_some_and(|snapshot| !snapshot.stale), - message: monitor_requirement_message(monitor_agent.as_ref(), snapshot.as_ref()), - suggestion: monitor_requirement_suggestion(monitor_agent.as_ref(), snapshot.as_ref()), + 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, }); @@ -1016,32 +1270,63 @@ fn app_requirement_checks() -> Vec { critical: false, }); - let fda_needed = snapshot - .as_ref() - .and_then(|snapshot| snapshot.status.as_ref()) - .is_some_and(|status| !status.workspaces.is_empty() && status.repos.is_empty()); + let fda = full_disk_access_dto(full_disk_access::probe(), snapshot.as_ref()); checks.push(RequirementCheckDto { name: "Full Disk Access".to_string(), - passed: !fda_needed, - message: if fda_needed { - "no repositories visible to the monitor".to_string() - } else { - "not currently required".to_string() - }, - suggestion: fda_needed - .then(|| "Grant Full Disk Access to Git-Same in System Settings".to_string()), + passed: fda.granted, + message: full_disk_access_message(&fda), + suggestion: (!fda.granted).then(|| { + "Grant Full Disk Access to Git-Same in System Settings, then quit and reopen the app" + .to_string() + }), critical: false, }); checks } +/// The monitor's build version when the mirrored status reports one that +/// differs from the app's own build, or `None` when they match or none is +/// known. Older monitors that predate the `monitor_version` field, or that are +/// too old to mirror a readable status at all, report `None` here; the stale +/// arm covers that case instead. +fn monitor_version_mismatch( + snapshot: Option<&StatusSnapshot>, + app_version: &str, +) -> Option { + snapshot + .and_then(|snapshot| snapshot.status.as_ref()) + .and_then(|status| status.monitor_version.clone()) + .filter(|version| version != app_version) +} + +/// Whether the Monitor requirement is satisfied. Mirrors the conditions that +/// `monitor_requirement_message`/`monitor_requirement_suggestion` treat as +/// problems, including a build-version skew, so the row's pass state never +/// contradicts its own message and suggestion. +fn monitor_requirement_passed( + agent: Option<&MonitorLaunchAgentStatusDto>, + snapshot: Option<&StatusSnapshot>, + app_version: &str, +) -> bool { + agent.is_some_and(|agent| agent.running && !agent.needs_reinstall) + && snapshot.is_some_and(|snapshot| !snapshot.stale) + && monitor_version_mismatch(snapshot, app_version).is_none() +} + fn monitor_requirement_message( agent: Option<&MonitorLaunchAgentStatusDto>, snapshot: Option<&StatusSnapshot>, + app_version: &str, ) -> String { + let skew = monitor_version_mismatch(snapshot, app_version); match agent { Some(agent) if !agent.installed => "LaunchAgent plist missing".to_string(), + Some(agent) if agent.needs_reinstall => { + "LaunchAgent needs reinstall: it runs the CLI helper, not the app, \ + so Full Disk Access cannot reach the monitor" + .to_string() + } Some(agent) if !agent.loaded => "LaunchAgent installed but not loaded".to_string(), Some(agent) if !agent.running => { "LaunchAgent loaded but monitor process is not running".to_string() @@ -1049,6 +1334,11 @@ fn monitor_requirement_message( Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => { "Monitor running but status file is stale".to_string() } + Some(_) if skew.is_some() => format!( + "Monitor is running a different build ({}) than the app ({})", + skew.as_deref().unwrap_or_default(), + app_version + ), Some(_) => snapshot .and_then(|snapshot| snapshot.updated_at.clone()) .unwrap_or_else(|| "Monitor running".to_string()), @@ -1059,16 +1349,25 @@ fn monitor_requirement_message( fn monitor_requirement_suggestion( agent: Option<&MonitorLaunchAgentStatusDto>, snapshot: Option<&StatusSnapshot>, + app_version: &str, ) -> Option { match agent { Some(agent) if !agent.installed => { Some("Install the Git-Same monitor LaunchAgent".to_string()) } + Some(agent) if agent.needs_reinstall => { + Some("Reinstall the monitor LaunchAgent so it runs under the app identity".to_string()) + } Some(agent) if !agent.loaded || !agent.running => { Some("Restart the Git-Same monitor LaunchAgent".to_string()) } - Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => { - Some("Restart the monitor or wait for the next scan".to_string()) + Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => Some( + "Restart the monitor (Settings) or wait for the next scan; \ + if you just upgraded, a restart picks up the new monitor build" + .to_string(), + ), + Some(_) if monitor_version_mismatch(snapshot, app_version).is_some() => { + Some("Restart the monitor so it runs the same build as the app".to_string()) } Some(_) => None, None => Some("Check LaunchAgent permissions and the git-same binary path".to_string()), @@ -1240,23 +1539,21 @@ fn requirement_check_dto(check: CheckResult) -> RequirementCheckDto { } } -pub(crate) fn read_status_snapshot() -> Result { - let ipc = IpcConfig::default_path()?; - read_status_snapshot_with(&ipc) -} - -fn read_status_snapshot_with(ipc: &IpcConfig) -> Result { +pub(crate) fn read_status_snapshot_with(ipc: &IpcConfig) -> Result { ipc.ensure_dir()?; let status_path = ipc.status_file_path(); - let writer = StatusFileWriter::new(status_path.clone()); - let metadata = fs::metadata(&status_path).ok(); - let updated_at = metadata - .as_ref() - .and_then(|meta| meta.modified().ok()) - .map(system_time_to_rfc3339); - let stale_by_age = metadata - .as_ref() - .and_then(|meta| meta.modified().ok()) + // Older layouts symlinked status.json into the app-group container; + // following that link would re-trigger the "access data from other apps" + // TCC prompt, so unlink it before anything dereferences the path. The + // monitor's next mirror write recreates a real file here. + remove_symlink_if_present(&status_path)?; + // Single parse: None covers both a missing and a corrupt status file. + let status = StatusFileWriter::new(status_path.clone()).read().ok(); + let modified = fs::metadata(&status_path) + .ok() + .and_then(|meta| meta.modified().ok()); + let updated_at = modified.map(system_time_to_rfc3339); + let stale_by_age = modified .map(|modified| { modified .elapsed() @@ -1264,22 +1561,11 @@ fn read_status_snapshot_with(ipc: &IpcConfig) -> Result Duration::from_secs(DAEMON_STALE_AFTER_SECS) }) .unwrap_or(true); - let monitor_alive = if writer.exists() { - writer - .read() - .map(|status| is_process_alive(status.daemon_pid)) - .unwrap_or(false) - } else { - false - }; + let monitor_alive = status + .as_ref() + .map(|status| is_process_alive(status.daemon_pid)) + .unwrap_or(false); let stale = stale_by_age || !monitor_alive; - let status = if writer.exists() && !stale { - Some(writer.read()?) - } else if writer.exists() { - writer.read().ok() - } else { - None - }; Ok(StatusSnapshot { status_path: status_path.display().to_string(), diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index 4aa19fa..897716e 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -13,6 +13,15 @@ impl ConfigEnvGuard { let lock = CONFIG_ENV_LOCK.lock().unwrap(); let previous = std::env::var("GIT_SAME_CONFIG_DIR").ok(); std::env::set_var("GIT_SAME_CONFIG_DIR", path); + // Fail fast if isolation ever breaks: writing through the real user + // config would leave temp workspaces registered on the developer's Mac. + let resolved = Config::default_path().expect("default_path"); + assert!( + resolved.starts_with(path), + "test config path {} escaped {}", + resolved.display(), + path.display() + ); Self { _lock: lock, previous, @@ -152,20 +161,99 @@ fn monitor_requirement_message_distinguishes_missing_plist() { installed: false, loaded: false, running: false, + needs_reinstall: false, state: "missing_plist".to_string(), message: "LaunchAgent plist is missing".to_string(), }; assert_eq!( - monitor_requirement_message(Some(&agent), None), + monitor_requirement_message(Some(&agent), None, "3.2.0"), "LaunchAgent plist missing" ); assert_eq!( - monitor_requirement_suggestion(Some(&agent), None), + monitor_requirement_suggestion(Some(&agent), None, "3.2.0"), Some("Install the Git-Same monitor LaunchAgent".to_string()) ); } +fn running_agent() -> MonitorLaunchAgentStatusDto { + MonitorLaunchAgentStatusDto { + label: MONITOR_LAUNCH_AGENT_LABEL.to_string(), + plist_path: "/tmp/agent.plist".to_string(), + binary_path: Some("/usr/local/bin/git-same".to_string()), + installed: true, + loaded: true, + running: true, + needs_reinstall: false, + state: "running".to_string(), + message: "Monitor running".to_string(), + } +} + +fn snapshot_with_monitor_version(version: Option<&str>) -> StatusSnapshot { + let mut status = FinderStatus::new(4242, "2026-07-07T00:00:00Z".to_string()); + status.monitor_version = version.map(str::to_string); + StatusSnapshot { + status_path: "/tmp/status.json".to_string(), + updated_at: Some("2026-07-07T00:00:00Z".to_string()), + stale: false, + status: Some(status), + } +} + +#[test] +fn monitor_requirement_flags_version_skew() { + let agent = running_agent(); + let snapshot = snapshot_with_monitor_version(Some("3.1.0")); + + assert_eq!( + monitor_requirement_message(Some(&agent), Some(&snapshot), "3.2.0"), + "Monitor is running a different build (3.1.0) than the app (3.2.0)" + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), Some(&snapshot), "3.2.0"), + Some("Restart the monitor so it runs the same build as the app".to_string()) + ); +} + +#[test] +fn monitor_requirement_fails_pass_on_version_skew() { + let agent = running_agent(); + + // A running monitor on a mismatched build must not pass, so the row's + // state agrees with its "different build" message and restart suggestion. + let skewed = snapshot_with_monitor_version(Some("3.1.0")); + assert!(!monitor_requirement_passed( + Some(&agent), + Some(&skewed), + "3.2.0" + )); + + // Matching builds still pass. + let matched = snapshot_with_monitor_version(Some("3.2.0")); + assert!(monitor_requirement_passed( + Some(&agent), + Some(&matched), + "3.2.0" + )); +} + +#[test] +fn monitor_requirement_ignores_matching_version() { + let agent = running_agent(); + let snapshot = snapshot_with_monitor_version(Some("3.2.0")); + + // Matching versions surface the healthy updated_at message and no skew hint. + assert_eq!( + monitor_requirement_message(Some(&agent), Some(&snapshot), "3.2.0"), + "2026-07-07T00:00:00Z" + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), Some(&snapshot), "3.2.0"), + None + ); +} + #[test] fn read_status_snapshot_returns_none_when_status_file_is_missing() { let temp = TestDir::new("missing-status"); @@ -207,6 +295,62 @@ fn read_status_snapshot_returns_last_known_status_when_monitor_pid_is_stale() { assert!(status.repos.is_empty()); } +#[cfg(unix)] +#[test] +fn read_status_snapshot_removes_a_status_symlink_and_reports_absent() { + use std::os::unix::fs::symlink; + + let temp = TestDir::new("status-symlink"); + let ipc = IpcConfig { + dir: temp.path().join("ipc"), + }; + ipc.ensure_dir().unwrap(); + + // Simulate the pre-upgrade layout: status.json is a symlink into another + // location (the app-group container). Following it would re-trigger the + // cross-app TCC prompt. + let external_target = temp.path().join("container-status.json"); + let mut external = FinderStatus::new(4242, chrono::Utc::now().to_rfc3339()); + external.repos = Vec::new(); + StatusFileWriter::new(external_target.clone()) + .write(&external) + .unwrap(); + let status_path = ipc.status_file_path(); + symlink(&external_target, &status_path).unwrap(); + assert!(std::fs::symlink_metadata(&status_path) + .unwrap() + .file_type() + .is_symlink()); + + let snapshot = read_status_snapshot_with(&ipc).unwrap(); + + // The guard unlinks the symlink and reports status absent rather than + // dereferencing it into the container. + assert!(snapshot.status.is_none()); + assert!(snapshot.stale); + assert!( + std::fs::symlink_metadata(&status_path).is_err(), + "status.json symlink must be removed" + ); +} + +#[test] +fn 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"); @@ -456,3 +600,175 @@ fn parse_pluginkit_output_ignores_other_extensions() { } ); } + +#[test] +fn monitor_requirement_flags_agent_that_runs_outside_the_app_identity() { + let agent = MonitorLaunchAgentStatusDto { + needs_reinstall: true, + ..running_agent() + }; + let snapshot = snapshot_with_monitor_version(Some("3.2.0")); + + // A running monitor under the helper's path-based TCC identity must fail + // the row and steer the user to a reinstall, not a plain restart. + assert!(!monitor_requirement_passed( + Some(&agent), + Some(&snapshot), + "3.2.0" + )); + assert!( + monitor_requirement_message(Some(&agent), Some(&snapshot), "3.2.0") + .contains("needs reinstall") + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), Some(&snapshot), "3.2.0"), + Some("Reinstall the monitor LaunchAgent so it runs under the app identity".to_string()) + ); +} + +#[test] +fn monitor_launch_agent_program_reads_first_program_argument() { + let rendered = MONITOR_PLIST_TEMPLATE.replace( + "__GIT_SAME_MONITOR_BINARY__", + "/Applications/Git-Same.app/Contents/MacOS/git-same-app", + ); + + assert_eq!( + monitor_launch_agent_program(&rendered).as_deref(), + Some("/Applications/Git-Same.app/Contents/MacOS/git-same-app") + ); +} + +#[test] +fn monitor_launch_agent_program_unescapes_xml_and_rejects_empty() { + let rendered = MONITOR_PLIST_TEMPLATE.replace( + "__GIT_SAME_MONITOR_BINARY__", + &escape_xml("/Users/m/Tom & Jerry's/Git-Same.app/Contents/MacOS/git-same-app"), + ); + assert_eq!( + monitor_launch_agent_program(&rendered).as_deref(), + Some("/Users/m/Tom & Jerry's/Git-Same.app/Contents/MacOS/git-same-app") + ); + + let empty = MONITOR_PLIST_TEMPLATE.replace("__GIT_SAME_MONITOR_BINARY__", ""); + assert_eq!(monitor_launch_agent_program(&empty), None); + assert_eq!(monitor_launch_agent_program(""), None); +} + +#[test] +fn bundle_main_executable_detection_requires_app_contents_macos_layout() { + assert!(is_bundle_main_executable(Path::new( + "/Applications/Git-Same.app/Contents/MacOS/git-same-app" + ))); + // The CLI helper lives in Contents/Helpers and is a separate TCC identity. + assert!(!is_bundle_main_executable(Path::new( + "/Applications/Git-Same.app/Contents/Helpers/git-same" + ))); + assert!(!is_bundle_main_executable(Path::new( + "/Users/m/repo/target/debug/git-same-app" + ))); + assert!(!is_bundle_main_executable(Path::new( + "/Users/m/Git-Same.bundle/Contents/MacOS/git-same-app" + ))); +} + +#[test] +fn extension_election_maps_to_pluginkit_verbs() { + assert_eq!(ExtensionElection::Use.pluginkit_arg(), "use"); + assert_eq!(ExtensionElection::Ignore.pluginkit_arg(), "ignore"); +} + +fn snapshot_with_fda(monitor: Option, stale: bool) -> StatusSnapshot { + let mut status = FinderStatus::new(4242, "2026-07-07T00:00:00Z".to_string()); + status.full_disk_access = monitor; + StatusSnapshot { + status_path: "/tmp/status.json".to_string(), + updated_at: Some("2026-07-07T00:00:00Z".to_string()), + stale, + status: Some(status), + } +} + +#[test] +fn fda_gate_prefers_a_fresh_monitor_answer() { + // The monitor holds the grant even though this process does not (for + // example a dev build): badges can render, so the gate passes. + assert!(fda_gate_passes(FullDiskAccess::Denied, Some(true), true)); + // The monitor lacks the grant even though this process has it (grant + // landed after the monitor started): badges would stay blank. + assert!(!fda_gate_passes(FullDiskAccess::Granted, Some(false), true)); +} + +#[test] +fn fda_gate_falls_back_to_the_host_probe_without_a_fresh_monitor() { + assert!(fda_gate_passes(FullDiskAccess::Granted, None, true)); + assert!(fda_gate_passes(FullDiskAccess::Granted, Some(false), false)); + assert!(!fda_gate_passes(FullDiskAccess::Denied, None, false)); + // Unknown never passes: the gate must not enable badges on a guess. + assert!(!fda_gate_passes(FullDiskAccess::Unknown, None, true)); + assert!(!fda_gate_passes(FullDiskAccess::NotApplicable, None, false)); +} + +#[test] +fn full_disk_access_dto_reports_both_identities() { + let stale_snapshot = snapshot_with_fda(Some(false), true); + + let dto = full_disk_access_dto(FullDiskAccess::Granted, Some(&stale_snapshot)); + + assert_eq!(dto.host, "granted"); + assert_eq!(dto.monitor, Some(false)); + assert!(!dto.monitor_fresh); + // Stale monitor: the host probe decides. + assert!(dto.granted); + + let fresh_snapshot = snapshot_with_fda(Some(false), false); + let dto = full_disk_access_dto(FullDiskAccess::Granted, Some(&fresh_snapshot)); + assert!(dto.monitor_fresh); + // Fresh monitor without the grant: its answer wins. + assert!(!dto.granted); + + let dto = full_disk_access_dto(FullDiskAccess::Denied, None); + assert_eq!(dto.host, "denied"); + assert_eq!(dto.monitor, None); + assert!(!dto.monitor_fresh); + assert!(!dto.granted); +} + +#[test] +fn full_disk_access_message_explains_each_state() { + let granted = full_disk_access_dto(FullDiskAccess::Granted, None); + assert_eq!(full_disk_access_message(&granted), "granted to Git-Same"); + + let stale_monitor = full_disk_access_dto( + FullDiskAccess::Granted, + Some(&snapshot_with_fda(Some(false), true)), + ); + assert!( + stale_monitor.granted, + "stale monitor must not block the host grant" + ); + + let fresh_lagging_monitor = full_disk_access_dto( + FullDiskAccess::Granted, + Some(&snapshot_with_fda(Some(false), false)), + ); + assert!(full_disk_access_message(&fresh_lagging_monitor).contains("restart the monitor")); + + let denied = full_disk_access_dto(FullDiskAccess::Denied, None); + assert_eq!( + full_disk_access_message(&denied), + "not granted (required for Finder badges)" + ); + + let unknown = full_disk_access_dto(FullDiskAccess::Unknown, None); + assert_eq!( + full_disk_access_message(&unknown), + "could not be determined" + ); + + let not_applicable = full_disk_access_dto(FullDiskAccess::NotApplicable, None); + assert_eq!( + full_disk_access_message(¬_applicable), + "not applicable on this platform" + ); +} diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index 211f05e..c7432c2 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![ @@ -22,10 +33,56 @@ fn main() { commands::read_status, commands::start_sync, commands::extension_status, + commands::enable_finder_extension, + commands::disable_finder_extension, + commands::full_disk_access_status, commands::open_url, ]) .setup(|app| { - 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())); + + // A leftover symlink at the host status.json path means an old + // monitor build is still running (only pre-upgrade monitors symlink + // it into the container; the current monitor writes a real mirror + // file). Best-effort restart the installed monitor so the upgraded + // build takes over and starts mirroring, instead of the app showing + // stale status until the user restarts it by hand. symlink_metadata + // does not follow the link, so this never reaches into the app-group + // container (no "access data from other apps" TCC prompt). Run on a + // background thread so the synchronous launchctl calls do not block + // app startup. + let host_status_is_symlink = host_ipc + .status_file_path() + .symlink_metadata() + .map(|meta| meta.file_type().is_symlink()) + .unwrap_or(false); + // A LaunchAgent that still execs the CLI helper (pre-3.2 layout) + // runs the monitor under a separate path-based TCC identity, so a + // Full Disk Access grant for Git-Same never reaches it. Re-render + // the plist against this app executable and restart. Only a + // bundled build ever reports this; dev builds leave the user's + // agent alone. + let agent_needs_reinstall = commands::monitor_launch_agent_needs_reinstall(); + if host_status_is_symlink || agent_needs_reinstall { + std::thread::spawn(move || { + let result = if agent_needs_reinstall { + commands::reinstall_monitor_if_installed() + } else { + commands::restart_monitor_if_installed() + }; + if let Err(error) = result { + eprintln!("failed to restart monitor after upgrade: {error}"); + } + }); + } + + if let Err(error) = status_stream::spawn_watcher(app.handle().clone(), host_ipc) { eprintln!("failed to start status watcher: {error}"); } Ok(()) diff --git a/crates/git-same-app/src/monitor_mode.rs b/crates/git-same-app/src/monitor_mode.rs new file mode 100644 index 0000000..82844ac --- /dev/null +++ b/crates/git-same-app/src/monitor_mode.rs @@ -0,0 +1,78 @@ +//! Headless monitor mode for the app binary. +//! +//! The monitor LaunchAgent runs `Git-Same.app/Contents/MacOS/git-same-app +//! monitor --foreground` instead of the CLI helper. macOS TCC attributes a +//! launchd-spawned process to its bundle only when the executable is the +//! bundle's `CFBundleExecutable`, so running the loop here is what lets one +//! Full Disk Access grant for "Git-Same" cover the monitor too. Running the +//! loop through `Contents/Helpers/git-same` gives the monitor a separate, +//! path-based TCC identity that the grant never reaches. +//! +//! Nothing Tauri or AppKit is touched on this path: no window, no Dock icon. +//! The loop itself lives in `git_same_core::monitor`; this file is the same +//! thin shim the CLI `monitor` subcommand uses. + +use git_same_core::config::Config; +use git_same_core::errors::{AppError, Result}; +use git_same_core::ipc::IpcConfig; +use git_same_core::monitor; +use git_same_core::output::{Output, Verbosity}; +use std::ffi::OsStr; + +/// Whether argv selects monitor mode: `git-same-app monitor [--foreground]`. +/// Only the first argument is inspected; trailing legacy flags are ignored. +pub(crate) fn is_monitor_invocation(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter() + .nth(1) + .is_some_and(|arg| arg.as_ref() == "monitor") +} + +/// Run the monitor loop until SIGTERM or SIGINT. Returns the process exit +/// code: launchd's `KeepAlive` restarts the agent on a non-zero exit. +pub(crate) fn run() -> i32 { + init_logging(); + match run_inner() { + Ok(()) => 0, + Err(error) => { + eprintln!("git-same-app monitor: {error}"); + 1 + } + } +} + +fn run_inner() -> Result<()> { + let config = Config::load()?; + let ipc_config = IpcConfig::default_path()?; + ipc_config.ensure_dir()?; + let output = Output::new(Verbosity::Quiet, false); + let opts = monitor::Options::from_config(&config, ipc_config, None); + let runtime = tokio::runtime::Runtime::new() + .map_err(|error| AppError::config(format!("tokio runtime init failed: {error}")))?; + runtime.block_on(monitor::run( + &config, + &output, + opts, + monitor::default_shutdown_signal(), + )) +} + +/// Same `GISA_LOG` contract as the CLI (`crates/git-same-cli/src/main.rs`): +/// the env filter selects the level, default `warn`, written to stderr so +/// launchd's `StandardErrorPath` captures it. +fn init_logging() { + use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + + let filter = EnvFilter::try_from_env("GISA_LOG").unwrap_or_else(|_| EnvFilter::new("warn")); + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().with_writer(std::io::stderr)) + .init(); +} + +#[cfg(test)] +#[path = "monitor_mode_tests.rs"] +mod tests; diff --git a/crates/git-same-app/src/monitor_mode_tests.rs b/crates/git-same-app/src/monitor_mode_tests.rs new file mode 100644 index 0000000..feac331 --- /dev/null +++ b/crates/git-same-app/src/monitor_mode_tests.rs @@ -0,0 +1,23 @@ +use super::*; + +#[test] +fn monitor_invocation_matches_first_argument() { + assert!(is_monitor_invocation(["git-same-app", "monitor"])); + assert!(is_monitor_invocation([ + "/Applications/Git-Same.app/Contents/MacOS/git-same-app", + "monitor", + "--foreground", + ])); +} + +#[test] +fn monitor_invocation_ignores_other_arguments() { + assert!(!is_monitor_invocation(["git-same-app"])); + assert!(!is_monitor_invocation([ + "git-same-app", + "--foreground", + "monitor" + ])); + assert!(!is_monitor_invocation(["git-same-app", "sync"])); + assert!(!is_monitor_invocation(Vec::<&str>::new())); +} diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs index 8006a0b..7730a5d 100644 --- a/crates/git-same-app/src/status_stream.rs +++ b/crates/git-same-app/src/status_stream.rs @@ -1,10 +1,15 @@ -use crate::commands::read_status_snapshot; +use crate::commands::read_status_snapshot_with; use git_same_core::ipc::IpcConfig; -use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use std::ffi::OsStr; use tauri::{AppHandle, Emitter}; -pub fn spawn_watcher(app: AppHandle) -> anyhow::Result<()> { - let ipc = IpcConfig::default_path()?; +/// Watches the host-facing IPC directory for `status.json` changes and emits a +/// `status-updated` event with a fresh snapshot. `ipc` is the resolved +/// host-facing config (`~/.config/git-same/finder/`, where the monitor mirrors +/// a real `status.json`), so neither the watch nor the reads cross into the +/// app-group container. +pub fn spawn_watcher(app: AppHandle, ipc: IpcConfig) -> anyhow::Result<()> { ipc.ensure_dir()?; let watch_path = ipc.dir.clone(); @@ -29,14 +34,48 @@ pub fn spawn_watcher(app: AppHandle) -> anyhow::Result<()> { } for event in rx { - if event.is_err() { + let event = match event { + Ok(event) => event, + Err(error) => { + eprintln!("status watcher event error: {error}"); + continue; + } + }; + if !event_touches_status_file(&event) { continue; } - if let Ok(snapshot) = read_status_snapshot() { - 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}"); + } } } })?; Ok(()) } + +/// Whether a watcher event concerns the final `status.json` rather than, for +/// example, the sibling `status.json.tmp` the atomic write creates first. +/// Without this filter every monitor write (tmp create + rename) triggers +/// several full snapshot reads and duplicate `status-updated` emits. +/// +/// Rescan events are always kept: after a kernel-side queue drop, notify's +/// macOS FSEvents backend marks the event for rescan but still attaches the +/// watched-directory path. Pathless events are also kept defensively because +/// other backends may use them to signal that an update was missed. +fn event_touches_status_file(event: &Event) -> bool { + event.need_rescan() + || event.paths.is_empty() + || event + .paths + .iter() + .any(|path| path.file_name() == Some(OsStr::new("status.json"))) +} + +#[cfg(test)] +#[path = "status_stream_tests.rs"] +mod tests; diff --git a/crates/git-same-app/src/status_stream_tests.rs b/crates/git-same-app/src/status_stream_tests.rs new file mode 100644 index 0000000..db4f480 --- /dev/null +++ b/crates/git-same-app/src/status_stream_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use notify::{event::Flag, EventKind}; +use std::path::PathBuf; + +fn event_with_paths(paths: Vec) -> Event { + Event { + paths, + ..Default::default() + } +} + +#[test] +fn keeps_events_for_the_final_status_file() { + let event = event_with_paths(vec![PathBuf::from("/ipc/status.json")]); + assert!(event_touches_status_file(&event)); +} + +#[test] +fn skips_events_for_the_temp_file() { + let event = event_with_paths(vec![PathBuf::from("/ipc/status.json.tmp")]); + assert!(!event_touches_status_file(&event)); +} + +#[test] +fn keeps_events_when_any_path_is_the_status_file() { + let event = event_with_paths(vec![ + PathBuf::from("/ipc/status.json.tmp"), + PathBuf::from("/ipc/status.json"), + ]); + assert!(event_touches_status_file(&event)); +} + +#[test] +fn keeps_path_bearing_rescan_events() { + let event = Event::new(EventKind::Other) + .set_flag(Flag::Rescan) + .add_path(PathBuf::from("/ipc")); + assert!(event_touches_status_file(&event)); +} + +#[test] +fn keeps_pathless_rescan_events() { + let event = Event::new(EventKind::Other).set_flag(Flag::Rescan); + assert!(event_touches_status_file(&event)); +} + +#[test] +fn keeps_pathless_events_without_rescan_flag() { + let event = event_with_paths(Vec::new()); + assert!(event_touches_status_file(&event)); +} diff --git a/crates/git-same-app/tauri.conf.json b/crates/git-same-app/tauri.conf.json index cdb336f..b8d752c 100644 --- a/crates/git-same-app/tauri.conf.json +++ b/crates/git-same-app/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Git-Same", - "version": "3.1.0", + "version": "3.2.0", "identifier": "com.zaai.git-same", "build": { "beforeDevCommand": "corepack pnpm dev", diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json index d37e928..f84befa 100644 --- a/crates/git-same-app/ui/package.json +++ b/crates/git-same-app/ui/package.json @@ -1,7 +1,7 @@ { "name": "git-same-app-ui", "private": true, - "version": "3.1.0", + "version": "3.2.0", "type": "module", "packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38", "scripts": { @@ -10,7 +10,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@lucide/svelte": "^1.21.0", + "@lucide/svelte": "^1.22.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.7.1", "svelte": "^5.56.4", @@ -18,10 +18,10 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@tauri-apps/cli": "^2.11.3", + "@tauri-apps/cli": "^2.11.4", "svelte-check": "^4.7.1", "typescript": "^6.0.3", - "vite": "^8.1.0" + "vite": "^8.1.2" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/crates/git-same-app/ui/pnpm-lock.yaml b/crates/git-same-app/ui/pnpm-lock.yaml index 7b3fa1b..424ab31 100644 --- a/crates/git-same-app/ui/pnpm-lock.yaml +++ b/crates/git-same-app/ui/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@lucide/svelte': - specifier: ^1.21.0 - version: 1.21.0(svelte@5.56.4) + specifier: ^1.22.0 + version: 1.22.0(svelte@5.56.4) '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 @@ -26,10 +26,10 @@ importers: devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^7.1.2 - version: 7.1.2(svelte@5.56.4)(vite@8.1.0) + version: 7.1.2(svelte@5.56.4)(vite@8.1.2) '@tauri-apps/cli': - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.4 + version: 2.11.4 svelte-check: specifier: ^4.7.1 version: 4.7.1(picomatch@4.0.4)(svelte@5.56.4)(typescript@6.0.3) @@ -37,8 +37,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.1.0 - version: 8.1.0 + specifier: ^8.1.2 + version: 8.1.2 packages: @@ -67,8 +67,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lucide/svelte@1.21.0': - resolution: {integrity: sha512-MEv//A7Jv3kHukZowv/DWp1MAtUzJKYwtJsmnQ7X98lCgtac3z3NbaToDl3Q6jO3gS9sougFpcD+t+YuxOkRMw==} + '@lucide/svelte@1.22.0': + resolution: {integrity: sha512-eaNC3GGu9ma7mviB9vPL6OnawXqxdvRnoAQSq5l15mBlsuwD7kozZ7pzPXSlT6OwSl7hz4qTk+ZU3OEewwi5gQ==} peerDependencies: svelte: ^5 @@ -198,79 +198,79 @@ packages: '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - '@tauri-apps/cli-darwin-arm64@2.11.3': - resolution: {integrity: sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==} + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.11.3': - resolution: {integrity: sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==} + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': - resolution: {integrity: sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.11.3': - resolution: {integrity: sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==} + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.11.3': - resolution: {integrity: sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==} + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': - resolution: {integrity: sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==} + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.11.3': - resolution: {integrity: sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==} + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.11.3': - resolution: {integrity: sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==} + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.11.3': - resolution: {integrity: sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==} + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.11.3': - resolution: {integrity: sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==} + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.11.3': - resolution: {integrity: sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==} + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.11.3': - resolution: {integrity: sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==} + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} engines: {node: '>= 10'} hasBin: true @@ -321,8 +321,8 @@ packages: esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - esrap@2.2.12: - resolution: {integrity: sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==} + esrap@2.2.13: + resolution: {integrity: sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==} peerDependencies: '@typescript-eslint/types': ^8.2.0 peerDependenciesMeta: @@ -430,8 +430,8 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -446,8 +446,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} readdirp@4.1.2: @@ -500,8 +500,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - vite@8.1.0: - resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + vite@8.1.2: + resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -591,7 +591,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lucide/svelte@1.21.0(svelte@5.56.4)': + '@lucide/svelte@1.22.0(svelte@5.56.4)': dependencies: svelte: 5.56.4 @@ -661,63 +661,63 @@ snapshots: '@sveltejs/load-config@0.2.0': {} - '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.1.0)': + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.1.2)': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.3 svelte: 5.56.4 - vite: 8.1.0 - vitefu: 1.1.3(vite@8.1.0) + vite: 8.1.2 + vitefu: 1.1.3(vite@8.1.2) '@tauri-apps/api@2.11.1': {} - '@tauri-apps/cli-darwin-arm64@2.11.3': + '@tauri-apps/cli-darwin-arm64@2.11.4': optional: true - '@tauri-apps/cli-darwin-x64@2.11.3': + '@tauri-apps/cli-darwin-x64@2.11.4': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.11.3': + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.11.3': + '@tauri-apps/cli-linux-arm64-musl@2.11.4': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.11.3': + '@tauri-apps/cli-linux-x64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-musl@2.11.3': + '@tauri-apps/cli-linux-x64-musl@2.11.4': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.11.3': + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.11.3': + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.11.3': + '@tauri-apps/cli-win32-x64-msvc@2.11.4': optional: true - '@tauri-apps/cli@2.11.3': + '@tauri-apps/cli@2.11.4': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.11.3 - '@tauri-apps/cli-darwin-x64': 2.11.3 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.3 - '@tauri-apps/cli-linux-arm64-gnu': 2.11.3 - '@tauri-apps/cli-linux-arm64-musl': 2.11.3 - '@tauri-apps/cli-linux-riscv64-gnu': 2.11.3 - '@tauri-apps/cli-linux-x64-gnu': 2.11.3 - '@tauri-apps/cli-linux-x64-musl': 2.11.3 - '@tauri-apps/cli-win32-arm64-msvc': 2.11.3 - '@tauri-apps/cli-win32-ia32-msvc': 2.11.3 - '@tauri-apps/cli-win32-x64-msvc': 2.11.3 + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 '@tauri-apps/plugin-dialog@2.7.1': dependencies: @@ -752,7 +752,7 @@ snapshots: esm-env@1.2.2: {} - esrap@2.2.12: + esrap@2.2.13: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -824,7 +824,7 @@ snapshots: mri@1.2.0: {} - nanoid@3.3.15: {} + nanoid@3.3.18: {} obug@2.1.3: {} @@ -832,9 +832,9 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.15: + postcss@8.5.28: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -900,7 +900,7 @@ snapshots: clsx: 2.1.1 devalue: 5.8.1 esm-env: 1.2.2 - esrap: 2.2.12 + esrap: 2.2.13 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -918,18 +918,18 @@ snapshots: typescript@6.0.3: {} - vite@8.1.0: + vite@8.1.2: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.28 rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 - vitefu@1.1.3(vite@8.1.0): + vitefu@1.1.3(vite@8.1.2): optionalDependencies: - vite: 8.1.0 + vite: 8.1.2 zimmerframe@1.1.4: {} diff --git a/crates/git-same-app/ui/src/App.svelte b/crates/git-same-app/ui/src/App.svelte index 98b68f8..b31e550 100644 --- a/crates/git-same-app/ui/src/App.svelte +++ b/crates/git-same-app/ui/src/App.svelte @@ -4,11 +4,27 @@ import Sidebar from './lib/Sidebar.svelte'; import StatusBanner from './lib/StatusBanner.svelte'; import TitleBar from './lib/TitleBar.svelte'; - import { errorMessage, loading, refresh, subscribePush } from './stores/status'; + import { + errorMessage, + loading, + refresh, + refreshPermissions, + subscribePush, + } from './stores/status'; import { routes } from './routes/router'; let unsubscribe: (() => void) | undefined; + // The user grants Full Disk Access and enables the extension in System + // Settings, so re-probe whenever the window comes back to the front. + function handleFocus() { + void refreshPermissions(); + } + + function handleVisibility() { + if (document.visibilityState === 'visible') void refreshPermissions(); + } + onMount(() => { void (async () => { try { @@ -20,10 +36,14 @@ loading.set(false); } })(); + window.addEventListener('focus', handleFocus); + document.addEventListener('visibilitychange', handleVisibility); }); onDestroy(() => { unsubscribe?.(); + window.removeEventListener('focus', handleFocus); + document.removeEventListener('visibilitychange', handleVisibility); }); diff --git a/crates/git-same-app/ui/src/lib/Banner.svelte b/crates/git-same-app/ui/src/lib/Banner.svelte deleted file mode 100644 index 80af50d..0000000 --- a/crates/git-same-app/ui/src/lib/Banner.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - -{#if showError} - -{:else if showProgress} - -{:else if showStale} - -{:else if showAllowExt} - -{:else if showFda} - -{/if} - - diff --git a/crates/git-same-app/ui/src/lib/StatusBanner.svelte b/crates/git-same-app/ui/src/lib/StatusBanner.svelte index 481534a..3b1f58a 100644 --- a/crates/git-same-app/ui/src/lib/StatusBanner.svelte +++ b/crates/git-same-app/ui/src/lib/StatusBanner.svelte @@ -1,8 +1,10 @@ @@ -76,9 +127,14 @@ {row.detail} {#if row.action && !row.passed} - {/if} @@ -253,6 +309,11 @@ font-weight: 700; } + button:disabled { + cursor: not-allowed; + opacity: 0.55; + } + .two-column { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); diff --git a/crates/git-same-app/ui/src/routes/Requirements.svelte b/crates/git-same-app/ui/src/routes/Requirements.svelte index 3473a8c..9127656 100644 --- a/crates/git-same-app/ui/src/routes/Requirements.svelte +++ b/crates/git-same-app/ui/src/routes/Requirements.svelte @@ -10,11 +10,7 @@ restartMonitor, } from '../stores/status'; import { openUrl } from '../lib/tauri'; - - const EXTENSIONS_URL = - 'x-apple.systempreferences:com.apple.LoginItems-Settings.extension'; - const FDA_URL = - 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'; + import { EXTENSIONS_URL, FDA_URL } from '../lib/systemSettings'; onMount(() => { void loadRequirements(); @@ -28,7 +24,12 @@ if (name === 'Finder extension') return 'extensions'; if (name === 'Full Disk Access') return 'fda'; if (name === 'Monitor') { - return message.includes('missing') ? 'monitor-install' : 'monitor-restart'; + // "needs reinstall" re-renders the plist against the app executable so + // the monitor shares the app's TCC identity; a plain restart would keep + // the old program. + return message.includes('missing') || message.includes('needs reinstall') + ? 'monitor-install' + : 'monitor-restart'; } return null; } diff --git a/crates/git-same-app/ui/src/stores/status.ts b/crates/git-same-app/ui/src/stores/status.ts index 9b3b686..eddbfef 100644 --- a/crates/git-same-app/ui/src/stores/status.ts +++ b/crates/git-same-app/ui/src/stores/status.ts @@ -2,6 +2,7 @@ import { derived, get, writable } from 'svelte/store'; import { checkRequirements, deleteWorkspace, + enableFinderExtension, ensureConfig, installMonitorLaunchAgent, listWorkspaces, @@ -9,6 +10,7 @@ import { onSyncProgress, readAppConfig, readExtensionStatus, + readFullDiskAccess, readStatus, readWorkspaceStructure, restartMonitorLaunchAgent, @@ -20,6 +22,7 @@ import type { AppConfigDto, AppConfigInput, ExtensionStatus, + FullDiskAccessDto, ProgressEvent, RequirementCheckDto, StatusSnapshot, @@ -36,6 +39,7 @@ export const NEW_WORKSPACE_ID = '__new_workspace__'; export const snapshot = writable(null); export const workspaces = writable([]); export const extensionStatus = writable(null); +export const fullDiskAccess = writable(null); export const appConfig = writable(null); export const requirements = writable([]); export const workspaceStructure = writable(null); @@ -61,7 +65,7 @@ export const currentWorkspace = derived( export async function refresh(): Promise { errorMessage.set(''); - const [workspaceList, status, ext, config] = await Promise.all([ + const [workspaceList, status, ext, fda, config] = await Promise.all([ listWorkspaces().catch((err) => { errorMessage.set(String(err)); return [] as WorkspaceSummary[]; @@ -71,13 +75,70 @@ export async function refresh(): Promise { return null; }), readExtensionStatus().catch(() => null), + readFullDiskAccess().catch(() => null), readAppConfig().catch(() => null), ]); workspaces.set(workspaceList); snapshot.set(status); extensionStatus.set(ext); + fullDiskAccess.set(fda); appConfig.set(config); reconcileSelectedWorkspace(workspaceList); + await kickMonitorIfLagging(fda); +} + +/** + * Re-read only the permission-shaped state (Full Disk Access, extension + * election). Called when the window regains focus so the badge checklist + * reflects what the user just changed in System Settings. + */ +export async function refreshPermissions(): Promise { + const [fda, ext] = await Promise.all([ + readFullDiskAccess().catch(() => null), + readExtensionStatus().catch(() => null), + ]); + fullDiskAccess.set(fda); + extensionStatus.set(ext); + await kickMonitorIfLagging(fda); +} + +// One restart per lag episode: cleared when the monitor reports the grant, +// so a monitor that can never hold it (a helper-identity agent) is not +// restarted in a loop. +let monitorKickPending = false; + +/** + * The app holds Full Disk Access but the running monitor was started before + * the grant landed (macOS applies TCC grants on process start). Restart it + * once so its scans and watchers pick up the grant. + */ +async function kickMonitorIfLagging(fda: FullDiskAccessDto | null): Promise { + if (!fda) return; + if (fda.monitor === true) { + monitorKickPending = false; + return; + } + if (fda.host !== 'granted' || fda.monitor !== false || !fda.monitor_fresh) return; + if (monitorKickPending) return; + monitorKickPending = true; + try { + await restartMonitorLaunchAgent(); + successMessage.set('Full Disk Access granted, monitor restarted'); + } catch (err) { + errorMessage.set(String(err)); + } +} + +/** Enable Finder badges; the backend refuses until Full Disk Access is granted. */ +export async function enableExtension(): Promise { + errorMessage.set(''); + try { + extensionStatus.set(await enableFinderExtension()); + successMessage.set('Finder badges enabled'); + } catch (err) { + errorMessage.set(String(err)); + } + await refreshPermissions(); } export async function loadAppConfig(): Promise { diff --git a/crates/git-same-cli/Cargo.toml b/crates/git-same-cli/Cargo.toml index 75ac847..abc3db9 100644 --- a/crates/git-same-cli/Cargo.toml +++ b/crates/git-same-cli/Cargo.toml @@ -39,7 +39,7 @@ tui = ["dep:ratatui", "dep:crossterm"] release-tools = ["dep:clap_complete", "dep:clap_mangen"] [dependencies] -git-same-core = { path = "../git-same-core", version = "=3.1.0" } +git-same-core = { path = "../git-same-core", version = "=3.2.0" } clap = { workspace = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/crates/git-same-cli/src/commands/monitor.rs b/crates/git-same-cli/src/commands/monitor.rs index 4fcb359..e0a329c 100644 --- a/crates/git-same-cli/src/commands/monitor.rs +++ b/crates/git-same-cli/src/commands/monitor.rs @@ -1,8 +1,9 @@ //! `gisa monitor`: start, stop, or query the long-running monitor process. //! //! The actual run-loop lives in `git_same_core::monitor`. This file is the -//! CLI surface only: parse args, handle `--status` / `--stop` locally, build -//! the shutdown future from `ctrl_c` + SIGTERM, and call into core. +//! CLI surface only: parse args, handle `--status` / `--stop` locally, and +//! call into core with the shared options and shutdown signal (the Tauri +//! app's headless monitor mode uses the same two helpers). //! //! `--status` and `--stop` stay in the CLI because they don't need the loop: //! they just read the status file or send a kill signal to the recorded PID. @@ -13,7 +14,6 @@ use git_same_core::errors::Result; use git_same_core::ipc::{IpcConfig, StatusFileWriter}; use git_same_core::monitor; use git_same_core::output::Output; -use std::time::Duration; use tracing::info; /// Run the `monitor` subcommand. @@ -30,43 +30,8 @@ pub async fn run(args: &MonitorArgs, config: &Config, output: &Output) -> Result info!("Starting git-same monitor"); - let interval_secs = resolve_interval_secs(args.interval, config.monitor.fullscan_interval_secs); - let opts = monitor::Options { - interval: Duration::from_secs(interval_secs), - ipc_config, - }; - - monitor::run(config, output, opts, shutdown_signal()).await -} - -/// Resolve the effective polling interval: an explicit `--interval` flag wins, -/// otherwise fall back to the value from `config.toml`. -fn resolve_interval_secs(cli_flag: Option, config_value: u64) -> u64 { - cli_flag.unwrap_or(config_value) -} - -/// Resolve when the user hits ctrl-c (SIGINT) or `gisa monitor --stop` -/// sends SIGTERM. Used as the shutdown future for the monitor loop. -async fn shutdown_signal() { - #[cfg(unix)] - { - let mut sigterm = - match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { - Ok(s) => s, - Err(_) => { - let _ = tokio::signal::ctrl_c().await; - return; - } - }; - tokio::select! { - _ = tokio::signal::ctrl_c() => {}, - _ = sigterm.recv() => {}, - } - } - #[cfg(not(unix))] - { - let _ = tokio::signal::ctrl_c().await; - } + let opts = monitor::Options::from_config(config, ipc_config, args.interval); + monitor::run(config, output, opts, monitor::default_shutdown_signal()).await } /// Show monitor status. diff --git a/crates/git-same-cli/src/commands/monitor_tests.rs b/crates/git-same-cli/src/commands/monitor_tests.rs index 492682b..e4b6c61 100644 --- a/crates/git-same-cli/src/commands/monitor_tests.rs +++ b/crates/git-same-cli/src/commands/monitor_tests.rs @@ -23,13 +23,3 @@ fn test_is_process_alive_rejects_out_of_range_pid() { assert!(!is_process_alive(u32::MAX)); assert!(!is_process_alive(0)); } - -#[test] -fn cli_flag_overrides_config_interval() { - assert_eq!(resolve_interval_secs(Some(10), 30), 10); -} - -#[test] -fn config_interval_used_when_flag_absent() { - assert_eq!(resolve_interval_secs(None, 90), 90); -} diff --git a/crates/git-same-cli/src/tui/app.rs b/crates/git-same-cli/src/tui/app.rs index fd2bd3d..be15cb4 100644 --- a/crates/git-same-cli/src/tui/app.rs +++ b/crates/git-same-cli/src/tui/app.rs @@ -234,7 +234,7 @@ pub struct App { /// Scroll offset for the workspace detail right pane. pub workspace_detail_scroll: u16, - /// Tick counter for driving animations on the Progress screen. + /// Tick counter for driving Sync and Dashboard animations. pub tick_count: u64, /// Structured sync log entries (enriched data). diff --git a/crates/git-same-cli/src/tui/backend.rs b/crates/git-same-cli/src/tui/backend.rs index e158a7e..2e116d8 100644 --- a/crates/git-same-cli/src/tui/backend.rs +++ b/crates/git-same-cli/src/tui/backend.rs @@ -17,7 +17,7 @@ use git_same_core::workflows::sync_workspace::{ execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, }; -use super::app::{App, Operation}; +use super::app::{App, Operation, OperationState}; use super::event::{AppEvent, BackendMessage}; // -- Progress adapters that send events to the TUI via channels -- @@ -212,6 +212,28 @@ impl SyncProgress for TuiSyncProgress { // -- Spawn functions -- +/// Start a background status refresh when it cannot conflict with another scan or sync. +pub(crate) fn try_start_status_refresh(app: &mut App, tx: &UnboundedSender) -> bool { + let sync_active = matches!( + &app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } | OperationState::Running { + operation: Operation::Sync, + .. + } + ); + + if app.active_workspace.is_none() || app.status_loading || sync_active { + return false; + } + + app.status_loading = true; + spawn_operation(Operation::Status, app, tx.clone()); + true +} + /// Spawn an async task to fetch recent commits for a repo (post-sync deep dive). pub fn spawn_commit_fetch( repo_path: std::path::PathBuf, diff --git a/crates/git-same-cli/src/tui/backend_tests.rs b/crates/git-same-cli/src/tui/backend_tests.rs index 21008c9..d7a36be 100644 --- a/crates/git-same-cli/src/tui/backend_tests.rs +++ b/crates/git-same-cli/src/tui/backend_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::tui::event::{AppEvent, BackendMessage}; -use git_same_core::config::Config; +use git_same_core::config::{Config, WorkspaceConfig}; use git_same_core::git::{FetchResult, PullResult}; use git_same_core::operations::clone::CloneProgress; use git_same_core::operations::sync::SyncProgress; @@ -21,6 +21,104 @@ fn expect_backend_event(event: AppEvent) -> BackendMessage { } } +fn app_with_temp_workspace() -> (tempfile::TempDir, App) { + let temp = tempfile::tempdir().expect("temp workspace"); + let workspace = WorkspaceConfig::new_from_root(temp.path()); + let app = App::new(Config::default(), vec![workspace], false); + (temp, app) +} + +#[tokio::test] +async fn try_start_status_refresh_starts_scan() { + let (_temp, mut app) = app_with_temp_workspace(); + let (tx, mut rx) = unbounded_channel(); + + assert!(try_start_status_refresh(&mut app, &tx)); + assert!(app.status_loading); + assert!(matches!(app.operation_state, OperationState::Idle)); + + let event = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for status results") + .expect("channel closed unexpectedly"); + assert!(matches!( + expect_backend_event(event), + BackendMessage::StatusResults(_) + )); +} + +#[test] +fn try_start_status_refresh_rejects_duplicate_scan() { + let (_temp, mut app) = app_with_temp_workspace(); + app.status_loading = true; + let (tx, mut rx) = unbounded_channel(); + + assert!(!try_start_status_refresh(&mut app, &tx)); + assert!(app.status_loading); + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); +} + +#[test] +fn try_start_status_refresh_rejects_missing_workspace() { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, mut rx) = unbounded_channel(); + + assert!(!try_start_status_refresh(&mut app, &tx)); + assert!(!app.status_loading); + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); +} + +#[test] +fn try_start_status_refresh_rejects_active_sync_states() { + let (_temp, mut app) = app_with_temp_workspace(); + let active_states = [ + OperationState::Discovering { + operation: Operation::Sync, + message: "Discovering repositories".to_string(), + }, + OperationState::Running { + operation: Operation::Sync, + total: 1, + completed: 0, + failed: 0, + skipped: 0, + current_repo: "acme/rocket".to_string(), + with_updates: 0, + cloned: 0, + synced: 0, + to_clone: 0, + to_sync: 1, + total_new_commits: 0, + started_at: std::time::Instant::now(), + active_repos: vec!["acme/rocket".to_string()], + throughput_samples: Vec::new(), + last_sample_completed: 0, + }, + ]; + + for state in active_states { + app.operation_state = state; + app.status_loading = false; + let (tx, mut rx) = unbounded_channel(); + + assert!(!try_start_status_refresh(&mut app, &tx)); + assert!(!app.status_loading); + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } | OperationState::Running { + operation: Operation::Sync, + .. + } + )); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); + } +} + #[test] fn discovery_progress_emits_expected_messages() { let (tx, mut rx) = unbounded_channel(); diff --git a/crates/git-same-cli/src/tui/handler.rs b/crates/git-same-cli/src/tui/handler.rs index 724e2b5..84cd074 100644 --- a/crates/git-same-cli/src/tui/handler.rs +++ b/crates/git-same-cli/src/tui/handler.rs @@ -34,12 +34,12 @@ pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &Unbounded } ); - // Keep sync animation/throughput sampling active even when progress popup is hidden. - if sync_in_progress { + // Keep operation animations active even when their UI is hidden. + if sync_in_progress || app.status_loading { app.tick_count = app.tick_count.wrapping_add(1); // Sample throughput every 10 ticks (1 second at 100ms tick rate) - if app.tick_count.is_multiple_of(10) { + if sync_in_progress && app.tick_count.is_multiple_of(10) { if let OperationState::Running { operation: Operation::Sync, completed, @@ -131,15 +131,11 @@ pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &Unbounded .and_then(|ws| ws.refresh_interval) .unwrap_or(app.config.refresh_interval); if app.screen == Screen::Dashboard - && app.active_workspace.is_some() - && !app.status_loading - && !sync_in_progress && app .last_status_scan .is_none_or(|t| t.elapsed().as_secs() >= refresh_interval) { - app.status_loading = true; - super::backend::spawn_operation(Operation::Status, app, backend_tx.clone()); + super::backend::try_start_status_refresh(app, backend_tx); } } AppEvent::Resize(_, _) => {} // ratatui handles resize @@ -544,9 +540,6 @@ fn handle_backend_message( let _ = manager.save(&app.sync_history); } } - - // Auto-trigger status scan so dashboard is fresh - super::backend::spawn_operation(Operation::Status, app, backend_tx.clone()); } // Default to Updated filter if there were updates, else All @@ -566,6 +559,13 @@ fn handle_backend_message( total_new_commits: tnc, duration_secs: dur, }; + + // Auto-trigger a guarded status scan after leaving the active Sync state. + // Setting status_loading in the shared helper prevents the next dashboard + // tick from launching a duplicate scan. + if op == Operation::Sync { + super::backend::try_start_status_refresh(app, backend_tx); + } } BackendMessage::OperationError(msg) => { app.operation_state = OperationState::Idle; @@ -575,7 +575,10 @@ fn handle_backend_message( app.local_repos = entries; if matches!( app.operation_state, - OperationState::Running { + OperationState::Discovering { + operation: Operation::Status, + .. + } | OperationState::Running { operation: Operation::Status, .. } diff --git a/crates/git-same-cli/src/tui/handler_tests.rs b/crates/git-same-cli/src/tui/handler_tests.rs index 369c983..246304e 100644 --- a/crates/git-same-cli/src/tui/handler_tests.rs +++ b/crates/git-same-cli/src/tui/handler_tests.rs @@ -3,8 +3,30 @@ use crate::setup::state::{OrgEntry, SetupState, SetupStep}; use crate::tui::event::{AppEvent, BackendMessage}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use git_same_core::config::{Config, WorkspaceConfig}; +use git_same_core::types::OpSummary; use tokio::sync::mpsc::unbounded_channel; +fn running_state(operation: Operation) -> OperationState { + OperationState::Running { + operation, + total: 5, + completed: 3, + failed: 0, + skipped: 0, + current_repo: String::new(), + with_updates: 0, + cloned: 0, + synced: 0, + to_clone: 0, + to_sync: 5, + total_new_commits: 0, + started_at: std::time::Instant::now(), + active_repos: Vec::new(), + throughput_samples: Vec::new(), + last_sample_completed: 0, + } +} + #[tokio::test] async fn q_quits_immediately() { let ws = WorkspaceConfig::new_from_root(std::path::Path::new("/tmp/test-ws")); @@ -136,3 +158,179 @@ async fn setup_check_results_preserve_suggestions() { Some("Run: gh auth login") ); } + +#[tokio::test] +async fn status_results_clears_discovering_status_state() { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = OperationState::Discovering { + operation: Operation::Status, + message: "Starting Status...".to_string(), + }; + app.status_loading = true; + + handle_event( + &mut app, + AppEvent::Backend(BackendMessage::StatusResults(Vec::new())), + &tx, + ) + .await; + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(!app.status_loading); + assert!(app.last_status_scan.is_some()); +} + +#[test] +fn status_results_clear_running_status_state() { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = running_state(Operation::Status); + app.status_loading = true; + + handle_backend_message(&mut app, BackendMessage::StatusResults(Vec::new()), &tx); + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(!app.status_loading); + assert!(app.last_status_scan.is_some()); +} + +#[test] +fn status_results_do_not_clear_active_sync_states() { + let states = [ + OperationState::Discovering { + operation: Operation::Sync, + message: "Discovering repositories".to_string(), + }, + running_state(Operation::Sync), + ]; + + for state in states { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, _rx) = unbounded_channel(); + app.operation_state = state; + app.status_loading = true; + + handle_backend_message(&mut app, BackendMessage::StatusResults(Vec::new()), &tx); + + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } | OperationState::Running { + operation: Operation::Sync, + .. + } + )); + assert!(!app.status_loading); + assert!(app.last_status_scan.is_some()); + } +} + +#[tokio::test] +async fn status_refresh_does_not_block_sync() { + let temp = tempfile::tempdir().expect("temp workspace"); + let workspace = WorkspaceConfig::new_from_root(temp.path()); + let mut app = App::new(Config::default(), vec![workspace], false); + let (tx, _rx) = unbounded_channel(); + app.screen = Screen::Dashboard; + app.checks_loading = true; + + handle_event( + &mut app, + AppEvent::Terminal(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), + &tx, + ) + .await; + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(app.status_loading); + + handle_event( + &mut app, + AppEvent::Backend(BackendMessage::StatusResults(Vec::new())), + &tx, + ) + .await; + assert!(!app.status_loading); + + // Avoid provider/network work while still exercising the full key-routing path. + app.active_workspace = None; + handle_event( + &mut app, + AppEvent::Terminal(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)), + &tx, + ) + .await; + + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } + )); + assert!(app.error_message.is_none()); +} + +#[tokio::test] +async fn status_loading_tick_advances_animation_while_operation_is_idle() { + let mut app = App::new(Config::default(), Vec::new(), false); + let (tx, _rx) = unbounded_channel(); + app.screen = Screen::Dashboard; + app.checks_loading = true; + app.status_loading = true; + app.tick_count = 9; + + handle_event(&mut app, AppEvent::Tick, &tx).await; + + assert_eq!(app.tick_count, 10); + assert!(matches!(app.operation_state, OperationState::Idle)); +} + +#[tokio::test] +async fn operation_complete_starts_one_guarded_status_refresh() { + let temp = tempfile::tempdir().expect("temp directory"); + let blocked_parent = temp.path().join("not-a-directory"); + std::fs::write(&blocked_parent, b"block workspace persistence").expect("create blocking file"); + let workspace = WorkspaceConfig::new_from_root(&blocked_parent.join("workspace")); + let mut app = App::new(Config::default(), vec![workspace], false); + let (tx, mut rx) = unbounded_channel(); + app.screen = Screen::Dashboard; + app.checks_loading = true; + app.operation_state = running_state(Operation::Sync); + + handle_backend_message( + &mut app, + BackendMessage::OperationComplete(OpSummary::new()), + &tx, + ); + + assert!(matches!( + app.operation_state, + OperationState::Finished { + operation: Operation::Sync, + .. + } + )); + assert!(app.status_loading); + + let tick_before = app.tick_count; + handle_event(&mut app, AppEvent::Tick, &tx).await; + assert_eq!(app.tick_count, tick_before.wrapping_add(1)); + + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("post-sync status scan should complete promptly") + .expect("backend event"); + assert!(matches!( + event, + AppEvent::Backend(BackendMessage::StatusResults(_)) + )); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()) + .await + .is_err(), + "the following dashboard tick must not launch a duplicate status scan" + ); +} diff --git a/crates/git-same-cli/src/tui/screens/dashboard.rs b/crates/git-same-cli/src/tui/screens/dashboard.rs index 7acf9e7..4ce8f73 100644 --- a/crates/git-same-cli/src/tui/screens/dashboard.rs +++ b/crates/git-same-cli/src/tui/screens/dashboard.rs @@ -31,9 +31,15 @@ pub async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSend show_sync_progress(app); } KeyCode::Char('t') => { - app.last_status_scan = None; // Force immediate refresh - app.status_loading = true; - start_operation(app, Operation::Status, backend_tx); + if crate::tui::backend::try_start_status_refresh(app, backend_tx) { + app.last_status_scan = None; + + // An in-flight requirements check already refreshes these results. Otherwise, + // clear the completed results so the next tick starts a fresh check. + if !app.checks_loading { + app.check_results.clear(); + } + } } // Tab shortcuts KeyCode::Char('o') => { @@ -112,7 +118,13 @@ pub async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSend } } -fn start_operation(app: &mut App, operation: Operation, backend_tx: &UnboundedSender) { +pub(crate) fn start_sync_operation(app: &mut App, backend_tx: &UnboundedSender) { + if app.status_loading { + app.error_message = + Some("Status refresh is still running; try again when it completes".to_string()); + return; + } + if matches!( app.operation_state, OperationState::Discovering { .. } | OperationState::Running { .. } @@ -123,17 +135,13 @@ fn start_operation(app: &mut App, operation: Operation, backend_tx: &UnboundedSe app.tick_count = 0; app.operation_state = OperationState::Discovering { - operation, - message: format!("Starting {}...", operation), + operation: Operation::Sync, + message: "Starting Sync...".to_string(), }; app.log_lines.clear(); app.scroll_offset = 0; - crate::tui::backend::spawn_operation(operation, app, backend_tx.clone()); -} - -pub(crate) fn start_sync_operation(app: &mut App, backend_tx: &UnboundedSender) { - start_operation(app, Operation::Sync, backend_tx); + crate::tui::backend::spawn_operation(Operation::Sync, app, backend_tx.clone()); } pub(crate) fn show_sync_progress(app: &mut App) { @@ -321,34 +329,40 @@ fn render_config_reqs(app: &App, frame: &mut Frame, area: Rect) { Span::styled(" Settings ", dim), ]; - let right = if app.checks_loading || app.check_results.is_empty() { - vec![ - Span::styled(" Checking...", Style::default().fg(Color::Yellow)), - Span::raw(" "), - Span::styled("[t]", key_style), - Span::styled(" Refresh", dim), - ] + let mut right = if app.checks_loading || app.check_results.is_empty() { + vec![Span::styled( + " Checking...", + Style::default().fg(Color::Yellow), + )] } else { let all_passed = app.check_results.iter().all(|c| c.passed); if all_passed { vec![ Span::styled(" [✓]", Style::default().fg(Color::Rgb(21, 128, 61))), Span::styled(" Requirements Satisfied", dim), - Span::raw(" "), - Span::styled("[t]", key_style), - Span::styled(" Refresh", dim), ] } else { vec![ Span::styled(" [✗]", Style::default().fg(Color::Red)), Span::styled(" Requirements Not Met", dim), - Span::raw(" "), - Span::styled("[t]", key_style), - Span::styled(" Refresh", dim), ] } }; + right.push(Span::raw(" ")); + if app.status_loading { + right.push(Span::styled( + format!( + "{} Refreshing...", + crate::tui::widgets::spinner::frame(app.tick_count) + ), + Style::default().fg(Color::Yellow), + )); + } else { + right.push(Span::styled("[t]", key_style)); + right.push(Span::styled(" Refresh", dim)); + } + render_info_line(frame, area, left, right); } diff --git a/crates/git-same-cli/src/tui/screens/dashboard_tests.rs b/crates/git-same-cli/src/tui/screens/dashboard_tests.rs index 4f5cbf0..1f17e09 100644 --- a/crates/git-same-cli/src/tui/screens/dashboard_tests.rs +++ b/crates/git-same-cli/src/tui/screens/dashboard_tests.rs @@ -1,6 +1,9 @@ use super::*; +use crate::tui::app::CheckEntry; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use git_same_core::config::{Config, WorkspaceConfig}; +use ratatui::backend::TestBackend; +use ratatui::Terminal; use tokio::sync::mpsc::unbounded_channel; fn build_app() -> App { @@ -11,6 +14,224 @@ fn build_app() -> App { app } +fn build_app_in(root: &std::path::Path) -> App { + let ws = WorkspaceConfig::new_from_root(root); + let mut app = App::new(Config::default(), vec![ws], false); + app.screen = Screen::Dashboard; + app.screen_stack.clear(); + app +} + +fn completed_check() -> CheckEntry { + CheckEntry { + name: "git".to_string(), + passed: true, + message: "Git is installed".to_string(), + suggestion: None, + critical: true, + } +} + +fn running_sync_state() -> OperationState { + OperationState::Running { + operation: Operation::Sync, + total: 2, + completed: 0, + failed: 0, + skipped: 0, + current_repo: "org/repo".to_string(), + with_updates: 0, + cloned: 0, + synced: 0, + to_clone: 1, + to_sync: 1, + total_new_commits: 0, + started_at: std::time::Instant::now(), + active_repos: vec!["org/repo".to_string()], + throughput_samples: Vec::new(), + last_sample_completed: 0, + } +} + +fn render_output(app: &mut App) -> String { + let backend = TestBackend::new(110, 32); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|frame| render(app, frame)).unwrap(); + + let buffer = terminal.backend().buffer(); + let mut text = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + text.push_str(buffer[(x, y)].symbol()); + } + text.push('\n'); + } + text +} + +#[tokio::test] +async fn t_key_does_not_set_operation_state() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + let completed_at = std::time::Instant::now(); + app.last_status_scan = Some(completed_at); + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert!(app.status_loading); + assert!(app.last_status_scan.is_none()); +} + +#[tokio::test] +async fn t_key_is_ignored_while_status_refresh_is_loading() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + let completed_at = std::time::Instant::now(); + app.status_loading = true; + app.last_status_scan = Some(completed_at); + app.check_results = vec![completed_check()]; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(app.status_loading); + assert_eq!(app.last_status_scan, Some(completed_at)); + assert_eq!(app.check_results.len(), 1); + assert!(matches!(app.operation_state, OperationState::Idle)); +} + +#[tokio::test] +async fn t_key_clears_check_results() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + app.check_results = vec![completed_check()]; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(app.check_results.is_empty()); + assert!(!app.checks_loading); + assert!(app.status_loading); + assert!(matches!(app.operation_state, OperationState::Idle)); +} + +#[tokio::test] +async fn t_key_is_ignored_while_sync_is_discovering_or_running() { + for operation_state in [ + OperationState::Discovering { + operation: Operation::Sync, + message: "Discovering repositories".to_string(), + }, + running_sync_state(), + ] { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + let completed_at = std::time::Instant::now(); + app.operation_state = operation_state; + app.last_status_scan = Some(completed_at); + app.check_results = vec![completed_check()]; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(!app.status_loading); + assert_eq!(app.last_status_scan, Some(completed_at)); + assert_eq!(app.check_results.len(), 1); + assert!(matches!( + app.operation_state, + OperationState::Discovering { + operation: Operation::Sync, + .. + } | OperationState::Running { + operation: Operation::Sync, + .. + } + )); + } +} + +#[tokio::test] +async fn t_key_preserves_in_flight_requirement_checks() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + app.checks_loading = true; + app.check_results = vec![completed_check()]; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(app.status_loading); + assert!(app.checks_loading); + assert_eq!(app.check_results.len(), 1); +} + +#[tokio::test] +async fn s_key_waits_for_active_status_refresh() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + let (tx, _rx) = unbounded_channel(); + app.status_loading = true; + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + &tx, + ) + .await; + + assert!(matches!(app.operation_state, OperationState::Idle)); + assert_eq!( + app.error_message.as_deref(), + Some("Status refresh is still running; try again when it completes") + ); +} + +#[test] +fn status_refresh_renders_animated_spinner_instead_of_key_hint() { + let workspace = tempfile::tempdir().unwrap(); + let mut app = build_app_in(workspace.path()); + app.status_loading = true; + app.tick_count = 0; + + let first_frame = render_output(&mut app); + assert!(first_frame.contains("⠋ Refreshing...")); + assert!(!first_frame.contains("[t] Refresh")); + + app.tick_count = 1; + let second_frame = render_output(&mut app); + assert!(second_frame.contains("⠙ Refreshing...")); + assert!(!second_frame.contains("[t] Refresh")); + assert_ne!(first_frame, second_frame); +} + #[tokio::test] async fn dashboard_s_starts_sync_without_opening_popup() { let mut app = build_app(); diff --git a/crates/git-same-cli/src/tui/widgets/mod.rs b/crates/git-same-cli/src/tui/widgets/mod.rs index 9273244..edc9450 100644 --- a/crates/git-same-cli/src/tui/widgets/mod.rs +++ b/crates/git-same-cli/src/tui/widgets/mod.rs @@ -1,4 +1,5 @@ //! Reusable TUI widgets. pub mod repo_table; +pub mod spinner; pub mod status_bar; diff --git a/crates/git-same-cli/src/tui/widgets/spinner.rs b/crates/git-same-cli/src/tui/widgets/spinner.rs new file mode 100644 index 0000000..ccd8fa7 --- /dev/null +++ b/crates/git-same-cli/src/tui/widgets/spinner.rs @@ -0,0 +1,12 @@ +//! Shared animated spinner frames for TUI screens. + +const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/// Return the spinner frame for the current application tick. +pub(crate) fn frame(tick_count: u64) -> char { + FRAMES[(tick_count % FRAMES.len() as u64) as usize] +} + +#[cfg(test)] +#[path = "spinner_tests.rs"] +mod tests; diff --git a/crates/git-same-cli/src/tui/widgets/spinner_tests.rs b/crates/git-same-cli/src/tui/widgets/spinner_tests.rs new file mode 100644 index 0000000..4c486d7 --- /dev/null +++ b/crates/git-same-cli/src/tui/widgets/spinner_tests.rs @@ -0,0 +1,14 @@ +use super::*; + +#[test] +fn spinner_advances_through_braille_frames() { + let rendered: String = (0..10).map(frame).collect(); + + assert_eq!(rendered, "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"); +} + +#[test] +fn spinner_wraps_after_the_last_frame() { + assert_eq!(frame(10), frame(0)); + assert_eq!(frame(11), frame(1)); +} 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 da0d8b5..194259d 100644 --- a/crates/git-same-core/src/config/workspace_store_tests.rs +++ b/crates/git-same-core/src/config/workspace_store_tests.rs @@ -77,6 +77,16 @@ fn with_temp_home(home: &Path, f: impl FnOnce() -> T) -> T { std::fs::create_dir_all(&appdata).ok(); std::env::set_var("APPDATA", &appdata); } + // Fail fast if isolation ever breaks: a test that resolves the real + // user config would silently register temp workspaces in + // ~/.config/git-same/config.toml instead of failing. + let resolved = crate::config::Config::default_path().expect("default_path"); + assert!( + resolved.starts_with(home), + "test config path {} escaped the temp home {}", + resolved.display(), + home.display() + ); f() } diff --git a/crates/git-same-core/src/ipc/mod.rs b/crates/git-same-core/src/ipc/mod.rs index c4e9322..34ea297 100644 --- a/crates/git-same-core/src/ipc/mod.rs +++ b/crates/git-same-core/src/ipc/mod.rs @@ -11,9 +11,16 @@ //! //! On macOS, IPC files live in the app-group container at //! `~/Library/Group Containers//` so the sandboxed Badges -//! extension and the (non-sandboxed) Tauri host can both reach them via the -//! `application-groups` entitlement, instead of via per-path absolute-path -//! exceptions that cannot be expanded for arbitrary users. +//! extension can reach them via the `application-groups` entitlement, instead +//! of via per-path absolute-path exceptions that cannot be expanded for +//! arbitrary users. +//! +//! The non-sandboxed Tauri host deliberately does NOT read from the container: +//! for a non-sandboxed process, reaching into an app container triggers the +//! "access data from other apps" TCC prompt. Instead the monitor mirrors a +//! real `status.json` into the host-facing dir from +//! [`IpcConfig::host_status_path`] (`~/.config/git-same/finder/`), and only +//! `finder.sock` is symlinked there (see `status_file::ensure_legacy_symlinks`). //! //! On non-macOS platforms (Linux, Windows), IPC files live under the user's //! XDG config dir at `~/.config/git-same/finder/`. @@ -23,7 +30,7 @@ pub mod status_file; #[cfg(unix)] pub mod unix_socket; -pub use status_file::StatusFileWriter; +pub use status_file::{remove_symlink_if_present, StatusFileWriter}; #[cfg(unix)] pub use unix_socket::{UnixSocketClient, UnixSocketListener}; @@ -66,7 +73,9 @@ impl IpcConfig { /// Returns the legacy `~/.config/git-same/finder/` path. /// /// Used as the macOS fallback and as the source side of legacy-symlink - /// migration on macOS (see `status_file::ensure_legacy_symlinks`). + /// migration on macOS (see `status_file::ensure_legacy_symlinks`). This is + /// the same directory as [`Self::host_status_path`], which is the + /// host-facing name for it; hosts reading live status should use that name. pub fn legacy_default_path() -> Result { let config_dir = crate::config::Config::default_path()?; let base_dir = config_dir @@ -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..af7e011 100644 --- a/crates/git-same-core/src/ipc/mod_tests.rs +++ b/crates/git-same-core/src/ipc/mod_tests.rs @@ -99,3 +99,56 @@ fn test_legacy_default_path_ends_in_finder() { ); } } + +#[test] +fn test_host_status_path_matches_legacy_default_path() { + // The host reads from the non-container host path; it must resolve to the + // same directory as legacy_default_path (a distinct name for clarity). + let host = IpcConfig::host_status_path(); + let legacy = IpcConfig::legacy_default_path(); + match (host, legacy) { + (Ok(host), Ok(legacy)) => { + assert_eq!(host.dir, legacy.dir); + // On Windows the dir ends in `git-same\config\finder` (see the + // comment on test_legacy_default_path_ends_in_finder), so the + // suffix check is unix-only; the equality above is the real point. + #[cfg(unix)] + assert!(host.dir.ends_with("git-same/finder")); + } + (Err(_), Err(_)) => {} + _ => panic!("host_status_path and legacy_default_path disagreed on success"), + } +} + +#[test] +fn test_status_writer_has_no_mirrors_for_custom_dir() { + // A caller-supplied dir (tests, embedders) must never leak mirror writes + // into the real user's host dir. + let temp = tempfile::tempdir().unwrap(); + let config = IpcConfig { + dir: temp.path().join("ipc"), + }; + let writer = config.status_writer(); + assert_eq!(writer.path(), config.status_file_path().as_path()); + assert!(writer.mirror_paths().is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_status_writer_mirrors_host_status_for_group_container() { + if std::env::var_os("HOME").is_none() { + return; + } + let config = IpcConfig::default_path().expect("default_path"); + let writer = config.status_writer(); + if Some(config.dir.as_path()) == macos_group_container_dir().as_deref() { + let host = IpcConfig::host_status_path().expect("host_status_path"); + assert_eq!( + writer.mirror_paths().to_vec(), + vec![host.status_file_path()] + ); + } else { + // Legacy fallback (HOME unset is handled above; this arm is defensive). + assert!(writer.mirror_paths().is_empty()); + } +} diff --git a/crates/git-same-core/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs index 81ad9b7..e501b45 100644 --- a/crates/git-same-core/src/ipc/status_file.rs +++ b/crates/git-same-core/src/ipc/status_file.rs @@ -12,12 +12,28 @@ use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct StatusFileWriter { path: PathBuf, + mirrors: Vec, } impl StatusFileWriter { /// Creates a writer for the given status file path. pub fn new(path: PathBuf) -> Self { - Self { path } + Self { + path, + mirrors: Vec::new(), + } + } + + /// Creates a writer that, after writing the primary `path`, writes an + /// identical atomic copy to each path in `mirrors`. + /// + /// Used on macOS so the monitor can keep `status.json` in the app-group + /// container (read by the sandboxed Badges extension) while also mirroring + /// a real copy into `~/.config/git-same/finder/` that the non-sandboxed + /// Tauri host can read without crossing the container boundary (which would + /// trigger the "access data from other apps" TCC prompt). + pub fn new_with_mirrors(path: PathBuf, mirrors: Vec) -> Self { + Self { path, mirrors } } /// The path this writer writes to. @@ -25,43 +41,33 @@ 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)))?; - let temp_path = self.path.with_extension("json.tmp"); - - // Ensure parent directory exists - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - AppError::path(format!( - "Failed to create directory '{}': {}", - parent.display(), - e - )) - })?; + write_atomic(&self.path, &json)?; + for mirror in &self.mirrors { + 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" + ); + } } - // Write to temp file - std::fs::write(&temp_path, &json).map_err(|e| { - AppError::path(format!( - "Failed to write temp status file '{}': {}", - temp_path.display(), - e - )) - })?; - - // Atomic rename - std::fs::rename(&temp_path, &self.path).map_err(|e| { - AppError::path(format!( - "Failed to rename '{}' → '{}': {}", - temp_path.display(), - self.path.display(), - e - )) - })?; - Ok(()) } @@ -83,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 + } } -/// On macOS, ensures `~/.config/git-same/finder/{status.json, finder.sock}` are -/// symlinks pointing into the app-group container directory. +/// 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/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> { @@ -104,19 +211,19 @@ 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(()); - } - - for filename in &["status.json", "finder.sock"] { - let legacy_path = legacy_dir.join(filename); - let target_path = group_dir.join(filename); - ensure_one_symlink(&legacy_path, &target_path)?; - } - - Ok(()) +/// 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. + let legacy_sock = legacy_dir.join("finder.sock"); + let target_sock = group_dir.join("finder.sock"); + ensure_one_symlink(&legacy_sock, &target_sock) } /// Non-macOS no-op so the monitor can call this unconditionally without `cfg` diff --git a/crates/git-same-core/src/ipc/status_file_tests.rs b/crates/git-same-core/src/ipc/status_file_tests.rs index 98831d5..c81b36e 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,120 @@ fn test_no_temp_file_remains_after_write() { assert!(!temp_path.exists()); } +#[test] +fn test_write_produces_primary_and_every_mirror() { + let temp = tempfile::tempdir().unwrap(); + let primary = temp.path().join("container/status.json"); + let mirror = temp.path().join("host/status.json"); + let writer = StatusFileWriter::new_with_mirrors(primary.clone(), vec![mirror.clone()]); + + let status = sample_status(); + writer.write(&status).unwrap(); + + // Both files exist as real files with identical content. + assert!(primary.exists()); + assert!(mirror.exists()); + assert_eq!( + std::fs::read_to_string(&primary).unwrap(), + std::fs::read_to_string(&mirror).unwrap() + ); + + // The writer reads back from the primary. + assert_eq!(writer.read().unwrap(), status); + + // A reader pointed at the mirror sees the same status. + let mirror_reader = StatusFileWriter::new(mirror); + assert_eq!(mirror_reader.read().unwrap(), status); +} + +#[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 +303,36 @@ 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")); } } 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 f222374..72fef55 100644 --- a/crates/git-same-core/src/macos/mod.rs +++ b/crates/git-same-core/src/macos/mod.rs @@ -1,8 +1,10 @@ //! macOS-only host integration helpers. //! -//! These wrap Cocoa / xattr operations that the FinderSync extension cannot -//! perform from its sandbox — currently only custom workspace folder icons -//! (painted via `NSWorkspace.setIcon`). On non-macOS targets the submodules -//! expose no-op stubs so callers can stay platform-agnostic. +//! These wrap Cocoa / xattr operations and TCC probes that the FinderSync +//! extension cannot perform from its sandbox: custom workspace folder icons +//! (painted via `NSWorkspace.setIcon`) and the Full Disk Access probe. On +//! non-macOS targets the submodules expose no-op stubs so callers can stay +//! platform-agnostic. pub mod folder_icon; +pub mod full_disk_access; diff --git a/crates/git-same-core/src/monitor/mod.rs b/crates/git-same-core/src/monitor/mod.rs index 448c8c8..6795318 100644 --- a/crates/git-same-core/src/monitor/mod.rs +++ b/crates/git-same-core/src/monitor/mod.rs @@ -17,4 +17,4 @@ pub mod run; #[cfg(unix)] pub mod socket_handler; -pub use run::{run, Options}; +pub use run::{default_shutdown_signal, run, Options}; diff --git a/crates/git-same-core/src/monitor/run.rs b/crates/git-same-core/src/monitor/run.rs index 430a307..95860a0 100644 --- a/crates/git-same-core/src/monitor/run.rs +++ b/crates/git-same-core/src/monitor/run.rs @@ -41,6 +41,47 @@ pub struct Options { pub ipc_config: IpcConfig, } +impl Options { + /// Build options from `config.toml`. An explicit `interval_override` (the + /// CLI `--interval` flag) wins over `[monitor] fullscan_interval_secs`. + pub fn from_config( + config: &Config, + ipc_config: IpcConfig, + interval_override: Option, + ) -> Self { + let secs = interval_override.unwrap_or(config.monitor.fullscan_interval_secs); + Self { + interval: Duration::from_secs(secs), + ipc_config, + } + } +} + +/// Resolve when the process receives SIGINT (ctrl-c) or SIGTERM (`gisa +/// monitor --stop`, `launchctl bootout`). Shared by every monitor host: the +/// CLI subcommand and the app's headless monitor mode. +pub async fn default_shutdown_signal() { + #[cfg(unix)] + { + let mut sigterm = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(signal) => signal, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + /// Run the monitor loop until `shutdown` resolves. pub async fn run(config: &Config, output: &Output, opts: Options, shutdown: S) -> Result<()> where @@ -60,7 +101,7 @@ where info!("Starting git-same monitor"); output.info("Starting git-same monitor..."); - 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)); @@ -150,7 +191,7 @@ where match result { Ok((stream, _)) => { let config_clone = config.clone(); - let writer_path = status_writer.path().to_path_buf(); + let writer = status_writer.clone(); let owner_clone = service.owner_types_clone(); let ambient_clone = service.ambient_upgrades_clone(); let status_clone = shared_status.clone(); @@ -159,7 +200,7 @@ where stream, &config_clone, pid, - &writer_path, + writer, status_clone, owner_clone, ambient_clone, @@ -331,3 +372,7 @@ fn enclosing_repo(path: &Path, watched_roots: &[PathBuf]) -> Option { current = current.parent()?; } } + +#[cfg(test)] +#[path = "run_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/monitor/run_tests.rs b/crates/git-same-core/src/monitor/run_tests.rs new file mode 100644 index 0000000..6444d28 --- /dev/null +++ b/crates/git-same-core/src/monitor/run_tests.rs @@ -0,0 +1,28 @@ +use super::*; + +fn ipc_config() -> IpcConfig { + IpcConfig { + dir: std::env::temp_dir().join("git-same-monitor-run-tests"), + } +} + +#[test] +fn from_config_uses_config_interval_when_no_override() { + let mut config = Config::default(); + config.monitor.fullscan_interval_secs = 90; + + let opts = Options::from_config(&config, ipc_config(), None); + + assert_eq!(opts.interval, Duration::from_secs(90)); + assert_eq!(opts.ipc_config.dir, ipc_config().dir); +} + +#[test] +fn from_config_lets_explicit_override_win() { + let mut config = Config::default(); + config.monitor.fullscan_interval_secs = 30; + + let opts = Options::from_config(&config, ipc_config(), Some(10)); + + assert_eq!(opts.interval, Duration::from_secs(10)); +} diff --git a/crates/git-same-core/src/monitor/socket_handler.rs b/crates/git-same-core/src/monitor/socket_handler.rs index fdb20c3..02f23ce 100644 --- a/crates/git-same-core/src/monitor/socket_handler.rs +++ b/crates/git-same-core/src/monitor/socket_handler.rs @@ -10,7 +10,6 @@ use crate::ipc::unix_socket::DaemonCommand; use crate::ipc::StatusFileWriter; use crate::monitor::incremental::rescan_and_merge; use crate::types::FinderStatus; -use std::path::Path; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -23,7 +22,7 @@ pub async fn handle_socket_connection( mut stream: UnixStream, config: &Config, pid: u32, - status_path: &Path, + status_writer: StatusFileWriter, shared_status: Arc>, owner_types: Option, ambient_upgrades: Option, @@ -59,8 +58,7 @@ pub async fn handle_socket_connection( let mut status = shared_status.lock().expect("status mutex poisoned"); let changed = rescan_and_merge(&service, &mut status, &canonical); if changed { - let file_writer = StatusFileWriter::new(status_path.to_path_buf()); - if let Err(e) = file_writer.write(&status) { + if let Err(e) = status_writer.write(&status) { error!(error = %e, "Failed to write status file after Refresh"); } } @@ -76,8 +74,7 @@ pub async fn handle_socket_connection( Ok(new_status) => { let mut status = shared_status.lock().expect("status mutex poisoned"); *status = new_status; - let file_writer = StatusFileWriter::new(status_path.to_path_buf()); - if let Err(e) = file_writer.write(&status) { + if let Err(e) = status_writer.write(&status) { error!(error = %e, "Failed to write status file after RefreshAll"); } "OK\n".to_string() @@ -87,7 +84,7 @@ pub async fn handle_socket_connection( "ERROR\n".to_string() } }, - DaemonCommand::Status => status_response(status_path), + DaemonCommand::Status => status_response(&status_writer), DaemonCommand::Unknown(cmd) => { format!("UNKNOWN: {}\n", cmd) } @@ -101,9 +98,8 @@ pub async fn handle_socket_connection( /// pretty JSON terminated by a newline so it matches the line-framed protocol /// (`PONG\n`, `OK\n`, `ERROR\n`). Returns `ERROR\n` if the file can't be read /// or serialized. -fn status_response(status_path: &Path) -> String { - let file_writer = StatusFileWriter::new(status_path.to_path_buf()); - match file_writer.read() { +fn status_response(writer: &StatusFileWriter) -> String { + match writer.read() { Ok(status) => serde_json::to_string_pretty(&status) .map(|s| format!("{s}\n")) .unwrap_or_else(|_| "ERROR\n".to_string()), diff --git a/crates/git-same-core/src/monitor/socket_handler_tests.rs b/crates/git-same-core/src/monitor/socket_handler_tests.rs index e8d74e8..aaec994 100644 --- a/crates/git-same-core/src/monitor/socket_handler_tests.rs +++ b/crates/git-same-core/src/monitor/socket_handler_tests.rs @@ -10,7 +10,7 @@ fn status_response_ends_with_newline() { .write(&FinderStatus::new(0, "2026-06-21T00:00:00Z".to_string())) .unwrap(); - let resp = status_response(&path); + let resp = status_response(&writer); assert!( resp.ends_with('\n'), "Status response must end with newline" @@ -21,6 +21,7 @@ fn status_response_ends_with_newline() { #[test] fn status_response_error_when_missing() { let dir = TempDir::new().unwrap(); - let resp = status_response(&dir.path().join("does-not-exist.json")); + let writer = StatusFileWriter::new(dir.path().join("does-not-exist.json")); + let resp = status_response(&writer); assert_eq!(resp, "ERROR\n"); } diff --git a/crates/git-same-core/src/provider/traits.rs b/crates/git-same-core/src/provider/traits.rs index 4d018d1..582319f 100644 --- a/crates/git-same-core/src/provider/traits.rs +++ b/crates/git-same-core/src/provider/traits.rs @@ -172,6 +172,9 @@ impl DiscoveryProgress for NoProgress { /// /// This trait defines the interface for interacting with Git hosting providers /// like GitHub, GitLab, and Bitbucket. +// async_trait expands each async fn with a #[must_use] attribute on a future +// that is already must_use; newer clippy flags that as double_must_use. +#[allow(clippy::double_must_use)] #[async_trait] pub trait Provider: Send + Sync { /// Returns the provider kind (GitHub, GitLab, etc.). 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 532355f..4bcf4a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -244,6 +244,17 @@ All examples in this README use `git-same`, but any alias works interchangeably. The cask installs `Git-Same.app`, the CLI aliases, a FinderSync badge extension, and a monitor LaunchAgent. The app reads the same config as the CLI and shows workspace status from the monitor. Finder badges use the monitor's status file, and workspace root folders get a custom Git-Same folder icon unless `[ui] custom_folder_icon = false` is set. +### Full Disk Access and Finder badges + +Finder badges need Full Disk Access. The monitor reads every repository folder, and without the grant macOS asks for each protected location (Desktop, Documents, Downloads, external and network volumes) and denies the folders you decline. The app therefore walks you through badge setup in this order, and refuses to enable the extension until the grant is in place: + +1. The monitor is running. +2. Full Disk Access is granted to `Git-Same` in System Settings > Privacy & Security > Full Disk Access. macOS applies the grant when a process starts, so quit and reopen the app afterwards; the app restarts the monitor for you once it sees the grant. +3. The Finder extension is installed. +4. Enable badges. The app sets the extension election itself; if macOS ignores that, use the Open button to toggle Git-Same Badges in Login Items & Extensions. + +One grant covers both the app and the monitor because the LaunchAgent runs the monitor through the app's own executable (`Git-Same.app/Contents/MacOS/git-same-app monitor`). Two things the grant never covers: `gisa` run from a terminal uses the terminal's permissions, and a development build under `target/` is a separate identity that macOS prompts for again. + Useful checks: ```bash diff --git a/macos/GitSameBadges/GitSameBadges.entitlements b/macos/GitSameBadges/GitSameBadges.entitlements index b1d1b3d..29cc387 100644 --- a/macos/GitSameBadges/GitSameBadges.entitlements +++ b/macos/GitSameBadges/GitSameBadges.entitlements @@ -9,13 +9,15 @@ so both processes reach the same files. No absolute-path exception is needed for the IPC files. - Workspace folders: the extension reads arbitrary user-defined - repository paths to compute badges. The shippable answer is - Full Disk Access, granted by the user once via System Settings > - Privacy & Security > Full Disk Access on first launch of - Git-Same.app. Per-workspace absolute-path entitlements would - require the user to re-sign the extension when adding a workspace, - which is impractical without an Apple Developer ID. + Workspace folders: the extension never reads repository paths itself. + It only registers the monitor's `monitored_roots` as + `directoryURLs` and answers Finder from `status.json`, so it needs + no file entitlements and triggers no TCC prompts. The process that + reads the folders is the monitor, which runs under the app's + bundle identity and needs Full Disk Access, granted once via + System Settings > Privacy & Security > Full Disk Access. Per-path + absolute-path exceptions would require re-signing the extension + for every new workspace, so they are deliberately not used. macOS 26 testing constraints (memory: feedback_finder_sync_testing.md): - Sandbox stays ON. diff --git a/macos/GitSameBadges/Info.plist b/macos/GitSameBadges/Info.plist index b019946..a55e641 100644 --- a/macos/GitSameBadges/Info.plist +++ b/macos/GitSameBadges/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 3.1.0 + 3.2.0 CFBundleVersion - 3.1.0 + 3.2.0 NSExtension NSExtensionPointIdentifier diff --git a/toolkit/homebrew/cask.rb.tmpl b/toolkit/homebrew/cask.rb.tmpl index 46c3973..934c1ed 100644 --- a/toolkit/homebrew/cask.rb.tmpl +++ b/toolkit/homebrew/cask.rb.tmpl @@ -38,7 +38,11 @@ cask "git-same" do plist_src = "#{appdir}/Git-Same.app/Contents/Resources/com.zaai.git-same.monitor.plist" plist_dst = "#{Dir.home}/Library/LaunchAgents/com.zaai.git-same.monitor.plist" - monitor_binary = "#{appdir}/Git-Same.app/Contents/Helpers/git-same" + # The agent execs the app's main executable (headless `monitor` mode), not + # the CLI helper: macOS TCC attributes a launchd-spawned process to its + # bundle only through the bundle's own executable, so this is what lets a + # single Full Disk Access grant for Git-Same cover the monitor. + monitor_binary = "#{appdir}/Git-Same.app/Contents/MacOS/git-same-app" FileUtils.mkdir_p(File.dirname(plist_dst)) rendered = File.read(plist_src).gsub("__GIT_SAME_MONITOR_BINARY__", monitor_binary) diff --git a/toolkit/packaging/macos/build-app-bundle.sh b/toolkit/packaging/macos/build-app-bundle.sh index 4bebb4b..3eefdfe 100755 --- a/toolkit/packaging/macos/build-app-bundle.sh +++ b/toolkit/packaging/macos/build-app-bundle.sh @@ -96,6 +96,11 @@ cat > "$APP/Contents/Info.plist" <LSMinimumSystemVersion13.0 LSApplicationCategoryTypepublic.app-category.developer-tools NSHighResolutionCapable + NSDesktopFolderUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. + NSDocumentsFolderUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. + NSDownloadsFolderUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. + NSRemovableVolumesUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. + NSNetworkVolumesUsageDescriptionGit-Same scans your repository folders to show sync status badges in Finder. EOF