diff --git a/.agents/research/2026-09-08-appstore-whats-new-1.4.txt b/.agents/research/2026-09-08-appstore-whats-new-1.4.txt new file mode 100644 index 0000000..a7d0486 --- /dev/null +++ b/.agents/research/2026-09-08-appstore-whats-new-1.4.txt @@ -0,0 +1,9 @@ +Totals you can trust, and a long list of fixes. + +• Every byte is counted. DirStat no longer skips hidden folders or dependency folders like node_modules, .git and DerivedData by default. On a real project folder the total went from 40 MB to the correct 7.5 GB; on a whole Mac it now matches what Finder reports as used. +• Dependency and cache folders collapse into a single row instead of thousands of tiny files, so scans stay fast and the chart stays readable. +• The duplicate finder no longer reports two different files as duplicates when they merely share a size and a first chunk. +• Errors are shown instead of silently ignored: failed scans, saves, exports and moves to Trash now tell you what happened. +• The chart no longer comes up empty for folders with hundreds of similar files, labels no longer overlap, and collapsed folders can't be opened into a dead end. +• Live refresh and Move to Trash no longer stall the window on large folders. +• Many smaller fixes: readable selection in light mode, unit changes apply immediately, "1000.0 KB" reads "1.0 MB", and more. diff --git a/.agents/research/2026-09-08-release-prep-v1.4.1.md b/.agents/research/2026-09-08-release-prep-v1.4.1.md new file mode 100644 index 0000000..88a0f54 --- /dev/null +++ b/.agents/research/2026-09-08-release-prep-v1.4.1.md @@ -0,0 +1,72 @@ +# Release Prep Report — v1.4.1 (Mac App Store) + +**Date:** 2026-09-08 +**Version:** 1.4.1 (Build 12) +**Release Type:** Patch (fixes only) +**Status:** Ready to upload (package built and signed; upload needs the account holder) + +## Version Bump + +- [x] MARKETING_VERSION: 1.4 → 1.4.1 (App Store Connect already has an approved 1.4, build 10) +- [x] CURRENT_PROJECT_VERSION: 10 → 12 +- [x] CHANGELOG 1.4.1 dated 2026-09-08 + +## Build artefacts (gitignored, in `build/AppStore-1.4.1-12/`) + +| File | Purpose | +|---|---| +| `DirStat-1.4.1-12.pkg` | App Store Connect upload package, signed "3rd Party Mac Developer Installer", app signed "Cloud Managed Apple Distribution", profile "Mac Team Store Provisioning Profile: com.macdirstat.app" | +| `DirStat-1.4.1-12.xcarchive` | The archive (Sparkle stripped: no Frameworks dir, no Sparkle load command; sandbox + user-selected read-write entitlements) | +| `exportOptions-appstore.plist` | `method=app-store-connect`, `signingStyle=automatic`, team L4UH9K7AW4 | +| `WhatsNew.txt` | Copy-paste release notes for App Store Connect | + +## Code readiness + +| Check | Status | Notes | +|---|---|---| +| Tests | ✓ | 156 tests, 0 failures (`swift test`) | +| Both configs build | ✓ | Release (Developer ID) and AppStore | +| Debug prints outside DEBUG | ✓ | none | +| Blocking TODOs | ✓ | one design note in AtomicDirectorySummary, not a blocker | +| Deployment target | ✓ | macOS 13.0 | +| Verified on a real disk | ✓ | Developer ID build: 881.2 GB shown vs 880 GB Finder used | +| Sandboxed build driven by hand | ✗ | not exercised this session (open panel needs a human); last verified 2026-08-25 | + +## Privacy & compliance + +| Check | Status | Notes | +|---|---|---| +| Privacy manifest | ✓ | `PrivacyInfo.xcprivacy`, UserDefaults reason CA92.1, no tracking, no collected data | +| Required-reason APIs | ✓ | UserDefaults declared; `volumeAvailableCapacity*` is macOS-only (no reason required on macOS) | +| Third-party SDKs in store bundle | ✓ | none (Sparkle physically stripped) | +| ATS exceptions | ✓ | none | +| Entitlements | ✓ | sandbox + files.user-selected.read-write only | +| Export compliance | ✓ | ITSAppUsesNonExemptEncryption=false | + +## App Store metadata + +| Check | Status | Notes | +|---|---|---| +| App icon | ✓ | 16…1024 present | +| What's New | ✓ | `WhatsNew.txt` | +| Screenshots | ? | existing listing screenshots predate the label/list changes; refresh if desired | +| Support / privacy URLs | ? | verify in App Store Connect | + +## Upload + +Either drop `DirStat-1.4.1-12.pkg` on Transporter, or from the repo: + +```bash +xcodebuild -exportArchive \ + -archivePath build/AppStore-1.4.1-12/DirStat-1.4.1-12.xcarchive \ + -exportOptionsPlist build/AppStore-1.4.1-12/exportOptions-upload.plist \ + -allowProvisioningUpdates +``` + +where `exportOptions-upload.plist` is the export plist with `destination` set to `upload`. Then in App Store Connect: create version 1.4.1, attach build 12, paste `WhatsNew.txt`, submit for review. + +## Post-release + +- [ ] Confirm 1.4.1 live; existing store users get the legacy-defaults migration on first launch +- [ ] Watch crash reports 48 h +- [ ] Tag `v1.4.1` once the DMG track ships the same commit diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2a6fe..8de946a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,95 @@ All notable changes to MacDirStat are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.4.1] - 2026-09-08 + +A correctness release: the scan total is now right by default, and a long +list of user-visible bugs found in an end-to-end audit are fixed. + +### Fixed + +- **Totals were wrong by default.** The factory settings excluded `.git`, + `node_modules`, `DerivedData` and `.Trash` and skipped every hidden entry, + so a real 7.5 GB project scanned as 40 MB. Nothing is excluded now and + hidden entries are counted; dependency and cache trees (`node_modules`, + `.git`, `Pods`, `.next`, `.venv`, ...) are collapsed into a single row + instead of dropped. Existing installs that still carry the old default + list are migrated once; a customised list is left alone. Verified + byte-for-byte against `du`. +- The duplicate finder could report two different files as duplicates when + they shared the same on-disk size and first 64 KB (sparse or compressed + files), so "Delete All Duplicates" could trash a distinct file. +- Errors from scanning, saving, opening, comparing, exporting and trashing + were recorded but never shown; they now appear as an alert. +- A flat folder with a few hundred similar-size files drew an empty chart + labelled "This folder contains no files". +- Collapsed folders (`node_modules` and friends) could be drilled into, + leaving an empty chart with the Back control hidden. +- Purgeable space (local snapshots, evictable caches) was reported as + "Hidden & Unreadable Space" on volume scans; the figure now matches what + Finder counts as used. Full Disk Access is detected by actually opening a + protected file, on either TCC database (issue #24). +- A scan that finished while a newer scan or archive load was in progress + could install its results over the newer one, keep watching the old + folder, or flip a live scan read-only. +- Live refresh did its disk walk on the main thread (UI stalls during + builds) and could resurrect a folder that had just been trashed. +- Trashing while duplicate detection was still running left "Scanning for + duplicates…" spinning until the next rescan. +- Symlinked scan roots (`/tmp`, alias folders) failed with a raw error + instead of scanning their target. +- A full scan of the boot volume now accounts for every readable byte: + 854.9 GB of 8.5 million items on a 907 GB container, with the remainder + being the Preboot and Recovery volumes plus purgeable snapshot space that + no file scanner can show. That remainder is reported as one synthetic + row, renamed from "Hidden & Unreadable Space" to "System & Unreadable + Space" now that hidden files are actually scanned. Verified in the + installed app: 881.2 GB shown for a volume Finder reports at 880 GB used. +- Existing installs also had 1.3's "hide hidden files" default persisted in + their preferences; the one-time migration now resets that too (a toggle + set afterwards is respected), which was worth another 260 GB on a real + home folder (~/.cache, ~/.ollama, .git, .Trash and friends). +- The "Full Disk Access required" banner no longer appears once the grant + is held: whatever is still denied then is root-only and no setting can + open it (issue #24). Locked folders keep their icon; Settings shows the + count. +- The file list said "Scanning…" for minutes after a large scan had + finished while safety tags were computed; tagging is now an incremental + path walk (no per-node URL) and the panel says "Preparing…" meanwhile. +- Live refresh re-hashed the entire tree for duplicates on every change + batch; it now hashes only the size buckets the changed folder touches, + and duplicate results are written on the main thread instead of racing + the views. +- Move to Trash runs off the main thread, so "Delete All Duplicates" over + thousands of files no longer freezes the window. +- Comparing two scans no longer lists every file under a folder as + "removed" when that folder became a collapsed summary between the two + scans; it reports the folder's own growth instead. +- Chart labels no longer print on top of each other or spill past their + arc ("PlugInsMediaProvMotionEffect.fxp"); long names are middle-truncated + to fit, labels that would be meaningless are dropped, and inner-ring + labels slide clear of the center disc instead of being cut off by it. +- The file list panel was narrow enough that names truncated to three + letters ("Fina…"); it now keeps room for the name column. +- Smaller polish: clicking empty chart space clears the selection (and the + 30 fps pulse timer that ran forever after the first click); the synthetic + hidden-space row no longer offers Reveal/Copy Path/Trash or shows up in + the legend as "(directory)"; extensionless files are "(no extension)"; + the legend recolors with the color scheme and sizes refresh when the + unit style changes; "1000.0 KB" now reads "1.0 MB"; selected list rows + are readable in light mode; expansion state and duplicate-group + expansion survive deletes; the sidebar Duplicates link that stranded the + user in the detail pane is gone; Export CSV is disabled with no scan + loaded; the toolbar shows progress while comparing. + +## [1.4.0] - 2026-08-25 (Mac App Store) + +- Back on the Mac App Store after 1.0: a sandboxed store configuration + built from the same source as the Developer ID release, with Sparkle + removed from the store bundle and "auto-scan last folder" carried through + a security-scoped bookmark. Full Disk Access features are hidden in the + store build, since a sandboxed app cannot hold that grant. + ## [1.3.0] - 2026-08-04 A performance-focused release: the scanner, the in-memory tree, and live diff --git a/MacDirStat.xcodeproj/project.pbxproj b/MacDirStat.xcodeproj/project.pbxproj index b3b3ce2..8958c04 100644 --- a/MacDirStat.xcodeproj/project.pbxproj +++ b/MacDirStat.xcodeproj/project.pbxproj @@ -11,51 +11,54 @@ 114A022A9D8A4529EC91BC12 /* ScanViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8911D9B9B974332D6C8A0DC6 /* ScanViewModel.swift */; }; 1CE01DEB69AB4BFABA1C3D53 /* DuplicatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2767B8AD8F6829C36A3576E6 /* DuplicatesView.swift */; }; 20E67F1EEADF74D28CDF6191 /* TreemapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40A6B24C517964825BA4B85 /* TreemapView.swift */; }; + 26B861D7144BFB6B2FD00F6B /* FileNodeRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03CE9327807EE8357D3678B /* FileNodeRecord.swift */; }; 287777DD85DBCE044C004846 /* MacDirStatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B91AB72C561811A78E4DB8C /* MacDirStatApp.swift */; }; 3864E5104A08A42FF56D7880 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8FDBB44CE9624311841E36 /* ContentView.swift */; }; 3881FC74433C93187BF941AF /* FSNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F5AEBBF9DECB5CB793339A /* FSNode.swift */; }; 3B190FDF9A024126B4ED2872 /* GlassWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B5316639CA4AC8990FBDD4 /* GlassWindow.swift */; }; - D1E2F3A4B5C607182930AABD /* GlassCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F607182930AABC /* GlassCompat.swift */; }; - FDA0FDA0FDA0FDA0FDA00002 /* FullDiskAccessSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA0FDA0FDA0FDA0FDA00001 /* FullDiskAccessSheet.swift */; }; 3F67AACE53A6C32467A5191C /* TreemapRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670402F6A7AEB43C1D764E04 /* TreemapRenderer.swift */; }; + 4A4F4C3CFEF84A1B827AB6EE /* ScanComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542CEEA3CDE4292A6661596 /* ScanComparison.swift */; }; + 57FD4B40D3F541AD98E734A1 /* ComparisonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A060E7C6E1C405F81CE31FA /* ComparisonView.swift */; }; 7AE6B3CB6CDE22EAA3AA46B3 /* DuplicateDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B58422D704C6E5265DA664 /* DuplicateDetector.swift */; }; + 84449F67C7DF40C737411C80 /* FileTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B5ADD782F265EBF4B1037EE /* FileTree.swift */; }; 86B25923164A00AB10D72C9B /* ScanProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCAD22DB54E68077B842421 /* ScanProgress.swift */; }; 8D4491F1E310D7522AC953CF /* ByteFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63B9141E78488371B27E584 /* ByteFormatter.swift */; }; 99810209AD4FE3320BC122AD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 58F02208A1E6A99468703A4E /* Assets.xcassets */; }; - AA00BB11CC22DD33EE44FF55 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA00BB11CC22DD33EE44FF56 /* PrivacyInfo.xcprivacy */; }; A0ACA58C598D03980CE0E1A8 /* DirectoryTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51083B9602AF3966A2918311 /* DirectoryTreeView.swift */; }; + A1B2C3D4E5F600112233445A /* BulkDirectoryEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600112233445B /* BulkDirectoryEnumerator.swift */; }; + A1B2C3D4E5F600112233445C /* AtomicDirectorySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600112233445D /* AtomicDirectorySummary.swift */; }; + A498AC088B1E3C6E1B255BC2 /* FileNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9429B8A593856D8B12941F49 /* FileNode.swift */; }; + A7CECAA54EC4419CA4B847D2 /* ScanArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0C663AD1A1417DA8FC6942 /* ScanArchive.swift */; }; + AA00BB11CC22DD33EE44FF55 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA00BB11CC22DD33EE44FF56 /* PrivacyInfo.xcprivacy */; }; AA11223344556677889900AA /* SafetyAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB11223344556677889900BB /* SafetyAnalyzer.swift */; }; AEE6BD1042811A9E652EFFD1 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CAC3593B7A665AD55414A36 /* SidebarView.swift */; }; C001072776950E6399A45B18 /* ExtensionColorMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 074CBBA0222600D63C734574 /* ExtensionColorMap.swift */; }; C6DF8F53ABC9BAC0D4112104 /* TreemapCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57889EBA74C350CC3B289A7 /* TreemapCell.swift */; }; C7CA42F3711C0D9BA152E308 /* FileTypeIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCF3DBFA96ECFE224E1D228 /* FileTypeIcon.swift */; }; + D1E2F3A4B5C607182930AABD /* GlassCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F607182930AABC /* GlassCompat.swift */; }; DD11223344556677889900DD /* FileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE11223344556677889900EE /* FileWatcher.swift */; }; E5BB63BED418F08CF4727F72 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 783902530DE4EA8040806BC9 /* SettingsView.swift */; }; + E7ED11C27A082009974B4706 /* FileTreeBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB7871D4D4A1E73B7984396 /* FileTreeBuilder.swift */; }; F81DF36922942E2F442814A3 /* TreemapLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB4247FB8E3E3FB5AF053F9 /* TreemapLayout.swift */; }; FB3E660E0C4E74F2A9EFBEF0 /* FileScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD56FD4714436315629F989 /* FileScanner.swift */; }; - A1B2C3D4E5F600112233445A /* BulkDirectoryEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600112233445B /* BulkDirectoryEnumerator.swift */; }; - A1B2C3D4E5F600112233445C /* AtomicDirectorySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600112233445D /* AtomicDirectorySummary.swift */; }; FD11F6ED4164F05503706848 /* ExtensionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987EDA67820F162556A7CA53 /* ExtensionListView.swift */; }; - 84449F67C7DF40C737411C80 /* FileTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B5ADD782F265EBF4B1037EE /* FileTree.swift */; }; - A498AC088B1E3C6E1B255BC2 /* FileNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9429B8A593856D8B12941F49 /* FileNode.swift */; }; - 26B861D7144BFB6B2FD00F6B /* FileNodeRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03CE9327807EE8357D3678B /* FileNodeRecord.swift */; }; - E7ED11C27A082009974B4706 /* FileTreeBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB7871D4D4A1E73B7984396 /* FileTreeBuilder.swift */; }; - A7CECAA54EC4419CA4B847D2 /* ScanArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0C663AD1A1417DA8FC6942 /* ScanArchive.swift */; }; - 4A4F4C3CFEF84A1B827AB6EE /* ScanComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542CEEA3CDE4292A6661596 /* ScanComparison.swift */; }; - 57FD4B40D3F541AD98E734A1 /* ComparisonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A060E7C6E1C405F81CE31FA /* ComparisonView.swift */; }; + FDA0FDA0FDA0FDA0FDA00002 /* FullDiskAccessSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA0FDA0FDA0FDA0FDA00001 /* FullDiskAccessSheet.swift */; }; SPARK003000000000000003AA /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = SPARK002000000000000002AA /* Sparkle */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 074CBBA0222600D63C734574 /* ExtensionColorMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionColorMap.swift; sourceTree = ""; }; 08B5316639CA4AC8990FBDD4 /* GlassWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassWindow.swift; sourceTree = ""; }; - A1B2C3D4E5F607182930AABC /* GlassCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCompat.swift; sourceTree = ""; }; - FDA0FDA0FDA0FDA0FDA00001 /* FullDiskAccessSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessSheet.swift; sourceTree = ""; }; + 1A060E7C6E1C405F81CE31FA /* ComparisonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparisonView.swift; sourceTree = ""; }; + 1B5ADD782F265EBF4B1037EE /* FileTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree.swift; sourceTree = ""; }; 2767B8AD8F6829C36A3576E6 /* DuplicatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuplicatesView.swift; sourceTree = ""; }; 35F5AEBBF9DECB5CB793339A /* FSNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSNode.swift; sourceTree = ""; }; 42B58422D704C6E5265DA664 /* DuplicateDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuplicateDetector.swift; sourceTree = ""; }; + 4542CEEA3CDE4292A6661596 /* ScanComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanComparison.swift; sourceTree = ""; }; + 4FB7871D4D4A1E73B7984396 /* FileTreeBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeBuilder.swift; sourceTree = ""; }; 51083B9602AF3966A2918311 /* DirectoryTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryTreeView.swift; sourceTree = ""; }; 58F02208A1E6A99468703A4E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 5B0C663AD1A1417DA8FC6942 /* ScanArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanArchive.swift; sourceTree = ""; }; 5B91AB72C561811A78E4DB8C /* MacDirStatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacDirStatApp.swift; sourceTree = ""; }; 670402F6A7AEB43C1D764E04 /* TreemapRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreemapRenderer.swift; sourceTree = ""; }; 6CAC3593B7A665AD55414A36 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; @@ -64,28 +67,25 @@ 7F8FDBB44CE9624311841E36 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 87CAB05044416C1233CD89F1 /* MacDirStat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MacDirStat.entitlements; sourceTree = ""; }; 8911D9B9B974332D6C8A0DC6 /* ScanViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanViewModel.swift; sourceTree = ""; }; + 9429B8A593856D8B12941F49 /* FileNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileNode.swift; sourceTree = ""; }; 967A6155625E5F7CD9A4CC95 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - AA00BB11CC22DD33EE44FF56 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 987EDA67820F162556A7CA53 /* ExtensionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionListView.swift; sourceTree = ""; }; 9CCF3DBFA96ECFE224E1D228 /* FileTypeIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTypeIcon.swift; sourceTree = ""; }; + A1B2C3D4E5F600112233445B /* BulkDirectoryEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulkDirectoryEnumerator.swift; sourceTree = ""; }; + A1B2C3D4E5F600112233445D /* AtomicDirectorySummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicDirectorySummary.swift; sourceTree = ""; }; + A1B2C3D4E5F607182930AABC /* GlassCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCompat.swift; sourceTree = ""; }; A57889EBA74C350CC3B289A7 /* TreemapCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreemapCell.swift; sourceTree = ""; }; + AA00BB11CC22DD33EE44FF56 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; B63B9141E78488371B27E584 /* ByteFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatter.swift; sourceTree = ""; }; BAB4247FB8E3E3FB5AF053F9 /* TreemapLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreemapLayout.swift; sourceTree = ""; }; BB11223344556677889900BB /* SafetyAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyAnalyzer.swift; sourceTree = ""; }; C40A6B24C517964825BA4B85 /* TreemapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreemapView.swift; sourceTree = ""; }; DBCAD22DB54E68077B842421 /* ScanProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanProgress.swift; sourceTree = ""; }; DDD56FD4714436315629F989 /* FileScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileScanner.swift; sourceTree = ""; }; - A1B2C3D4E5F600112233445B /* BulkDirectoryEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulkDirectoryEnumerator.swift; sourceTree = ""; }; - A1B2C3D4E5F600112233445D /* AtomicDirectorySummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicDirectorySummary.swift; sourceTree = ""; }; EE11223344556677889900EE /* FileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcher.swift; sourceTree = ""; }; - F6ADF08FD012926F8704A51C /* MacDirStat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacDirStat.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 1B5ADD782F265EBF4B1037EE /* FileTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree.swift; sourceTree = ""; }; - 9429B8A593856D8B12941F49 /* FileNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileNode.swift; sourceTree = ""; }; F03CE9327807EE8357D3678B /* FileNodeRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileNodeRecord.swift; sourceTree = ""; }; - 4FB7871D4D4A1E73B7984396 /* FileTreeBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeBuilder.swift; sourceTree = ""; }; - 5B0C663AD1A1417DA8FC6942 /* ScanArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanArchive.swift; sourceTree = ""; }; - 4542CEEA3CDE4292A6661596 /* ScanComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanComparison.swift; sourceTree = ""; }; - 1A060E7C6E1C405F81CE31FA /* ComparisonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparisonView.swift; sourceTree = ""; }; + F6ADF08FD012926F8704A51C /* DirStat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DirStat.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FDA0FDA0FDA0FDA0FDA00001 /* FullDiskAccessSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessSheet.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -111,7 +111,7 @@ 30772F61AC9BDC8631745269 /* Products */ = { isa = PBXGroup; children = ( - F6ADF08FD012926F8704A51C /* MacDirStat.app */, + F6ADF08FD012926F8704A51C /* DirStat.app */, ); name = Products; sourceTree = ""; @@ -134,14 +134,6 @@ path = Duplicates; sourceTree = ""; }; - A240638C83684A03A61256D5 /* Comparison */ = { - isa = PBXGroup; - children = ( - 1A060E7C6E1C405F81CE31FA /* ComparisonView.swift */, - ); - path = Comparison; - sourceTree = ""; - }; 71D2E56247FE75C3DCE9B609 = { isa = PBXGroup; children = ( @@ -199,6 +191,14 @@ path = Views; sourceTree = ""; }; + A240638C83684A03A61256D5 /* Comparison */ = { + isa = PBXGroup; + children = ( + 1A060E7C6E1C405F81CE31FA /* ComparisonView.swift */, + ); + path = Comparison; + sourceTree = ""; + }; A6644EB28BF6F3D30059D92F /* Shared */ = { isa = PBXGroup; children = ( @@ -299,6 +299,7 @@ AA0000000000000000000002 /* Frameworks */, 65FA6119905E087A5E53253D /* Sources */, 7997A58CB6F97DD5FFDE7900 /* Resources */, + A5C0DE0000000000000003AA /* Strip Sparkle (App Store) */, ); buildRules = ( ); @@ -309,7 +310,7 @@ SPARK002000000000000002AA /* Sparkle */, ); productName = MacDirStat; - productReference = F6ADF08FD012926F8704A51C /* MacDirStat.app */; + productReference = F6ADF08FD012926F8704A51C /* DirStat.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -360,6 +361,24 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + A5C0DE0000000000000003AA /* Strip Sparkle (App Store) */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Strip Sparkle (App Store)"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# ponytail: Sparkle is linked target-wide because SwiftPM product dependencies\n# cannot be scoped to a build configuration, so the App Store build strips the\n# embedded framework here instead. It has to go: Sparkle nests Updater.app and\n# XPC services inside its own framework, and nested bundles like those fail App\n# Store upload validation outright, on top of guideline 2.4.5 barring\n# self-updaters. The APPSTORE compilation condition removes every Sparkle call\n# and -Wl,-dead_strip_dylibs drops the now-unused load command, so nothing\n# references what this deletes. Upgrade path if it ever gets fiddly: a second\n# app target that never links Sparkle at all.\n[ \"$CONFIGURATION\" = AppStore ] || exit 0\n\nrm -rf \"$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH/Sparkle.framework\"\nrmdir \"$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH\" 2>/dev/null || true\n\n# The check, not just the comment: if the linker kept the load command, the\n# stripped app would crash on launch. Fail here rather than at App Review.\nif otool -L \"$TARGET_BUILD_DIR/$EXECUTABLE_PATH\" | grep -q Sparkle; then\n\techo \"error: App Store executable still links Sparkle.framework\"\n\texit 1\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 65FA6119905E087A5E53253D /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -414,7 +433,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = L4UH9K7AW4; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; @@ -425,7 +444,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.macdirstat.app; PRODUCT_NAME = DirStat; SWIFT_EMIT_LOC_STRINGS = YES; @@ -442,7 +461,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = L4UH9K7AW4; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; @@ -453,7 +472,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.macdirstat.app; PRODUCT_NAME = DirStat; SWIFT_EMIT_LOC_STRINGS = YES; @@ -461,6 +480,94 @@ }; name = Debug; }; + A5C0DE0000000000000001AA /* AppStore */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "MacDirStat/MacDirStat-AppStore.entitlements"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 12; + DEVELOPMENT_TEAM = L4UH9K7AW4; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "MacDirStat/Info-AppStore.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.4.1; + OTHER_LDFLAGS = ( + "$(inherited)", + "-Wl,-dead_strip_dylibs", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.macdirstat.app; + PRODUCT_NAME = DirStat; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APPSTORE $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = AppStore; + }; + A5C0DE0000000000000002AA /* AppStore */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = AppStore; + }; B74382A4D110BEFC1DEB55E4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -587,6 +694,7 @@ buildConfigurations = ( B74382A4D110BEFC1DEB55E4 /* Debug */, F901A40D62D95EAB754CE15B /* Release */, + A5C0DE0000000000000002AA /* AppStore */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -596,6 +704,7 @@ buildConfigurations = ( 683105B965893FCC643C4ECF /* Debug */, 4DC7B479A29173674AFD7BAC /* Release */, + A5C0DE0000000000000001AA /* AppStore */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -620,7 +729,6 @@ productName = Sparkle; }; /* End XCSwiftPackageProductDependency section */ - }; rootObject = DB931C7330675EECD13E725C /* Project object */; } diff --git a/MacDirStat.xcodeproj/xcshareddata/xcschemes/DirStat (App Store).xcscheme b/MacDirStat.xcodeproj/xcshareddata/xcschemes/DirStat (App Store).xcscheme new file mode 100644 index 0000000..ffec06a --- /dev/null +++ b/MacDirStat.xcodeproj/xcshareddata/xcschemes/DirStat (App Store).xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MacDirStat.xcodeproj/xcshareddata/xcschemes/MacDirStat.xcscheme b/MacDirStat.xcodeproj/xcshareddata/xcschemes/MacDirStat.xcscheme new file mode 100644 index 0000000..824655d --- /dev/null +++ b/MacDirStat.xcodeproj/xcshareddata/xcschemes/MacDirStat.xcscheme @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MacDirStat/Info-AppStore.plist b/MacDirStat/Info-AppStore.plist new file mode 100644 index 0000000..d3ad9e5 --- /dev/null +++ b/MacDirStat/Info-AppStore.plist @@ -0,0 +1,43 @@ + + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + DirStat + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright © 2026 Ti-03. All rights reserved. + LSApplicationCategoryType + public.app-category.utilities + NSPrincipalClass + NSApplication + + ITSAppUsesNonExemptEncryption + + + diff --git a/MacDirStat/MacDirStat-AppStore.entitlements b/MacDirStat/MacDirStat-AppStore.entitlements new file mode 100644 index 0000000..ba71aa0 --- /dev/null +++ b/MacDirStat/MacDirStat-AppStore.entitlements @@ -0,0 +1,14 @@ + + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + + diff --git a/README.md b/README.md index 7762b70..a99f7cb 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ cd MacDirStat swift run ``` -Requires macOS 14+ and Xcode 15+. +The app itself runs on **macOS 13 Ventura or later**. Building from source +with SwiftPM needs macOS 14+ and Xcode 15+. ### Verify a release diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index 6924962..6962001 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -11,8 +11,15 @@ struct ContentView: View { @State private var showingSettings = false @AppStorage("defaultTab") private var defaultTab = "treemap" @AppStorage("treemapColorScheme") private var treemapColorScheme = "byType" + @AppStorage("useBinarySize") private var useBinarySize = false @Namespace private var tabNamespace + // `errorMessage` is the one channel every failure (scan, save, open, + // compare, export, trash) reports through; this is the one place it is shown. + private var errorAlertShown: Binding { + Binding(get: { vm.errorMessage != nil }, set: { if !$0 { vm.errorMessage = nil } }) + } + private var showTree: Bool { userWantsTree && !scanHidesTree } var body: some View { @@ -63,10 +70,13 @@ struct ContentView: View { } } - if vm.isScanning || vm.isComputingLayout { + if vm.isScanning || vm.isComputingLayout || vm.isComputingComparison { HStack(spacing: 6) { ProgressView().controlSize(.small) - if vm.isScanning { + if vm.isComputingComparison { + Text("Comparing…") + .font(.caption).foregroundStyle(.secondary) + } else if vm.isScanning { Text("\(vm.itemsScanned) items") .font(.caption).foregroundStyle(.secondary).monospacedDigit() Text(ByteFormatter.string(from: vm.bytesFound)) @@ -107,6 +117,12 @@ struct ContentView: View { if scanning { activeTab = .treemap } } .onChange(of: treemapColorScheme) { _ in vm.refreshLayout() } + .onChange(of: useBinarySize) { _ in vm.refreshLayout() } + .alert("Something Went Wrong", isPresented: errorAlertShown) { + Button("OK", role: .cancel) {} + } message: { + Text(vm.errorMessage ?? "") + } .onAppear { if defaultTab == "duplicates" { activeTab = .duplicates } } @@ -202,7 +218,14 @@ struct ContentView: View { if vm.isReadOnlySnapshot { SnapshotBanner(date: vm.snapshotDate) } - if !vm.hasFullDiskAccess || (vm.deniedCount > 0 && !vm.isScanning) { + // The banner's whole call to action is Full Disk Access, which a + // sandboxed App Store build can't use. Blocked folders there are a + // fact of the sandbox, not something the user can fix. Likewise, + // once the grant is held, whatever is still denied is root-only + // (/private/var/db, …) and no setting will open it, so telling the + // user to grant access again is wrong (issue #24). Denied folders + // keep their lock icon and the count shows in Settings. + if !Build.isAppStore, !vm.hasFullDiskAccess { FullDiskAccessBanner(hasFullDiskAccess: vm.hasFullDiskAccess, deniedCount: vm.deniedCount) { vm.showFDASheet = true } @@ -222,10 +245,13 @@ struct ContentView: View { VStack(spacing: 0) { if showTree { HSplitView { + // Wide enough that the name column keeps ~120 pt next to + // the fixed size/percent columns; at 220 names shrank to + // three letters. DirectoryTreeView() - .frame(minWidth: 220, idealWidth: 300) + .frame(minWidth: 290, idealWidth: 320) TreemapView() - .frame(minWidth: 300) + .frame(minWidth: 300, idealWidth: 640) } } else { TreemapView() @@ -476,8 +502,8 @@ private struct DashboardSettingsView: View { @EnvironmentObject private var vm: ScanViewModel @AppStorage("hapticFeedbackEnabled") private var hapticEnabled = true @AppStorage("useBinarySize") private var useBinarySize = false - @AppStorage("showHiddenFiles") private var showHiddenFiles = false - @AppStorage("excludedFolderNames") private var excludedFolderNames = ".git,node_modules,DerivedData,.Trash" + @AppStorage("showHiddenFiles") private var showHiddenFiles = ScanDefaults.showHiddenFiles + @AppStorage("excludedFolderNames") private var excludedFolderNames = ScanDefaults.excludedFolderNames @AppStorage("autoScanLastFolder") private var autoScanLastFolder = false @AppStorage("realtimeMonitoring") private var realtimeMonitoring = true @AppStorage("defaultTab") private var defaultTab = "treemap" @@ -530,7 +556,7 @@ private struct DashboardSettingsView: View { settingsSection("Files") { Toggle("Show hidden files", isOn: $showHiddenFiles) .toggleStyle(.switch).controlSize(.small) - caption("Include dot-files like .DS_Store and .git in scans.") + caption("Include dot-files and dot-folders (.git, .cache, .Trash, …). Turning this off makes totals smaller than Finder reports.") VStack(alignment: .leading, spacing: 4) { Text("Excluded folders") @@ -538,7 +564,7 @@ private struct DashboardSettingsView: View { TextField("Comma-separated names", text: $excludedFolderNames) .textFieldStyle(.roundedBorder) .font(.system(size: 11, design: .monospaced)) - caption("Folder names to skip during scanning.") + caption("Folder names to skip entirely. Skipped folders count as 0 bytes, so leave this empty for accurate totals; dependency and cache folders like node_modules are collapsed automatically instead.") } } @@ -552,6 +578,10 @@ private struct DashboardSettingsView: View { caption("Watch for file changes after scanning (uses FSEvents).") } + // Nothing in this section applies to a sandboxed App Store + // build: the grant can't be held, so the status light, the drag + // tile and the settings shortcut would all be dead ends. + if !Build.isAppStore { Divider() settingsSection("Permissions") { @@ -576,9 +606,10 @@ private struct DashboardSettingsView: View { if vm.hasFullDiskAccess && vm.deniedCount > 0 { caption("\(vm.deniedCount) folders were still unreadable in the last scan. If DirStat is already listed, remove it with “−” and drag this copy in again — macOS ties the grant to one exact copy.") } else { - caption("Without it, protected folders scan as 0 bytes and land in “Hidden & Unreadable Space”.") + caption("Without it, protected folders scan as 0 bytes and land in “System & Unreadable Space”.") } } + } Divider() @@ -604,6 +635,10 @@ private struct DashboardSettingsView: View { } Link("ti0.me", destination: URL(string: "http://ti0.me/")!) .font(.caption) + // App Review wants a reachable privacy policy, and it's + // worth having in the Developer ID build too. + Link("Privacy Policy", destination: URL(string: "https://ti-03.github.io/MacDirStat/privacy")!) + .font(.caption) } .frame(maxWidth: .infinity) .padding() diff --git a/Sources/App/MacDirStatApp.swift b/Sources/App/MacDirStatApp.swift index d76d2a1..5ef6aa0 100644 --- a/Sources/App/MacDirStatApp.swift +++ b/Sources/App/MacDirStatApp.swift @@ -1,15 +1,44 @@ import SwiftUI +#if !APPSTORE import Sparkle +#endif import UniformTypeIdentifiers +/// Which distribution channel this binary was built for. The `APPSTORE` +/// compilation condition is set only by the AppStore build configuration. +/// +/// App Store builds are sandboxed, which rules out two things the Developer ID +/// build relies on: Sparkle self-updates (App Review guideline 2.4.5) and Full +/// Disk Access (a sandboxed app can never hold it, so the onboarding flow would +/// send users to System Settings for nothing). +enum Build { + #if APPSTORE + static let isAppStore = true + #else + static let isAppStore = false + #endif +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + // The App Store build quits with its last window, matching how 1.0 shipped. + // The Developer ID build stays resident so its Sparkle updater keeps + // running, which is the whole point of that track. + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + Build.isAppStore + } +} + @main struct MacDirStatApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var vm = ScanViewModel() + #if !APPSTORE private let updaterController = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil ) + #endif var body: some Scene { WindowGroup("DirStat") { @@ -43,6 +72,7 @@ struct MacDirStatApp: App { NotificationCenter.default.post(name: .exportCSV, object: nil) } .keyboardShortcut("e", modifiers: [.command, .shift]) + .disabled(vm.tree == nil) Button("Compare With Saved Scan…") { compareWithSavedScanPicker(vm: vm) @@ -51,16 +81,20 @@ struct MacDirStatApp: App { .disabled(vm.tree == nil) } CommandGroup(after: .appInfo) { + #if !APPSTORE Button("Check for Updates…") { updaterController.checkForUpdates(nil) } + #endif Button("Visit Website") { NSWorkspace.shared.open(URL(string: "https://ti-03.github.io/MacDirStat/")!) } } CommandGroup(after: .help) { - Button("Grant Full Disk Access…") { - vm.showFDASheet = true + if !Build.isAppStore { + Button("Grant Full Disk Access…") { + vm.showFDASheet = true + } } } } diff --git a/Sources/Duplicates/DuplicateDetector.swift b/Sources/Duplicates/DuplicateDetector.swift index d271050..5a6cb19 100644 --- a/Sources/Duplicates/DuplicateDetector.swift +++ b/Sources/Duplicates/DuplicateDetector.swift @@ -16,9 +16,28 @@ public actor DuplicateDetector { // Operates over flat FileTree indices rather than an FSNode tree — a // simple loop over `tree.records` reaches every node without recursion, // since the array already covers the whole tree regardless of hierarchy. - public func detect(in tree: FileTree) async { + // + // Returns the group id (or nil = "not a duplicate") for every candidate + // it examined, or nil if cancelled. It never writes to `tree`: the caller + // applies the result with `FileTree.applyDuplicateGroups` on the main + // actor, so SwiftUI never reads a record while it is being mutated here. + // + // `focus`: after a live-refresh splice only the freshly rescanned + // subtree is new. Passing its indices restricts hashing to files whose + // size matches one of the new files (the only ones whose duplicate + // status can have changed), instead of re-hashing the whole tree on + // every FSEvents batch. Every member of any affected size bucket is + // included, so existing groups in that bucket are re-evaluated whole. + public func detect(in tree: FileTree, focusing focus: Set? = nil) async -> [Int: UUID?]? { var candidates: [Int] = [] collect(tree: tree, into: &candidates) + if let focus { + let sizes = Set(candidates.lazy.filter { focus.contains($0) }.map { tree.records[$0].size }) + candidates = candidates.filter { sizes.contains(tree.records[$0].size) } + } + var assignments: [Int: UUID?] = [:] + assignments.reserveCapacity(candidates.count) + for index in candidates { assignments[index] = .some(nil) } // Group by size first — eliminates the vast majority of files cheaply let bySize = Dictionary(grouping: candidates) { tree.records[$0].size } @@ -34,19 +53,22 @@ public actor DuplicateDetector { ) { index in Self.partialHash(url: FileNode(tree: tree, index: index).url, maxBytes: quickHashBytes) } - guard let quickHashResults else { return } + guard let quickHashResults else { return nil } for (index, qh) in quickHashResults { let key = "\(tree.records[index].size)-\(qh)" byQuickHash[key, default: []].append(index) } - // Small-file shortcut: if a file's whole content fits within the quick-hash window, - // the quick hash already IS a full-content hash, so those groups are final as-is. - // Only groups whose files exceed the quick-hash window need a full-file hash pass. + // Small-file shortcut: when the quick hash reached end-of-file, it already + // IS a full-content hash, so that group is final. Whether EOF was hit is + // part of the hash key (see `partialHash`), never inferred from + // `record.size`: that field is the ALLOCATED size, and a sparse or + // APFS-compressed file can occupy a few KB on disk while holding + // megabytes of content that differ after the first 64 KB. var hashGroups: [String: [Int]] = [:] var toFullHash: [Int] = [] for (key, indices) in byQuickHash where indices.count > 1 { - if tree.records[indices[0]].size <= Int64(quickHashBytes) { + if key.hasSuffix(Self.reachedEOFMarker) { hashGroups[key] = indices } else { toFullHash.append(contentsOf: indices) @@ -59,7 +81,7 @@ public actor DuplicateDetector { let fullHashResults: [(Int, String)]? = await Self.hashInParallel(indices: toFullHash) { index in Self.fullHash(url: FileNode(tree: tree, index: index).url) } - guard let fullHashResults else { return } + guard let fullHashResults else { return nil } for (index, hash) in fullHashResults { let key = "\(tree.records[index].size)-\(hash)" hashGroups[key, default: []].append(index) @@ -68,10 +90,11 @@ public actor DuplicateDetector { // Assign group IDs to genuine duplicates for (_, indices) in hashGroups where indices.count > 1 { - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return nil } let groupID = UUID() - for index in indices { tree.setDuplicateGroupID(groupID, at: index) } + for index in indices { assignments[index] = groupID } } + return assignments } private func collect(tree: FileTree, into list: inout [Int]) { @@ -133,7 +156,11 @@ public actor DuplicateDetector { return results } - // Reads up to `maxBytes` from the file and returns a SHA256 hex string. + static let reachedEOFMarker = "-eof" + + // Reads up to `maxBytes` from the file and returns a SHA256 hex string, + // suffixed with `reachedEOFMarker` when the whole file fit in the window + // (so the caller knows the partial hash is in fact a full-content hash). // Each chunk is drained from the autorelease pool immediately to prevent accumulation. private static func partialHash(url: URL, maxBytes: Int) -> String? { guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } @@ -141,15 +168,23 @@ public actor DuplicateDetector { var hasher = SHA256() var remaining = maxBytes let chunkSize = min(65_536, maxBytes) + var reachedEOF = false while remaining > 0 { if Task.isCancelled { return nil } let toRead = min(chunkSize, remaining) let chunk: Data? = autoreleasepool { try? handle.read(upToCount: toRead) } - guard let chunk, !chunk.isEmpty else { break } + guard let chunk, !chunk.isEmpty else { reachedEOF = true; break } hasher.update(data: chunk) remaining -= chunk.count } - return hasher.finalize().map { String(format: "%02x", $0) }.joined() + if !reachedEOF { + // Exactly filled the window: peek one byte to tell "file is exactly + // maxBytes long" apart from "there is more". + let probe: Data? = autoreleasepool { try? handle.read(upToCount: 1) } + reachedEOF = (probe?.isEmpty ?? true) + } + let hex = hasher.finalize().map { String(format: "%02x", $0) }.joined() + return reachedEOF ? hex + reachedEOFMarker : hex } // Full-file SHA256. Each 1 MB chunk is released immediately via autoreleasepool, diff --git a/Sources/Layout/TreemapLayout.swift b/Sources/Layout/TreemapLayout.swift index e1fd1db..420a199 100644 --- a/Sources/Layout/TreemapLayout.swift +++ b/Sources/Layout/TreemapLayout.swift @@ -16,7 +16,13 @@ public struct TreemapLayout { static let centerRadius: CGFloat = 76 private static let maxDepth = 5 - private static let minArcAngle: Double = 0.018 + // Arcs thinner than this at their outer edge are skipped (their angle + // is still consumed so siblings keep their true proportions). Angle-based + // thresholds made any folder with ~350+ similar-size children render as + // an empty chart; a length threshold only drops arcs that can't be seen. + // ponytail: sub-pixel siblings are dropped rather than pooled into an + // "Other" arc; add pooling if a huge flat folder ever looks sparse. + private static let minArcLength: CGFloat = 0.75 // Gentle per-depth darkening so deep files stay recognisable private static let depthDarken: [Double] = [0.0, 0.10, 0.18, 0.26, 0.32, 0.38] @@ -66,7 +72,7 @@ public struct TreemapLayout { let arcAngle = fraction * totalAngle let arcEnd = angle + arcAngle - guard arcAngle >= minArcAngle else { angle = arcEnd; continue } + guard CGFloat(arcAngle) * outerR >= minArcLength else { angle = arcEnd; continue } let cellColor = color(for: child, depth: depth, colorMap: colorMap) diff --git a/Sources/Model/FileNode.swift b/Sources/Model/FileNode.swift index 86246b7..ced41c4 100644 --- a/Sources/Model/FileNode.swift +++ b/Sources/Model/FileNode.swift @@ -48,6 +48,22 @@ public struct FileNode: Identifiable, Hashable, Sendable { return (0.. 0 + } + + // "12,431 files, summarized" / "8 items" for tooltips and rows. + public var itemCountLabel: String { + if isAutoSummarized { + return "\(descendantFileCount.formatted()) files, summarized" + } + let count = tree.childCount[index] + return count == 1 ? "1 item" : "\(count.formatted()) items" + } + public var optionalChildren: [FileNode]? { guard isDirectory, tree.childCount[index] > 0 else { return nil } return children diff --git a/Sources/Model/FileTree.swift b/Sources/Model/FileTree.swift index 15245cf..ddbbcfd 100644 --- a/Sources/Model/FileTree.swift +++ b/Sources/Model/FileTree.swift @@ -60,6 +60,14 @@ public final class FileTree: @unchecked Sendable { records[index].duplicateGroupID = id } + // Applies a `DuplicateDetector.detect` result in one pass. Call on the + // main actor so no view reads `records` mid-write. + public func applyDuplicateGroups(_ assignments: [Int: UUID?]) { + for (index, id) in assignments where index < records.count { + records[index].duplicateGroupID = id + } + } + public func setSafety(_ level: SafetyLevel, at index: Int) { records[index].safetyLevel = level } diff --git a/Sources/Model/ScanComparison.swift b/Sources/Model/ScanComparison.swift index 03e2e13..f698e25 100644 --- a/Sources/Model/ScanComparison.swift +++ b/Sources/Model/ScanComparison.swift @@ -50,6 +50,25 @@ public enum ScanComparison { let beforeIndex = relativePathIndex(of: before) let afterIndex = relativePathIndex(of: after) + // A directory auto-summarized on EITHER side has no materialized + // children there, so its descendants on the other side would all + // read as added/removed even though nothing under it changed. Those + // descendants are suppressed and the directory itself is reported as + // one grew/shrank row instead (directories are otherwise never + // reported for size changes, see below). + var summarizedDirs = Set() + for (path, i) in beforeIndex where before.records[i].isAutoSummarized { summarizedDirs.insert(path) } + for (path, i) in afterIndex where after.records[i].isAutoSummarized { summarizedDirs.insert(path) } + func underSummarizedDirectory(_ relativePath: String) -> Bool { + guard !summarizedDirs.isEmpty else { return false } + var current = parentRelativePath(of: relativePath) + while let path = current { + if summarizedDirs.contains(path) { return true } + current = parentRelativePath(of: path) + } + return false + } + var changes: [ScanChange] = [] // Added: present in `after`, absent from `before`. Collapsed to the @@ -59,7 +78,7 @@ public enum ScanComparison { // is skipped here, since it's implied by the directory's own // "added" row, exactly like Radix's diff-row suppression. for (relativePath, afterNodeIndex) in afterIndex { - guard beforeIndex[relativePath] == nil else { continue } + guard beforeIndex[relativePath] == nil, !underSummarizedDirectory(relativePath) else { continue } // The parent counts as pre-existing only if it was there AND was // already a directory. If it was a *file* that has since become a // directory, everything now inside it is new content implied by @@ -84,7 +103,7 @@ public enum ScanComparison { // present in `after`) — only the topmost removed node per subtree is // reported. for (relativePath, beforeNodeIndex) in beforeIndex { - guard afterIndex[relativePath] == nil else { continue } + guard afterIndex[relativePath] == nil, !underSummarizedDirectory(relativePath) else { continue } // Mirror of the added-side rule: if the parent is now a file where // it used to be a directory, this node's disappearance is implied // by the parent's "replaced" row. @@ -132,12 +151,14 @@ public enum ScanComparison { continue } - guard !beforeRecord.isDirectory, !afterRecord.isDirectory else { continue } + if underSummarizedDirectory(relativePath) { continue } + let summarizedHere = beforeRecord.isAutoSummarized || afterRecord.isAutoSummarized + guard !beforeRecord.isDirectory || summarizedHere else { continue } guard beforeRecord.size != afterRecord.size else { continue } changes.append(ScanChange( relativePath: relativePath, name: afterRecord.name, - isDirectory: false, + isDirectory: afterRecord.isDirectory, kind: afterRecord.size > beforeRecord.size ? .grew : .shrank, beforeSize: beforeRecord.size, afterSize: afterRecord.size diff --git a/Sources/Safety/SafetyAnalyzer.swift b/Sources/Safety/SafetyAnalyzer.swift index 31c9c2f..7eae48c 100644 --- a/Sources/Safety/SafetyAnalyzer.swift +++ b/Sources/Safety/SafetyAnalyzer.swift @@ -28,7 +28,7 @@ public struct SafetyAnalyzer { level(path: node.url.path, name: node.name, isSynthetic: node.isSynthetic) } - private static func level(path: String, name: String, isSynthetic: Bool) -> SafetyLevel { + static func level(path: String, name: String, isSynthetic: Bool) -> SafetyLevel { // Synthetic nodes (e.g. the hidden-space reconciliation entry) don't point // at a real, deletable file — always treat them as the most protective // level so nothing ever attempts to trash/move them. diff --git a/Sources/Scanner/AtomicDirectorySummary.swift b/Sources/Scanner/AtomicDirectorySummary.swift index e9a14f2..639b553 100644 --- a/Sources/Scanner/AtomicDirectorySummary.swift +++ b/Sources/Scanner/AtomicDirectorySummary.swift @@ -16,10 +16,15 @@ import Foundation // summarizing a huge subtree is a scan-speed win too, not just a node-count // win. -// Directory names that are, by convention, generated dependency/package -// trees not worth browsing file-by-file. Matched against a directory's own -// name; bypasses the depth gate below. -private let knownGeneratedDirectoryNames: Set = ["node_modules"] +// Directory names that are, by convention, generated dependency/package/ +// cache trees not worth browsing file-by-file. Matched against a directory's +// own name; bypasses the depth gate below. Only names that are unambiguous +// belong here: a user's own folder called "build" or "target" must never be +// collapsed. Shared with ScanViewModel's splice-refresh guard. +let knownGeneratedDirectoryNames: Set = [ + "node_modules", ".git", "Pods", ".next", ".nuxt", ".venv", "venv", + "__pycache__", ".gradle", ".build", ".cargo", ".pnpm-store", ".yarn", +] enum AtomicSummaryThresholds { // Overridable via env var so tests can exercise the general heuristic diff --git a/Sources/Scanner/FileScanner.swift b/Sources/Scanner/FileScanner.swift index f990c23..ddcdd32 100644 --- a/Sources/Scanner/FileScanner.swift +++ b/Sources/Scanner/FileScanner.swift @@ -62,6 +62,23 @@ private final class ProgressCounter: @unchecked Sendable { } } +// Factory defaults for the user-tunable scan settings. Single source of truth +// for the registered defaults, the Settings UI, and the scanner itself. +// +// Nothing is excluded and dotfiles are counted: a disk-usage tool that skips +// `.git`, `node_modules`, `DerivedData` and every hidden folder by default +// reported a real 7.3 GB project as 40 MB. Generated trees are collapsed by +// auto-summarization (see `knownGeneratedDirectoryNames`) instead of dropped, +// so the total stays right and the treemap stays browsable. +enum ScanDefaults { + static let excludedFolderNames = "" + static let showHiddenFiles = true + + static func parseExcludedNames(_ raw: String) -> Set { + Set(raw.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }) + } +} + // Snapshot of scan-time settings, read once per scan (not per directory) to avoid // UserDefaults / environment overhead on the hot path. struct ScanConfig: Sendable { @@ -77,10 +94,11 @@ struct ScanConfig: Sendable { let autoSummarizeEnabled: Bool static func loadFromUserDefaults() -> ScanConfig { - let rawExcluded = UserDefaults.standard.string(forKey: "excludedFolderNames") - ?? ".git,node_modules,DerivedData,.Trash" - let excludedNames = Set(rawExcluded.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }) - let showHiddenFiles = UserDefaults.standard.bool(forKey: "showHiddenFiles") + let rawExcluded = UserDefaults.standard.string(forKey: "excludedFolderNames") ?? ScanDefaults.excludedFolderNames + let excludedNames = ScanDefaults.parseExcludedNames(rawExcluded) + let showHiddenFiles = UserDefaults.standard.object(forKey: "showHiddenFiles") == nil + ? ScanDefaults.showHiddenFiles + : UserDefaults.standard.bool(forKey: "showHiddenFiles") let forceFallbackEnum = ProcessInfo.processInfo.environment["MDS_FORCE_FALLBACK_ENUM"] == "1" // Default-true feature flag: absent key means "on" (unlike the other // UserDefaults-backed flags above, which default to false/absent-Bool). @@ -103,6 +121,10 @@ public actor FileScanner { public func scan(url: URL) -> AsyncStream { activeTask?.cancel() let (stream, continuation) = AsyncStream.makeStream() + // A symlinked root (a Finder alias folder, /tmp, /var, ...) is scanned + // as the directory it points to. Every subdirectory keeps the + // no-follow rule; only the root the user explicitly chose is resolved. + let url = Self.resolvingSymlinkRoot(url) activeTask = Task { let counter = ProgressCounter() @@ -135,6 +157,19 @@ public actor FileScanner { } } +extension FileScanner { + // realpath(3) rather than URL.resolvingSymlinksInPath(), which also strips + // the /private prefix and would hand back a path that differs from what + // FSEvents later reports for the same directory. + nonisolated static func resolvingSymlinkRoot(_ url: URL) -> URL { + var st = stat() + guard lstat(url.path, &st) == 0, st.st_mode & S_IFMT == S_IFLNK else { return url } + guard let resolved = realpath(url.path, nil) else { return url } + defer { free(resolved) } + return URL(fileURLWithPath: String(cString: resolved), isDirectory: true) + } +} + // MARK: - Iterative work-stack tree builder (free functions, not actor-isolated) // // Concurrency invariant: a directory's `FSNode.children` array is written by diff --git a/Sources/ViewModels/ScanViewModel.swift b/Sources/ViewModels/ScanViewModel.swift index 7e95fca..24962ff 100644 --- a/Sources/ViewModels/ScanViewModel.swift +++ b/Sources/ViewModels/ScanViewModel.swift @@ -58,16 +58,22 @@ public final class ScanViewModel: ObservableObject { private var securityScopedURL: URL? private var memoryPressureSource: DispatchSourceMemoryPressure? private var fdaSheetShownThisLaunch = false + // Bumped by every scan(), openArchive() and cancelScan(). Work that resumes + // on the main actor after an await compares against it, so a finished + // scan A can't install its tree over scan B that started meanwhile, and a + // slow archive decode can't flip a newer live scan into a read-only snapshot. + private var scanGeneration = 0 public init() { + Self.migrateLegacyExclusionDefault(in: UserDefaults.standard) UserDefaults.standard.register(defaults: [ "realtimeMonitoring": true, "autoScanLastFolder": false, - "showHiddenFiles": false, + "showHiddenFiles": ScanDefaults.showHiddenFiles, "useBinarySize": false, "treemapColorScheme": "byType", "showFileCount": false, - "excludedFolderNames": ".git,node_modules,DerivedData,.Trash", + "excludedFolderNames": ScanDefaults.excludedFolderNames, "defaultTab": "treemap", ]) setupMemoryPressureHandler() @@ -87,20 +93,106 @@ public final class ScanViewModel: ObservableObject { if !resumedPendingRescan, UserDefaults.standard.bool(forKey: "autoScanLastFolder"), - let path = UserDefaults.standard.string(forKey: "lastScannedPath"), - FileManager.default.fileExists(atPath: path) { - scan(url: URL(fileURLWithPath: path)) + let last = Self.resolveLastScannedFolder() { + scan(url: last) } } + // MARK: - Legacy defaults migration + + /// Releases up to 1.3 shipped `.git,node_modules,DerivedData,.Trash` as the + /// factory exclusion list and "hide hidden files", and persisted both into + /// the preferences file, so a new factory default alone never reaches + /// existing installs. Each step runs once: it resets exactly the legacy + /// value (a customised exclusion list is left alone) and, for hidden files, + /// the legacy `false`, since with 1.3's defaults that value is + /// indistinguishable from "never touched". A user who wants dot-files + /// hidden again flips the toggle once more. + nonisolated static func migrateLegacyExclusionDefault(in defaults: UserDefaults) { + let versionKey = "legacyDefaultsMigration" + // Step 1 shipped under a Bool flag before this became versioned. + var done = defaults.integer(forKey: versionKey) + if done == 0, defaults.bool(forKey: "legacyExclusionDefaultMigrated") { done = 1 } + + if done < 1, defaults.string(forKey: "excludedFolderNames") == ".git,node_modules,DerivedData,.Trash" { + defaults.removeObject(forKey: "excludedFolderNames") + } + if done < 2, defaults.object(forKey: "showHiddenFiles") != nil, !defaults.bool(forKey: "showHiddenFiles") { + defaults.removeObject(forKey: "showHiddenFiles") + } + defaults.set(2, forKey: versionKey) + } + + // MARK: - Last scanned folder + + static let lastScannedBookmarkKey = "lastScannedBookmark" + + /// A sandboxed build loses access to a folder the moment the process exits, so + /// "auto-scan last folder" needs a security-scoped bookmark, not a bare path. + /// Must be called while the security scope for `url` is still held. + static func rememberLastScannedFolder(_ url: URL) { + UserDefaults.standard.set(url.path, forKey: "lastScannedPath") + guard Build.isAppStore else { return } + if let data = try? url.bookmarkData(options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil) { + UserDefaults.standard.set(data, forKey: lastScannedBookmarkKey) + } else { + UserDefaults.standard.removeObject(forKey: lastScannedBookmarkKey) + } + } + + /// The folder to resume on launch, or nil if there isn't a usable one. + static func resolveLastScannedFolder() -> URL? { + guard Build.isAppStore else { + guard let path = UserDefaults.standard.string(forKey: "lastScannedPath"), + FileManager.default.fileExists(atPath: path) else { return nil } + return URL(fileURLWithPath: path) + } + guard let data = UserDefaults.standard.data(forKey: lastScannedBookmarkKey) else { return nil } + var stale = false + // A stale bookmark still resolves; scan(url:) records a fresh one on the + // way through, so there's nothing to repair here. + guard let url = try? URL(resolvingBookmarkData: data, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &stale) else { + UserDefaults.standard.removeObject(forKey: lastScannedBookmarkKey) + return nil + } + return url + } + deinit { memoryPressureSource?.cancel() } private func checkFullDiskAccess() { - // TCC.db is only readable when Full Disk Access is granted - let probe = "/Library/Application Support/com.apple.TCC/TCC.db" - hasFullDiskAccess = FileManager.default.isReadableFile(atPath: probe) + // A sandboxed App Store build can never hold Full Disk Access, so the + // probe would always fail and the app would nag about a permission the + // user cannot grant. Report satisfied and let the open panel be the + // only way in. + guard !Build.isAppStore else { + hasFullDiskAccess = true + return + } + hasFullDiskAccess = Self.probeFullDiskAccess() + } + + /// Ground truth is whether a TCC-protected file actually opens, because that + /// is exactly what the scanner will hit. `access(2)` (isReadableFile) is not + /// the same check the sandbox applies to `open(2)`, and either TCC database + /// alone can be missing or unreadable for reasons unrelated to the grant. + nonisolated static func probeFullDiskAccess() -> Bool { + let probes = [ + "/Library/Application Support/com.apple.TCC/TCC.db", + NSHomeDirectory() + "/Library/Application Support/com.apple.TCC/TCC.db", + ] + for path in probes { + let fd = open(path, O_RDONLY | O_CLOEXEC) + if fd >= 0 { close(fd); return true } + } + return false } /// Public wrapper so the onboarding sheet can poll for a live permission change @@ -170,8 +262,10 @@ public final class ScanViewModel: ObservableObject { isWatching = false hasStaleResults = false layoutGeneration += 1 // invalidate any in-progress layout + scanGeneration += 1 + let generation = scanGeneration scanURL = url - UserDefaults.standard.set(url.path, forKey: "lastScannedPath") + Self.rememberLastScannedFolder(url) isReadOnlySnapshot = false snapshotDate = nil tree = nil @@ -231,12 +325,15 @@ public final class ScanViewModel: ObservableObject { await Task.detached(priority: .userInitiated) { Self.tagSafetyLevels(tree: finalTree) }.value + // A newer scan/archive/cancel superseded this one while tagging ran. + guard self.scanGeneration == generation else { return } self.tree = finalTree let rootNode = FileNode(tree: finalTree, index: finalTree.rootIndex) let map = ExtensionColorMap(root: rootNode) self.colorMap = map await self.recomputeLayout() // isComputingLayout set to false inside recomputeLayout + guard self.scanGeneration == generation else { return } // Start live file watching (if enabled) if UserDefaults.standard.bool(forKey: "realtimeMonitoring") { @@ -251,18 +348,7 @@ public final class ScanViewModel: ObservableObject { await MainActor.run { vm?.extensionSummaries = summaries } } // Duplicate detection: lower priority, also off main actor - self.duplicateTask = Task.detached(priority: .utility) { [finalTree, weak self] in - let detector = DuplicateDetector() - await detector.detect(in: finalTree) - guard !Task.isCancelled else { return } - let groups = Self.buildDuplicateGroups(tree: finalTree) - guard !Task.isCancelled else { return } - let vm = self - await MainActor.run { - vm?.duplicatesReady = true - vm?.duplicateGroups = groups - } - } + self.runDuplicateDetection(on: finalTree) case .failed(let msg): self.errorMessage = msg self.isScanning = false @@ -274,6 +360,7 @@ public final class ScanViewModel: ObservableObject { public func cancelScan() { Task { await scanner.cancel() } + scanGeneration += 1 scanTask?.cancel() extensionTask?.cancel() duplicateTask?.cancel() @@ -356,18 +443,31 @@ public final class ScanViewModel: ObservableObject { return } - var workingTree = startingTree - var skippedUnspliceable = false - for changedPath in paths { - guard let spliced = Self.splicedTree(afterChangeAt: changedPath, in: workingTree) else { - if ProcessInfo.processInfo.environment["MDS_DEBUG_TREE"] != nil { - FileHandle.standardError.write("REFRESH skipped-unspliceable path=\(changedPath)\n".data(using: .utf8)!) + // Each splice re-walks the changed directory on disk and copies the + // whole record array; on a large tree with a build running that is + // hundreds of milliseconds per FSEvents batch, so it runs off the main + // actor. Everything it needs is immutable (`splicedTree` is a + // nonisolated static over a FileTree whose topology never changes). + let debug = ProcessInfo.processInfo.environment["MDS_DEBUG_TREE"] != nil + let (workingTree, skippedUnspliceable) = await Task.detached(priority: .utility) { () -> (FileTree, Bool) in + var working = startingTree + var skipped = false + for changedPath in paths { + guard let spliced = Self.splicedTree(afterChangeAt: changedPath, in: working) else { + if debug { + FileHandle.standardError.write("REFRESH skipped-unspliceable path=\(changedPath)\n".data(using: .utf8)!) + } + skipped = true + continue } - skippedUnspliceable = true - continue + working = spliced } - workingTree = spliced - } + return (working, skipped) + }.value + + // A trash, a new scan or an opened archive replaced `tree` meanwhile: + // this result was derived from a tree that is no longer on screen. + guard tree === startingTree else { return } if skippedUnspliceable { hasStaleResults = true } @@ -376,7 +476,7 @@ public final class ScanViewModel: ObservableObject { if ProcessInfo.processInfo.environment["MDS_DEBUG_TREE"] != nil { FileHandle.standardError.write("REFRESH spliced changedPaths=\(paths.count) root=\(scanURL.path)\n".data(using: .utf8)!) } - await applySplicedTree(workingTree, from: startingTree) + await applySplicedTree(workingTree, from: startingTree, changedPaths: paths) } // Rescans exactly the on-disk directory at `changedPath` (a full, @@ -424,7 +524,7 @@ public final class ScanViewModel: ObservableObject { guard !tree.records[index].isAutoSummarized else { return nil } let node = FileNode(tree: tree, index: index) - let showHiddenFiles = UserDefaults.standard.bool(forKey: "showHiddenFiles") + let showHiddenFiles = ScanConfig.loadFromUserDefaults().showHiddenFiles let excludedNames = parseExcludedNames() // Cross-tree hardlink dedup (BUG 2 fix): `scanSubtree`'s own @@ -470,7 +570,7 @@ public final class ScanViewModel: ObservableObject { seenRefs: &seenRefs ) - guard freshNode.name != "node_modules", !containsUnsummarizedGeneratedDirectory(freshNode) else { return nil } + guard !knownGeneratedDirectoryNames.contains(freshNode.name), !containsUnsummarizedGeneratedDirectory(freshNode) else { return nil } let subtree = FileTreeBuilder.build(from: freshNode, rootPath: node.url.path) var result = tree.replacingSubtree(at: index, with: subtree) @@ -555,7 +655,7 @@ public final class ScanViewModel: ObservableObject { // See the last bullet of `splicedTree`'s doc comment above. private nonisolated static func containsUnsummarizedGeneratedDirectory(_ node: FSNode) -> Bool { for child in node.children where child.isDirectory { - if child.name == "node_modules" || containsUnsummarizedGeneratedDirectory(child) { + if knownGeneratedDirectoryNames.contains(child.name) || containsUnsummarizedGeneratedDirectory(child) { return true } } @@ -580,7 +680,7 @@ public final class ScanViewModel: ObservableObject { // anything else touches `newTree.records`: it and `DuplicateDetector` // both mutate that array in place, so — same reasoning as `scan(url:)` — // they can't be allowed to run concurrently with each other. - private func applySplicedTree(_ newTree: FileTree, from oldTree: FileTree) async { + private func applySplicedTree(_ newTree: FileTree, from oldTree: FileTree, changedPaths: [String]) async { let selectedPath = selectedNode.map { oldTree.path(of: $0.index) } let drillPaths = drillStack.map { oldTree.path(of: $0.index) } @@ -588,6 +688,9 @@ public final class ScanViewModel: ObservableObject { Self.tagSafetyLevels(tree: newTree) }.value + // A trash or a new scan replaced the tree while tagging ran; installing + // this splice would resurrect what the user just deleted. + guard tree === oldTree else { return } self.tree = newTree if let selectedPath, let idx = Self.findIndex(forPath: selectedPath, in: newTree) { @@ -614,21 +717,47 @@ public final class ScanViewModel: ObservableObject { await MainActor.run { self?.extensionSummaries = summaries } } + // Only the rescanned subtrees are new; hash just the size buckets + // they touch instead of the whole tree on every FSEvents batch. If + // detection had not finished yet there is nothing to build on, so + // run it in full. + var focus: Set? = nil + if duplicatesReady { + var indices = Set() + for path in changedPaths { + let normalized = (path.hasSuffix("/") && path != "/") ? String(path.dropLast()) : path + if let start = Self.findIndex(forPath: normalized, in: newTree) { + var stack = [start] + while let i = stack.popLast() { + indices.insert(i) + let s = newTree.childStart[i], c = newTree.childCount[i] + for offset in 0..? = nil) { duplicateTask?.cancel() duplicateTask = Task.detached(priority: .utility) { [weak self] in - let detector = DuplicateDetector() - await detector.detect(in: newTree) + guard let assignments = await DuplicateDetector().detect(in: tree, focusing: focus) else { return } guard !Task.isCancelled else { return } - let groups = Self.buildDuplicateGroups(tree: newTree) + await MainActor.run { tree.applyDuplicateGroups(assignments) } + let groups = Self.buildDuplicateGroups(tree: tree) guard !Task.isCancelled else { return } await MainActor.run { self?.duplicatesReady = true self?.duplicateGroups = groups } } - - isComputingLayout = true - await recomputeLayout() } // Walk the tree by path components to find the FSNode for a given path. @@ -681,9 +810,7 @@ public final class ScanViewModel: ObservableObject { // Parses the excludedFolderNames default the same way FileScanner does. private nonisolated static func parseExcludedNames() -> Set { - let raw = UserDefaults.standard.string(forKey: "excludedFolderNames") - ?? ".git,node_modules,DerivedData,.Trash" - return Set(raw.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }) + ScanConfig.loadFromUserDefaults().excludedNames } // Re-stat the directory on disk and update children to match. @@ -692,7 +819,7 @@ public final class ScanViewModel: ObservableObject { nonisolated static func refreshDirectory(node: FSNode) -> Bool { guard node.isDirectory else { return false } let fm = FileManager.default - let showHiddenFiles = UserDefaults.standard.bool(forKey: "showHiddenFiles") + let showHiddenFiles = ScanConfig.loadFromUserDefaults().showHiddenFiles let excludedNames = parseExcludedNames() guard let entries = try? fm.contentsOfDirectory( at: node.url, @@ -861,6 +988,12 @@ public final class ScanViewModel: ObservableObject { return hidden >= oneGB ? hidden : nil } + // What the gap on a volume scan actually is: other volumes in the same + // APFS container (Preboot, Recovery, VM), snapshot and purgeable space, + // filesystem metadata, and anything the process was denied. None of it + // is hidden files, which are scanned. + static let syntheticSpaceNodeName = "System & Unreadable Space" + // When the scanned URL is itself a volume's mount point, returns a NEW // tree with a synthetic "Hidden & Unreadable Space" child representing // the portion of the volume's used space the scanner could never @@ -873,9 +1006,15 @@ public final class ScanViewModel: ObservableObject { volumeURL.standardizedFileURL.path == scannedURL.standardizedFileURL.path else { return nil } - guard let volumeValues = try? scannedURL.resourceValues(forKeys: [.volumeTotalCapacityKey, .volumeAvailableCapacityKey]), + // "Important usage" availability is the figure Finder and Storage + // settings show: it treats purgeable space (local Time Machine + // snapshots, caches the system will evict) as free. Using the plain + // available figure instead reported all of that purgeable space as + // "unreadable", which on a typical Mac is tens of GB that no + // permission grant could ever make visible (issue #24). + guard let volumeValues = try? scannedURL.resourceValues(forKeys: [.volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey]), let totalCapacity = volumeValues.volumeTotalCapacity, - let availableCapacity = volumeValues.volumeAvailableCapacity + let availableCapacity = volumeValues.volumeAvailableCapacityForImportantUsage ?? volumeValues.volumeAvailableCapacity.map(Int64.init) else { return nil } guard let hidden = hiddenSpaceBytes( @@ -884,7 +1023,7 @@ public final class ScanViewModel: ObservableObject { scannedTotal: tree.records[tree.rootIndex].size ) else { return nil } - return tree.appendingSyntheticRootChild(name: "Hidden & Unreadable Space", size: hidden) + return tree.appendingSyntheticRootChild(name: Self.syntheticSpaceNodeName, size: hidden) } // Walk up the parent chain recalculating folder sizes from their children. @@ -912,7 +1051,9 @@ public final class ScanViewModel: ObservableObject { } public func drillDown(into node: FileNode) { - guard node.isDirectory else { return } + // Auto-summarized folders have no materialized children: drilling in + // would show an empty chart with no way back, so select instead. + guard node.isDrillable else { select(node); return } drillStack.append(node) Task { await recomputeLayout() } } @@ -932,9 +1073,18 @@ public final class ScanViewModel: ObservableObject { } public func refreshLayout() { - guard let root else { return } - colorMap = ExtensionColorMap(root: root) + guard let root, let currentTree = tree else { return } + let map = ExtensionColorMap(root: root) + colorMap = map Task { await recomputeLayout() } + // The legend bakes each extension's color in, so a color-scheme change + // must rebuild it too, not just the chart. + extensionTask?.cancel() + extensionTask = Task.detached(priority: .userInitiated) { [weak self] in + let summaries = Self.buildExtensionSummaries(tree: currentTree, map: map) + guard !Task.isCancelled else { return } + await MainActor.run { self?.extensionSummaries = summaries } + } } // MARK: - Move to Trash (prune-in-place, no rescan) @@ -957,25 +1107,63 @@ public final class ScanViewModel: ObservableObject { // the wrong (current, possibly since-changed) file or silently no-op, // neither of which is acceptable, so this is a hard no-op while a // snapshot is loaded. + // Returns whether a trash operation was started (false for a read-only + // snapshot, an empty batch, or nodes that no longer resolve). The moves + // themselves run off the main actor: "Delete All Duplicates" over + // thousands of files used to freeze the UI for the whole loop. The tree + // is pruned when they finish. @discardableResult public func trashNodes(_ nodes: [FileNode]) -> Bool { guard !isReadOnlySnapshot, let currentTree = tree, !nodes.isEmpty else { return false } - var trashedIndices: [Int] = [] - trashedIndices.reserveCapacity(nodes.count) - for node in nodes where ObjectIdentifier(node.tree) == ObjectIdentifier(currentTree) { - do { - try FileManager.default.trashItem(at: node.url, resultingItemURL: nil) - trashedIndices.append(node.index) - } catch { - // Swallow, same as the old per-call-site `try?` behavior — - // one failed delete (e.g. permissions) shouldn't block the - // rest of the batch or surface a blocking alert. + var targets: [(index: Int, path: String, name: String)] = [] + for node in nodes where !node.isSynthetic { + // Duplicate groups and list rows can outlive a live-refresh splice + // and still point at the previous tree instance; resolve them by + // path instead of silently dropping the request. + let index: Int + if node.tree === currentTree { + index = node.index + } else if let resolved = Self.findIndex(forPath: node.tree.path(of: node.index), in: currentTree) { + index = resolved + } else { + continue } + targets.append((index, currentTree.path(of: index), node.name)) + } + guard !targets.isEmpty else { return false } + + Task { [weak self] in + let (trashed, failures) = await Task.detached(priority: .userInitiated) { () -> ([Int], [String]) in + var trashed: [Int] = [] + var failures: [String] = [] + for target in targets { + do { + try FileManager.default.trashItem(at: URL(fileURLWithPath: target.path), resultingItemURL: nil) + trashed.append(target.index) + } catch { + // One failed delete (e.g. permissions) shouldn't block + // the rest of the batch, but the user has to hear about + // it: the item stays in the tree, so a silent failure + // looks like a no-op. + failures.append("\(target.name): \(error.localizedDescription)") + } + } + return (trashed, failures) + }.value + guard let self else { return } + if !failures.isEmpty { + let shown = failures.prefix(3).joined(separator: "\n") + let more = failures.count > 3 ? "\n…and \(failures.count - 3) more" : "" + self.errorMessage = "Couldn't move to Trash:\n\(shown)\(more)" + } + guard !trashed.isEmpty else { return } + // The tree was replaced (splice, rescan) while the moves ran: the + // indices no longer apply. The files are gone from disk, so mark + // the display stale rather than prune the wrong nodes. + guard self.tree === currentTree else { self.hasStaleResults = true; return } + self.pruneTree(afterTrashing: trashed, from: currentTree) } - guard !trashedIndices.isEmpty else { return false } - - pruneTree(afterTrashing: trashedIndices, from: currentTree) return true } @@ -1025,11 +1213,18 @@ public final class ScanViewModel: ObservableObject { await MainActor.run { self?.extensionSummaries = summaries } } - duplicateTask?.cancel() - duplicateTask = Task.detached(priority: .utility) { [weak self] in - let groups = Self.buildDuplicateGroups(tree: newTree) - guard !Task.isCancelled else { return } - await MainActor.run { self?.duplicateGroups = groups } + // If detection was still running when the user trashed something, + // cancelling it above would otherwise leave "Scanning for duplicates…" + // spinning until the next full rescan, so finish detecting first. + if duplicatesReady { + duplicateTask?.cancel() + duplicateTask = Task.detached(priority: .utility) { [weak self] in + let groups = Self.buildDuplicateGroups(tree: newTree) + guard !Task.isCancelled else { return } + await MainActor.run { self?.duplicateGroups = groups } + } + } else { + runDuplicateDetection(on: newTree) } Task { await recomputeLayout() } @@ -1085,7 +1280,7 @@ public final class ScanViewModel: ObservableObject { private nonisolated static func buildExtensionSummaries(tree: FileTree, map: ExtensionColorMap) -> [ExtensionSummary] { var groups: [String: (count: Int, size: Int64)] = [:] - for record in tree.records where !record.isDirectory { + for record in tree.records where !record.isDirectory && !record.isSynthetic { groups[record.fileExtension, default: (0, 0)].count += 1 groups[record.fileExtension, default: (0, 0)].size += record.size } @@ -1093,7 +1288,7 @@ public final class ScanViewModel: ObservableObject { return groups.map { ext, stats in ExtensionSummary( id: ext, - ext: ext.isEmpty ? "(directory)" : ".\(ext)", + ext: ext.isEmpty ? "(no extension)" : ".\(ext)", color: map.color(for: ext), fileCount: stats.count, totalSize: stats.size, @@ -1105,7 +1300,12 @@ public final class ScanViewModel: ObservableObject { private func recomputeLayout() async { guard let displayRoot = treemapRoot, let map = colorMap, - layoutSize.width > 1, layoutSize.height > 1 else { return } + layoutSize.width > 1, layoutSize.height > 1 else { + // Nothing is being computed (no size yet, e.g. a scan started from + // init before any view exists); `updateLayoutSize` will re-arm. + isComputingLayout = false + return + } layoutGeneration += 1 let myGen = layoutGeneration isComputingLayout = true @@ -1119,13 +1319,23 @@ public final class ScanViewModel: ObservableObject { self.isComputingLayout = false } - // Flat loop over every index — order doesn't matter (each node's safety - // level only depends on its own reconstructed path/name, both already - // fully populated by the builder before this runs). + // Iterative DFS building each node's absolute path from its parent's, so + // tagging a multi-million-node tree costs one string append per node + // instead of a parent-chain walk plus a URL allocation per node (that + // version kept a full-disk scan on "Scanning…" for two extra minutes + // after the walk itself had finished). private nonisolated static func tagSafetyLevels(tree: FileTree) { - for index in 0.. = [] + // Keyed by the group's stable duplicateGroupID (not array offset), since + // groups re-sort after a delete and offsets would point at the wrong group. + @State private var expanded: Set = [] var body: some View { let groups = vm.duplicateGroups @@ -19,16 +21,16 @@ struct DuplicatesView: View { Divider() ScrollView { LazyVStack(spacing: 0, pinnedViews: []) { - ForEach(Array(groups.enumerated()), id: \.offset) { index, group in + ForEach(groups, id: \.groupKey) { group in + let key = group.groupKey GroupSection( - index: index, group: group, - isExpanded: expanded.contains(index), + isExpanded: expanded.contains(key), isReadOnly: vm.isReadOnlySnapshot, onToggle: { withAnimation(.easeInOut(duration: 0.15)) { - if expanded.contains(index) { expanded.remove(index) } - else { expanded.insert(index) } + if expanded.contains(key) { expanded.remove(key) } + else { expanded.insert(key) } } }, onDeleteGroup: { deleteGroup(group) }, @@ -89,10 +91,15 @@ struct DuplicatesView: View { } } +// Stable identity for a duplicate group across re-sorts: keyed on the shared +// duplicateGroupID rather than the array offset, which shifts after a delete. +private extension Array where Element == FileNode { + var groupKey: UUID { first?.duplicateGroupID ?? UUID() } +} + // MARK: - Group section (value type — no @State, safe for LazyVStack) private struct GroupSection: View { - let index: Int let group: [FileNode] let isExpanded: Bool let isReadOnly: Bool diff --git a/Sources/Views/ExtensionList/ExtensionListView.swift b/Sources/Views/ExtensionList/ExtensionListView.swift index afd42a6..33f2ef1 100644 --- a/Sources/Views/ExtensionList/ExtensionListView.swift +++ b/Sources/Views/ExtensionList/ExtensionListView.swift @@ -44,7 +44,7 @@ struct ExtensionListView: View { systemImage: "internaldrive") .monospacedDigit() if node.isDirectory { - Label("\(node.children.count) items", + Label(node.itemCountLabel, systemImage: "folder") } else if !node.fileExtension.isEmpty { Text(".\(node.fileExtension.uppercased())") @@ -61,13 +61,15 @@ struct ExtensionListView: View { Spacer(minLength: 12) // Quick-action buttons - HStack(spacing: 4) { - iconButton("doc.on.doc.fill", help: "Copy path") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(node.url.path, forType: .string) - } - iconButton("folder.fill", help: "Reveal in Finder") { - NSWorkspace.shared.activateFileViewerSelecting([node.url]) + if !node.isSynthetic { + HStack(spacing: 4) { + iconButton("doc.on.doc.fill", help: "Copy path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(node.url.path, forType: .string) + } + iconButton("folder.fill", help: "Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([node.url]) + } } } } diff --git a/Sources/Views/Shared/ByteFormatter.swift b/Sources/Views/Shared/ByteFormatter.swift index f58b462..878a876 100644 --- a/Sources/Views/Shared/ByteFormatter.swift +++ b/Sources/Views/Shared/ByteFormatter.swift @@ -1,33 +1,26 @@ import Foundation public enum ByteFormatter { + private static let decimalUnits = ["B", "KB", "MB", "GB", "TB", "PB"] + private static let binaryUnits = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] + /// Formats bytes using the user's chosen unit style (decimal SI or binary IEC). public static func string(from bytes: Int64) -> String { - let binary = UserDefaults.standard.bool(forKey: "useBinarySize") - if binary { - let kib: Int64 = 1_024 - let mib = kib * 1_024 - let gib = mib * 1_024 - let tib = gib * 1_024 - switch bytes { - case tib...: return String(format: "%.1f TiB", Double(bytes) / Double(tib)) - case gib...: return String(format: "%.1f GiB", Double(bytes) / Double(gib)) - case mib...: return String(format: "%.1f MiB", Double(bytes) / Double(mib)) - case kib...: return String(format: "%.1f KiB", Double(bytes) / Double(kib)) - default: return "\(bytes) B" - } - } else { - let kb: Int64 = 1_000 - let mb = kb * 1_000 - let gb = mb * 1_000 - let tb = gb * 1_000 - switch bytes { - case tb...: return String(format: "%.1f TB", Double(bytes) / Double(tb)) - case gb...: return String(format: "%.1f GB", Double(bytes) / Double(gb)) - case mb...: return String(format: "%.1f MB", Double(bytes) / Double(mb)) - case kb...: return String(format: "%.1f KB", Double(bytes) / Double(kb)) - default: return "\(bytes) B" - } + string(from: bytes, binary: UserDefaults.standard.bool(forKey: "useBinarySize")) + } + + public static func string(from bytes: Int64, binary: Bool) -> String { + let base: Double = binary ? 1_024 : 1_000 + let units = binary ? binaryUnits : decimalUnits + var value = Double(bytes) + var unit = 0 + // Step up while the value would still print as "1000.0" or more, so a + // hair under a unit boundary reads "1.0 MB" instead of "1000.0 KB". + while unit < units.count - 1, value >= base - 0.05 { + value /= base + unit += 1 } + if unit == 0 { return "\(bytes) B" } + return String(format: "%.1f %@", value, units[unit]) } } diff --git a/Sources/Views/Sidebar/SidebarView.swift b/Sources/Views/Sidebar/SidebarView.swift index 2e3eadd..2ba640f 100644 --- a/Sources/Views/Sidebar/SidebarView.swift +++ b/Sources/Views/Sidebar/SidebarView.swift @@ -49,22 +49,6 @@ struct SidebarView: View { .foregroundStyle(.secondary) } } - - if vm.duplicatesReady { - Section { - NavigationLink { - DuplicatesView() - } label: { - Label("Duplicates", systemImage: "doc.on.doc") - .foregroundStyle(.orange) - } - } header: { - Label("Analysis", systemImage: "magnifyingglass") - .font(.caption) - .foregroundStyle(.secondary) - } - .transition(.move(edge: .bottom).combined(with: .opacity)) - } } .listStyle(.sidebar) .scrollContentBackground(.hidden) diff --git a/Sources/Views/Treemap/TreemapRenderer.swift b/Sources/Views/Treemap/TreemapRenderer.swift index ec8864d..57e3296 100644 --- a/Sources/Views/Treemap/TreemapRenderer.swift +++ b/Sources/Views/Treemap/TreemapRenderer.swift @@ -19,6 +19,13 @@ struct TreemapRenderer { let isSpotlighting = hoveredNode != nil let isFiltering = highlightedExtension != nil + // Bounding boxes of labels drawn so far; a label that would overlap + // one of them is skipped, so adjacent arcs never print on top of each + // other ("PlugInsMediaProvMotionEffect.fxp"). + // The center disc is drawn above the canvas by TreemapView, so it is + // reserved up front: inner-ring labels must not run underneath it. + var placedLabels: [CGRect] = [] + // ── Pass 1 — glow layers (drawn under everything) ─────────────────── // Selected arc: pulsing halo if let sel = selectedNode { @@ -93,7 +100,7 @@ struct TreemapRenderer { // Label if !isFiltered, !isSpotlit { - drawLabel(context: &context, cell: cell, center: center, showFileCount: showFileCount) + drawLabel(context: &context, cell: cell, center: center, showFileCount: showFileCount, placedLabels: &placedLabels) } } } @@ -117,67 +124,121 @@ struct TreemapRenderer { // MARK: - Labels - private static func drawLabel(context: inout GraphicsContext, cell: TreemapCell, center: CGPoint, showFileCount: Bool) { + private static func drawLabel(context: inout GraphicsContext, cell: TreemapCell, center: CGPoint, showFileCount: Bool, placedLabels: inout [CGRect]) { let arcLen = cell.arcLength let bandH = cell.outerRadius - cell.innerRadius guard arcLen > 38, bandH > 12 else { return } let r = cell.midRadius - let cx = center.x + r * cos(cell.midAngle) - let cy = center.y + r * sin(cell.midAngle) - let pt = CGPoint(x: cx, y: cy) - let maxW = min(arcLen - 8, 120.0) + // `midAngle` is Double and the radii are CGFloat; mixing them inside one + // expression is ambiguous to the compiler on SDKs where the two types + // are distinct, so the trig is converted once, explicitly. (It is also + // reused by the placement loop below instead of being recomputed.) + let cosMid = CGFloat(cos(cell.midAngle)) + let sinMid = CGFloat(sin(cell.midAngle)) + let pt = CGPoint(x: center.x + r * cosMid, y: center.y + r * sinMid) + let twoLine = arcLen > 72 && bandH > 26 var ctx = context ctx.addFilter(.shadow(color: .black.opacity(0.65), radius: 2, x: 0, y: 1)) - if arcLen > 72, bandH > 26 { - let nameText = ctx.resolve( - Text(cell.node.name) - .font(.system(size: 10.5, weight: .semibold)) - .foregroundColor(.white) - ) - let sizeText = ctx.resolve( - Text(ByteFormatter.string(from: cell.node.size)) - .font(.system(size: 8.5, weight: .regular)) - .foregroundColor(.white.opacity(0.75)) - ) - - let showCount = showFileCount && cell.node.isDirectory && !cell.node.children.isEmpty - let countText: GraphicsContext.ResolvedText? = showCount ? ctx.resolve( - Text("\(cell.node.children.count) items") - .font(.system(size: 7.5, weight: .regular)) - .foregroundColor(.white.opacity(0.55)) - ) : nil - - let ns = nameText.measure(in: CGSize(width: maxW, height: 20)) - guard ns.width <= maxW else { return } - let ss = sizeText.measure(in: CGSize(width: maxW, height: 16)) - let cs = countText?.measure(in: CGSize(width: maxW, height: 14)) ?? .zero - let gap: CGFloat = 2 - var blockH = ns.height + gap + ss.height + let sizeText = ctx.resolve( + Text(ByteFormatter.string(from: cell.node.size)) + .font(.system(size: 8.5, weight: .regular)) + .foregroundColor(.white.opacity(0.75)) + ) + let showCount = twoLine && showFileCount && cell.node.isDirectory && !cell.node.children.isEmpty + let countText: GraphicsContext.ResolvedText? = showCount ? ctx.resolve( + Text(cell.node.itemCountLabel) + .font(.system(size: 7.5, weight: .regular)) + .foregroundColor(.white.opacity(0.55)) + ) : nil + let ss = twoLine ? sizeText.measure(in: unbounded) : .zero + let cs = countText?.measure(in: unbounded) ?? .zero + let gap: CGFloat = 2 + + // Start at the widest label the arc allows and narrow it until the + // block clears the center disc (inner ring labels are often wider than + // the ring is deep). A block that collides with another label is + // simply dropped: shrinking would not move it out of the way. + var maxW = min(arcLen - 8, 120.0) + while maxW >= 30 { + guard let (nameText, ns) = fittedName(cell.node.name, size: twoLine ? 10.5 : 9.5, weight: twoLine ? .semibold : .medium, maxW: maxW, ctx: ctx) else { return } + var blockH = ns.height + if twoLine { blockH += gap + ss.height } if countText != nil { blockH += gap + cs.height } + let blockW = max(ns.width, twoLine ? ss.width : 0, cs.width) + + // Slide outward along the radius (staying inside the arc's own + // band) before giving up width: inner-ring labels are usually + // wider than the band is deep, and a nudge of a few points is + // enough to clear the disc. + var pt = pt + var rect = CGRect.zero + var clears = false + // The block may overhang its arc's outer edge by up to a quarter of + // its height; the visible disc is a few points smaller than the + // layout radius, so the disc test uses the unpadded block. + let maxShift = max(0, cell.outerRadius - r - blockH / 4) + for shift in stride(from: CGFloat(0), through: maxShift, by: 2) { + pt = CGPoint(x: center.x + (r + shift) * cosMid, y: center.y + (r + shift) * sinMid) + let block = CGRect(x: pt.x - blockW / 2, y: pt.y - blockH / 2, width: blockW, height: blockH) + rect = block.insetBy(dx: -3, dy: -2) + if clearsCenterDisc(block, center: center) { clears = true; break } + } + if !clears { maxW -= 12; continue } + guard !placedLabels.contains(where: { $0.intersects(rect) }) else { return } + placedLabels.append(rect) var y = pt.y - blockH / 2 + ns.height / 2 ctx.draw(nameText, at: CGPoint(x: pt.x, y: y), anchor: .center) - y += ns.height / 2 + gap + ss.height / 2 - if ss.width <= maxW { + if twoLine { + y += ns.height / 2 + gap + ss.height / 2 ctx.draw(sizeText, at: CGPoint(x: pt.x, y: y), anchor: .center) } - if let countText, cs.width <= maxW { - y += ss.height / 2 + gap + cs.height / 2 + if let countText { + y += (twoLine ? ss.height : ns.height) / 2 + gap + cs.height / 2 ctx.draw(countText, at: CGPoint(x: pt.x, y: y), anchor: .center) } - } else { - let nameText = ctx.resolve( - Text(cell.node.name) - .font(.system(size: 9.5, weight: .medium)) - .foregroundColor(.white) - ) - let ns = nameText.measure(in: CGSize(width: maxW, height: 20)) - guard ns.width <= maxW else { return } - ctx.draw(nameText, at: pt, anchor: .center) + return + } + } + + private static let unbounded = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + + // The center disc is drawn above the canvas by TreemapView, so a label + // must not run underneath it: true when the disc stays out of `rect`. + private static func clearsCenterDisc(_ rect: CGRect, center: CGPoint) -> Bool { + let nearest = CGPoint(x: min(max(center.x, rect.minX), rect.maxX), + y: min(max(center.y, rect.minY), rect.maxY)) + return hypot(nearest.x - center.x, nearest.y - center.y) >= TreemapLayout.centerRadius + } + + // Resolves `name` so that it fits in `maxW`, middle-truncating with an + // ellipsis when it doesn't. Measuring inside a bounded width used to + // report a size that fit while `draw` then painted the full natural width, + // so long names spilled over neighbouring arcs. A label that would keep + // fewer than 7 characters, or under 30% of the name, says nothing useful + // ("Fr…ks"), so nil is returned and no label is drawn; the hover tooltip + // still shows the full name. + private static func fittedName(_ name: String, size: CGFloat, weight: Font.Weight, maxW: CGFloat, ctx: GraphicsContext) -> (GraphicsContext.ResolvedText, CGSize)? { + func resolve(_ s: String) -> (GraphicsContext.ResolvedText, CGSize) { + let t = ctx.resolve(Text(s).font(.system(size: size, weight: weight)).foregroundColor(.white)) + return (t, t.measure(in: unbounded)) + } + var (text, measured) = resolve(name) + if measured.width <= maxW { return (text, measured) } + let chars = Array(name) + let minKeep = max(7, Int(Double(chars.count) * 0.3)) + // Proportional first guess, then shrink until it fits. + var keep = max(2, Int(Double(chars.count) * Double(maxW / measured.width)) - 1) + while keep >= minKeep { + let candidate = String(chars.prefix((keep + 1) / 2)) + "…" + String(chars.suffix(keep / 2)) + (text, measured) = resolve(candidate) + if measured.width <= maxW { return (text, measured) } + keep -= 2 } + return nil } // MARK: - Hit testing diff --git a/Sources/Views/Treemap/TreemapView.swift b/Sources/Views/Treemap/TreemapView.swift index 361a990..d86cc89 100644 --- a/Sources/Views/Treemap/TreemapView.swift +++ b/Sources/Views/Treemap/TreemapView.swift @@ -87,18 +87,26 @@ struct TreemapView: View { .padding(20) .glassCard(cornerRadius: 16) .allowsHitTesting(false) - } else if let root = vm.treemapRoot, !vm.cells.isEmpty { + } else if let root = vm.treemapRoot { + // Always shown once a chart root exists, so the Back control + // never disappears just because no arc was large enough to draw. centerLabel(for: root) .allowsHitTesting(false) } // ── Empty folder ───────────────────────────────────────────────── - if vm.cells.isEmpty, !vm.isScanning, !vm.isComputingLayout, vm.root != nil { + // Keyed off the node, not off `cells`: an empty cell list can also + // mean the view is too small to draw anything. + if let root = vm.treemapRoot, !vm.isScanning, !vm.isComputingLayout, + !root.children.contains(where: { $0.size > 0 }) { CompatContentUnavailableView( - title: "Empty Folder", + title: root.isAutoSummarized ? "Summarized Folder" : "Empty Folder", systemImage: "folder", - description: Text("This folder contains no files") + description: Text(root.isAutoSummarized + ? "\(root.itemCountLabel). Contents aren't browsable individually." + : "This folder contains no files") ) + .allowsHitTesting(false) } // ── Live indicator ─────────────────────────────────────────────── @@ -138,7 +146,7 @@ struct TreemapView: View { guard c.x > 0 else { return } if TreemapRenderer.isInCenter(point: loc, center: c) { - guard !vm.drillStack.isEmpty else { return } + guard !vm.drillStack.isEmpty else { vm.select(nil); return } HapticEngine.shared.drillOut() drillingIn = false clickAnchor = .center @@ -146,8 +154,9 @@ struct TreemapView: View { vm.drillUp() return } - guard let cell = TreemapRenderer.cell(at: loc, center: c, in: vm.cells) else { return } - if cell.node.isDirectory { + // Clicking empty canvas clears the selection (and pauses the pulse timer). + guard let cell = TreemapRenderer.cell(at: loc, center: c, in: vm.cells) else { vm.select(nil); return } + if cell.node.isDrillable { HapticEngine.shared.drillIn() drillingIn = true let w = viewSize.width > 0 ? viewSize.width : 1 @@ -179,16 +188,19 @@ struct TreemapView: View { Divider() - Button { - NSWorkspace.shared.activateFileViewerSelecting([node.url]) - } label: { - Label("Reveal in Finder", systemImage: "folder.viewfinder") - } - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(node.url.path, forType: .string) - } label: { - Label("Copy Path", systemImage: "doc.on.clipboard") + // The synthetic "Hidden & Unreadable Space" node has no path on disk. + if !node.isSynthetic { + Button { + NSWorkspace.shared.activateFileViewerSelecting([node.url]) + } label: { + Label("Reveal in Finder", systemImage: "folder.viewfinder") + } + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(node.url.path, forType: .string) + } label: { + Label("Copy Path", systemImage: "doc.on.clipboard") + } } Button { NSPasteboard.general.clearContents() @@ -197,7 +209,7 @@ struct TreemapView: View { Label("Copy Name", systemImage: "textformat") } - if node.isDirectory { + if node.isDrillable { Divider() Button { HapticEngine.shared.drillIn() @@ -210,7 +222,7 @@ struct TreemapView: View { } } - if !vm.isReadOnlySnapshot { + if !vm.isReadOnlySnapshot, !node.isSynthetic { Divider() Button(role: .destructive) { @@ -329,7 +341,7 @@ private struct HoverTooltip: View { Label(ByteFormatter.string(from: node.size), systemImage: "internaldrive") .monospacedDigit() if node.isDirectory { - Label("\(node.children.count) items", systemImage: "folder") + Label(node.itemCountLabel, systemImage: "folder") } else if !node.fileExtension.isEmpty { Text(".\(node.fileExtension.uppercased())") .padding(.horizontal, 4) diff --git a/Tests/AutoSummaryTests.swift b/Tests/AutoSummaryTests.swift index 53dae17..2dc2126 100644 --- a/Tests/AutoSummaryTests.swift +++ b/Tests/AutoSummaryTests.swift @@ -21,11 +21,8 @@ final class AutoSummaryTests: XCTestCase { return try await body() } - // The app's default excludedFolderNames already contains "node_modules" - // (it is fully skipped, not just collapsed - see ScanConfig's default in - // FileScanner.swift), so tests exercising the node_modules named-layout - // shortcut must use a config where it is NOT excluded, exactly like a - // user who removed it from their exclusion list would see. + // Pins the exclusion list for the duration of a test so results don't + // depend on whatever the developer's own UserDefaults happen to hold. private func withExcludedFolderNames(_ names: String, _ body: () async throws -> T) async rethrows -> T { let prior = UserDefaults.standard.string(forKey: "excludedFolderNames") UserDefaults.standard.set(names, forKey: "excludedFolderNames") diff --git a/Tests/DefaultScanCoverageTests.swift b/Tests/DefaultScanCoverageTests.swift new file mode 100644 index 0000000..35214fc --- /dev/null +++ b/Tests/DefaultScanCoverageTests.swift @@ -0,0 +1,142 @@ +import XCTest +@testable import MacDirStat + +// Regression: with factory defaults (no user-set exclusions, no hidden-file +// setting), a scan must account for EVERY byte under the root. The shipped +// 1.3 defaults excluded `.git,node_modules,DerivedData,.Trash` and skipped +// dotfiles, which made a real 7.3 GB project report as 40 MB. +final class DefaultScanCoverageTests: XCTestCase { + + private let keys = ["excludedFolderNames", "showHiddenFiles", "autoSummarizeEnabled"] + + private func withFactoryDefaults(_ body: () async throws -> T) async rethrows -> T { + let prior = keys.map { ($0, UserDefaults.standard.object(forKey: $0)) } + for key in keys { UserDefaults.standard.removeObject(forKey: key) } + defer { + for (key, value) in prior { + if let value { UserDefaults.standard.set(value, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + } + return try await body() + } + + private func write(_ bytes: Int, at url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(repeating: 0x41, count: bytes).write(to: url) + } + + private func allocated(_ url: URL) -> Int64 { + var st = stat() + guard lstat(url.path, &st) == 0 else { return 0 } + return Int64(st.st_blocks) * 512 + } + + private func scanTree(at url: URL) async -> FileNode? { + let scanner = FileScanner() + var root: FileNode? + for await progress in await scanner.scan(url: url) { + if case .completed(let tree, _) = progress { root = FileNode(tree: tree, index: tree.rootIndex) } + } + return root + } + + func test_factory_defaults_account_for_every_byte() async throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("mds-default-coverage-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + + let files: [(String, Int)] = [ + ("src/main.swift", 20_000), + (".git/objects/ab/cdef", 300_000), + (".hidden-cache/blob", 150_000), + ("node_modules/pkg/dist/index.js", 400_000), + ("DerivedData/Build/app.bin", 900_000), + (".Trash/old.dmg", 250_000), + (".DS_Store", 6_000), + ] + var expected: Int64 = 0 + for (relative, bytes) in files { + let url = dir.appendingPathComponent(relative) + try write(bytes, at: url) + expected += allocated(url) + } + + let root = await withFactoryDefaults { await scanTree(at: dir) } + let unwrapped = try XCTUnwrap(root) + + XCTAssertEqual(unwrapped.size, expected, "scan under factory defaults must equal the sum of every file's allocated size") + + let names = Set(unwrapped.children.map(\.name)) + for name in [".git", ".hidden-cache", "node_modules", "DerivedData", ".Trash", ".DS_Store", "src"] { + XCTAssertTrue(names.contains(name), "\(name) missing from root children: \(names.sorted())") + } + + // Generated trees are collapsed into one browsable-as-a-whole node, + // never silently dropped. + let nodeModules = try XCTUnwrap(unwrapped.children.first { $0.name == "node_modules" }) + XCTAssertTrue(nodeModules.isAutoSummarized) + XCTAssertEqual(nodeModules.descendantFileCount, 1) + let git = try XCTUnwrap(unwrapped.children.first { $0.name == ".git" }) + XCTAssertTrue(git.isAutoSummarized) + } + + func test_symlinked_root_scans_its_target() async throws { + let base = FileManager.default.temporaryDirectory.appendingPathComponent("mds-symroot-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: base) } + let real = base.appendingPathComponent("real") + try write(50_000, at: real.appendingPathComponent("a.bin")) + let link = base.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + + let scanned = await scanTree(at: link) + let root = try XCTUnwrap(scanned) + XCTAssertEqual(root.size, allocated(real.appendingPathComponent("a.bin"))) + XCTAssertEqual(root.children.map(\.name), ["a.bin"]) + XCTAssertEqual(root.tree.rootPath, FileScanner.resolvingSymlinkRoot(link).path) + } + + func test_legacy_exclusion_default_is_reset_once() { + let suite = "mds-migration-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set(".git,node_modules,DerivedData,.Trash", forKey: "excludedFolderNames") + ScanViewModel.migrateLegacyExclusionDefault(in: defaults) + XCTAssertNil(defaults.string(forKey: "excludedFolderNames"), "legacy factory value is reset") + + defaults.set(".git,node_modules,DerivedData,.Trash", forKey: "excludedFolderNames") + ScanViewModel.migrateLegacyExclusionDefault(in: defaults) + XCTAssertEqual(defaults.string(forKey: "excludedFolderNames"), ".git,node_modules,DerivedData,.Trash", "runs only once") + + let custom = UserDefaults(suiteName: suite + "-custom")! + defer { custom.removePersistentDomain(forName: suite + "-custom") } + custom.set("Library,Movies", forKey: "excludedFolderNames") + ScanViewModel.migrateLegacyExclusionDefault(in: custom) + XCTAssertEqual(custom.string(forKey: "excludedFolderNames"), "Library,Movies", "user customisation is preserved") + } + + // 1.3 also persisted its "hide hidden files" default. An install that + // already ran the first (exclusion-only) migration must still get this + // one, exactly once. + func test_legacy_hidden_files_false_is_reset_once_even_after_first_migration() { + let suite = "mds-migration-hidden-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set(true, forKey: "legacyExclusionDefaultMigrated") // step 1 already ran + defaults.set(false, forKey: "showHiddenFiles") + ScanViewModel.migrateLegacyExclusionDefault(in: defaults) + XCTAssertNil(defaults.object(forKey: "showHiddenFiles"), "legacy hidden=false is reset") + XCTAssertEqual(defaults.integer(forKey: "legacyDefaultsMigration"), 2) + + defaults.set(false, forKey: "showHiddenFiles") + ScanViewModel.migrateLegacyExclusionDefault(in: defaults) + XCTAssertEqual(defaults.object(forKey: "showHiddenFiles") as? Bool, false, "a deliberate later choice is kept") + } + + func test_shared_defaults_are_used_everywhere() { + XCTAssertEqual(ScanDefaults.excludedFolderNames, "") + XCTAssertTrue(ScanDefaults.showHiddenFiles) + XCTAssertTrue(knownGeneratedDirectoryNames.isSuperset(of: ["node_modules", ".git", "Pods", ".next"])) + } +} diff --git a/Tests/DuplicateDetectorTests.swift b/Tests/DuplicateDetectorTests.swift index 7912c2e..dfbaaac 100644 --- a/Tests/DuplicateDetectorTests.swift +++ b/Tests/DuplicateDetectorTests.swift @@ -34,7 +34,7 @@ final class DuplicateDetectorTests: XCTestCase { let tree = FileTreeBuilder.build(from: root, rootPath: tmp.path) let detector = DuplicateDetector() - await detector.detect(in: tree) + if let groups = await detector.detect(in: tree) { tree.applyDuplicateGroups(groups) } let copy1 = index(in: tree, named: "copy1.bin")! let copy2 = index(in: tree, named: "copy2.bin")! @@ -46,6 +46,48 @@ final class DuplicateDetectorTests: XCTestCase { XCTAssertNil(tree.records[unique].duplicateGroupID, "unique file must not be grouped") } + // Regression: `record.size` is the ALLOCATED size. Two sparse files that + // share their first 64 KB, occupy the same few blocks on disk, but differ + // in their tails were declared duplicates because the small-allocated-size + // shortcut treated the 64 KB quick hash as a full-content hash. + func test_sparse_files_with_same_head_and_different_tail_are_not_duplicates() async throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + func writeSparse(_ url: URL, tail: UInt8) throws { + try Data(repeating: 7, count: 8192).write(to: url) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seek(toOffset: 4 * 1024 * 1024) + try handle.write(contentsOf: Data([tail])) + } + let a = tmp.appendingPathComponent("a.bin") + let b = tmp.appendingPathComponent("b.bin") + let c = tmp.appendingPathComponent("c.bin") + try writeSparse(a, tail: 1) + try writeSparse(b, tail: 2) + try writeSparse(c, tail: 1) // genuinely identical to a + + // `size` in the tree is the ALLOCATED size the scanner records. Pin it + // to what a sparse file occupies so the test does not depend on the + // filesystem actually punching a hole: the point is that the detector + // must not trust a small allocated size as "the quick hash saw it all". + let root = FSNode(url: tmp, name: "root", isDirectory: true, size: 0, fileExtension: "", parent: nil) + for url in [a, b, c] { + let child = FSNode(url: url, name: url.lastPathComponent, isDirectory: false, size: 12_288, fileExtension: "bin", parent: root) + root.children.append(child) + root.size += child.size + } + let tree = FileTreeBuilder.build(from: root, rootPath: tmp.path) + if let groups = await DuplicateDetector().detect(in: tree) { tree.applyDuplicateGroups(groups) } + + let ia = index(in: tree, named: "a.bin")!, ib = index(in: tree, named: "b.bin")!, ic = index(in: tree, named: "c.bin")! + XCTAssertNotNil(tree.records[ia].duplicateGroupID) + XCTAssertEqual(tree.records[ia].duplicateGroupID, tree.records[ic].duplicateGroupID, "a and c are identical") + XCTAssertNil(tree.records[ib].duplicateGroupID, "b differs in its tail and must not be grouped") + } + func test_small_files_below_threshold_are_skipped() async throws { let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -65,7 +107,7 @@ final class DuplicateDetectorTests: XCTestCase { let tree = FileTreeBuilder.build(from: root, rootPath: tmp.path) let detector = DuplicateDetector() - await detector.detect(in: tree) + if let groups = await detector.detect(in: tree) { tree.applyDuplicateGroups(groups) } let tiny1 = index(in: tree, named: "tiny1.txt")! let tiny2 = index(in: tree, named: "tiny2.txt")! @@ -98,7 +140,7 @@ final class DuplicateDetectorTests: XCTestCase { let tree = FileTreeBuilder.build(from: root, rootPath: tmp.path) let detector = DuplicateDetector() - await detector.detect(in: tree) + if let groups = await detector.detect(in: tree) { tree.applyDuplicateGroups(groups) } let a = index(in: tree, named: "a.bin")! let b = index(in: tree, named: "b.bin")! @@ -129,7 +171,7 @@ final class DuplicateDetectorTests: XCTestCase { let tree = FileTreeBuilder.build(from: root, rootPath: tmp.path) let detector = DuplicateDetector() - await detector.detect(in: tree) + if let groups = await detector.detect(in: tree) { tree.applyDuplicateGroups(groups) } let s1 = index(in: tree, named: "s1.bin")! let s2 = index(in: tree, named: "s2.bin")! @@ -175,7 +217,7 @@ final class DuplicateDetectorTests: XCTestCase { let tree = FileTreeBuilder.build(from: root, rootPath: tmp.path) let detector = DuplicateDetector() - await detector.detect(in: tree) + if let groups = await detector.detect(in: tree) { tree.applyDuplicateGroups(groups) } var seenGroupIDs = Set() for (nameA, nameB) in pairNames { @@ -192,3 +234,30 @@ final class DuplicateDetectorTests: XCTestCase { XCTAssertEqual(seenGroupIDs.count, pairCount) } } + +final class DuplicateDetectorFocusTests: XCTestCase { + func test_focus_only_examines_matching_size_buckets() async throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + let small = Data(repeating: 5, count: 8192) + let large = Data(repeating: 9, count: 16384) + let files: [(String, Data)] = [("s1.bin", small), ("s2.bin", small), ("l1.bin", large), ("l2.bin", large)] + let root = FSNode(url: tmp, name: "root", isDirectory: true, size: 0, fileExtension: "", parent: nil) + for (name, data) in files { + let url = tmp.appendingPathComponent(name) + try data.write(to: url) + root.children.append(FSNode(url: url, name: name, isDirectory: false, size: Int64(data.count), fileExtension: "bin", parent: root)) + } + let tree = FileTreeBuilder.build(from: root, rootPath: tmp.path) + let idx = { (n: String) in tree.records.firstIndex { $0.name == n }! } + + // Focus on the small pair only: the large pair is never touched. + let detected = await DuplicateDetector().detect(in: tree, focusing: [idx("s1.bin")]) + let result = try XCTUnwrap(detected) + XCTAssertEqual(Set(result.keys), [idx("s1.bin"), idx("s2.bin")]) + XCTAssertNotNil(result[idx("s1.bin")]!) + XCTAssertEqual(result[idx("s1.bin")]!, result[idx("s2.bin")]!) + } +} diff --git a/Tests/FileTreePruneTests.swift b/Tests/FileTreePruneTests.swift index 7d56a78..0fa882d 100644 --- a/Tests/FileTreePruneTests.swift +++ b/Tests/FileTreePruneTests.swift @@ -472,11 +472,10 @@ final class ScanViewModelPruneTests: XCTestCase { vm.select(target) let trashed = vm.trashNode(target) - XCTAssertTrue(trashed, "trashNode should report success for a real, trashable file") + XCTAssertTrue(trashed, "trashNode should accept a real, trashable file") - // The prune itself (tree/selection/drillStack/colorMap) is synchronous; - // only the off-thread extension-summary/duplicate-group passes need - // waiting for. + // The move runs off the main actor and the prune lands when it finishes. + await waitUntil { vm.root?.children.count == 1 } XCTAssertFalse(FileManager.default.fileExists(atPath: toDeleteURL.path), "trashItem should have actually moved the file out of tmp") guard let newRoot = vm.root else { return XCTFail("tree must still exist after a prune") } XCTAssertEqual(newRoot.children.map(\.name), ["keep.bin"], "the trashed node must be gone from the live tree") diff --git a/Tests/LastScannedFolderTests.swift b/Tests/LastScannedFolderTests.swift new file mode 100644 index 0000000..83f5c72 --- /dev/null +++ b/Tests/LastScannedFolderTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import MacDirStat + +/// The App Store build resumes the last scanned folder from a security-scoped +/// bookmark instead of a bare path. These cover the Developer ID branch, which +/// is what the test binary compiles as (`Build.isAppStore` is false here); the +/// bookmark branch needs a real sandbox to exercise. +final class LastScannedFolderTests: XCTestCase { + + private let pathKey = "lastScannedPath" + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: pathKey) + UserDefaults.standard.removeObject(forKey: ScanViewModel.lastScannedBookmarkKey) + super.tearDown() + } + + @MainActor + func test_remembered_folder_resolves_back() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dirstat-last-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + ScanViewModel.rememberLastScannedFolder(dir) + + XCTAssertEqual(ScanViewModel.resolveLastScannedFolder()?.path, dir.path) + } + + @MainActor + func test_folder_deleted_since_last_scan_resolves_to_nil() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dirstat-gone-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + ScanViewModel.rememberLastScannedFolder(dir) + try FileManager.default.removeItem(at: dir) + + // Auto-scan must not fire on a folder that no longer exists, or the app + // opens onto an empty chart with no explanation. + XCTAssertNil(ScanViewModel.resolveLastScannedFolder()) + } + + @MainActor + func test_no_previous_scan_resolves_to_nil() { + UserDefaults.standard.removeObject(forKey: pathKey) + UserDefaults.standard.removeObject(forKey: ScanViewModel.lastScannedBookmarkKey) + + XCTAssertNil(ScanViewModel.resolveLastScannedFolder()) + } +} diff --git a/Tests/ScanComparisonTests.swift b/Tests/ScanComparisonTests.swift index 5c5260c..060120b 100644 --- a/Tests/ScanComparisonTests.swift +++ b/Tests/ScanComparisonTests.swift @@ -189,3 +189,40 @@ final class ScanComparisonTests: XCTestCase { XCTAssertEqual(changes[0].delta, 0) } } + +final class ScanComparisonSummarizedTests: XCTestCase { + // `deps` is fully materialized in "before" and auto-summarized in "after" + // (it crossed the summarization threshold). The old report listed every + // file under it as removed; the directory's own growth must be the only row. + private func makeTree(summarized: Bool, depsSize: Int64) -> FileTree { + let root = FSNode(url: URL(fileURLWithPath: "/scan"), name: "scan", isDirectory: true, size: 0, fileExtension: "", parent: nil) + let keep = FSNode(url: URL(fileURLWithPath: "/scan/keep.txt"), name: "keep.txt", isDirectory: false, size: 10, fileExtension: "txt", parent: root) + let deps = FSNode(url: URL(fileURLWithPath: "/scan/deps"), name: "deps", isDirectory: true, size: depsSize, fileExtension: "", parent: root) + if summarized { + deps.isAutoSummarized = true + deps.descendantFileCount = 2 + } else { + let a = FSNode(url: URL(fileURLWithPath: "/scan/deps/a.js"), name: "a.js", isDirectory: false, size: depsSize / 2, fileExtension: "js", parent: deps) + let b = FSNode(url: URL(fileURLWithPath: "/scan/deps/b.js"), name: "b.js", isDirectory: false, size: depsSize / 2, fileExtension: "js", parent: deps) + deps.children = [a, b] + } + root.children = [keep, deps] + root.size = keep.size + deps.size + return FileTreeBuilder.build(from: root, rootPath: "/scan") + } + + func test_directory_that_became_summarized_reports_one_growth_row() { + let changes = ScanComparison.compare(before: makeTree(summarized: false, depsSize: 200), + after: makeTree(summarized: true, depsSize: 300)) + XCTAssertEqual(changes.map(\.relativePath), ["deps"]) + XCTAssertEqual(changes.first?.kind, .grew) + XCTAssertEqual(changes.first?.delta, 100) + XCTAssertTrue(changes.first?.isDirectory ?? false) + } + + func test_summarized_on_both_sides_with_same_size_is_silent() { + let changes = ScanComparison.compare(before: makeTree(summarized: true, depsSize: 200), + after: makeTree(summarized: true, depsSize: 200)) + XCTAssertTrue(changes.isEmpty) + } +} diff --git a/Tests/TreemapLayoutTests.swift b/Tests/TreemapLayoutTests.swift index ff0cc79..363f93f 100644 --- a/Tests/TreemapLayoutTests.swift +++ b/Tests/TreemapLayoutTests.swift @@ -52,6 +52,23 @@ final class TreemapLayoutTests: XCTestCase { XCTAssertEqual(ByteFormatter.string(from: 500), "500 B") } + func test_byte_formatter_steps_up_at_unit_boundary() { + // 999,950 B used to print as "1000.0 KB". + XCTAssertEqual(ByteFormatter.string(from: 999_950), "1.0 MB") + XCTAssertEqual(ByteFormatter.string(from: 999_949), "999.9 KB") + XCTAssertEqual(ByteFormatter.string(from: 999_999_999_950), "1.0 TB") + XCTAssertEqual(ByteFormatter.string(from: 1_073_741_824, binary: true), "1.0 GiB") + XCTAssertEqual(ByteFormatter.string(from: 1_023, binary: true), "1023 B") + } + + // Regression: an angle threshold dropped every child below 0.29% of the + // parent, so a flat folder of ~350+ similar files drew nothing at all. + func test_layout_many_equal_small_children_still_draws_cells() { + let root = makeTree((0..<600).map { ("f\($0).txt", Int64(1_000)) }) + let cells = TreemapLayout.compute(root: root, in: CGRect(x: 0, y: 0, width: 600, height: 600), colorMap: ExtensionColorMap(root: root)) + XCTAssertEqual(cells.count, 600) + } + // MARK: - Helpers // Builds an FSNode fixture (as before) and converts it to a FileTree, diff --git a/docs/adr/0002-two-distribution-tracks.md b/docs/adr/0002-two-distribution-tracks.md new file mode 100644 index 0000000..e16d5a5 --- /dev/null +++ b/docs/adr/0002-two-distribution-tracks.md @@ -0,0 +1,92 @@ +# 0002. Ship two distribution tracks from one target, split by build configuration + +## Status + +Accepted, 2026-08-25. + +## Context + +DirStat goes out two ways, and the two ways contradict each other: + +- **Developer ID / notarized DMG** from GitHub Releases. Unsandboxed, so it can + hold Full Disk Access and chart an entire volume. Ships Sparkle so it can + update itself. +- **Mac App Store** (`com.macdirstat.app`, app id 6766033292). The store + requires the App Sandbox, which permanently rules out Full Disk Access. It + also bars self-updaters under guideline 2.4.5, and Sparkle nests `Updater.app` + plus XPC services inside its framework, which fails upload validation on + nested-bundle grounds before a human ever sees it. + +The repo had drifted onto the Developer ID track alone: `MacDirStat.entitlements` +turned the sandbox off in commit `319117c` so Full Disk Access would work. The +App Store listing was left behind at 1.0, because the tree could no longer +produce a build the store would accept. + +Store builds 1.0 and 1.1 were cut from a **second clone** at +`~/Documents/Projects/MacDirStat-appstore`, where the App Store adaptation +(Sparkle torn out of the pbxproj, `Package.swift`, `Info.plist` and +`MacDirStatApp.swift`; sandbox back on; a Privacy Policy link; quit-on-last-window) +lived as permanently **uncommitted working-tree edits**. That works exactly once. +The edits can't be reviewed, `git checkout` discards them, and the clone stayed +pinned at `e85159a` while the real work moved on, so shipping a current build +meant pulling forty-odd commits and re-applying that diff over conflicts in the +two files it touches most. + +## Decision + +One app target, three build configurations. `AppStore` differs from `Release` +in exactly four ways: + +| | Release (Developer ID) | AppStore | +|---|---|---| +| Entitlements | `MacDirStat.entitlements`, sandbox **off** | `MacDirStat-AppStore.entitlements`, sandbox **on** + `files.user-selected.read-write` | +| Info.plist | `Info.plist`, Sparkle feed keys present | `Info-AppStore.plist`, no Sparkle keys, no FDA usage string | +| Swift flags | none | `APPSTORE` compilation condition | +| Linker | default | `-Wl,-dead_strip_dylibs` | + +`Build.isAppStore` in `MacDirStatApp.swift` reads the compilation condition once +so the rest of the app can branch on a plain constant instead of scattering +`#if` blocks. It switches off the Full Disk Access probe, banner, sheet, and +menu item, none of which mean anything in a sandbox. + +Two consequences follow from the sandbox and are deliberate, not oversights: + +- The App Store build scans only what the user hands it through the open panel. + There is no whole-disk view. Read-write is requested because the treemap and + duplicate views move files to the Trash. +- "Auto-scan last folder" cannot work from a stored path, because a sandboxed + app loses access at exit. `ScanViewModel.rememberLastScannedFolder` stores a + security-scoped bookmark for App Store builds and a plain path otherwise. + +## Why not a second target + +A duplicate target is the textbook answer, because SwiftPM product +dependencies attach to a target and cannot be scoped to a configuration, so the +`AppStore` configuration still links and embeds Sparkle. Instead: the `APPSTORE` +condition removes every Sparkle call, `-dead_strip_dylibs` drops the now-unused +load command, and a `Strip Sparkle (App Store)` shell phase deletes the embedded +framework. Xcode signs the bundle after that phase runs, so the signature comes +out valid over the stripped bundle. The phase re-checks with `otool -L` and fails +the build if the load command survived, because a stripped app that still +references Sparkle would crash on launch. + +That is three moving parts against a duplicate target's ongoing cost of keeping +two source lists, two phase lists, and two settings blocks in step. If the strip +ever gets fragile, the second target is the upgrade path. + +## Consequences + +- Archive the store build with `-configuration AppStore`; the DMG workflow keeps + using `-configuration Release` and is untouched. +- The store build needs an **Apple Distribution** certificate. The existing + "Mac Team Store Provisioning Profile: com.macdirstat.app" is valid to + 2027-05-03. +- The two tracks now share a version number. Both read `MARKETING_VERSION`. +- `~/Documents/Projects/MacDirStat-appstore` is superseded and should be deleted + once a store build has shipped from this tree. Leaving it around invites a + future release being cut from a clone that is missing everything since + `e85159a`. +- One thing that clone did was request `files.user-selected.read-only`. That does + not permit moving files to the Trash, so right-click delete and the duplicates + view could not have worked in the shipped store build. This tree asks for + read-write. diff --git a/docs/index.html b/docs/index.html index 34488e6..fbde786 100644 --- a/docs/index.html +++ b/docs/index.html @@ -325,7 +325,7 @@

See where your disk space went.

Available Now - macOS 14+ + macOS 13+ Native Swift No subscription Open Source