From 1001d20d559885fdb0bed3cc88474f89a626f224 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 25 Aug 2026 12:00:07 +0300 Subject: [PATCH 01/19] build(appstore): AppStore configuration for the Mac App Store track The tree could only produce Developer ID builds: MacDirStat.entitlements turned the sandbox off in 319117c so Full Disk Access would work, and the store refuses anything unsandboxed. The App Store listing sat at 1.0 as a result, four releases behind. Adds a third build configuration that differs from Release in four ways: sandboxed entitlements with user-selected read-write (the treemap and duplicate views trash files, and Save Scan writes a .mdscan), an Info.plist without Sparkle's feed keys, the APPSTORE compilation condition, and -dead_strip_dylibs. Sparkle has to go, not just go quiet: it nests Updater.app and XPC services inside its framework, and nested bundles fail App Store upload validation before review even starts. SwiftPM product dependencies can't be scoped to a configuration, so a build phase strips the embedded framework and re-checks with otool. Xcode signs after that phase runs, so the signature covers the stripped bundle. Build.isAppStore switches off the Full Disk Access probe, the banner, the sheet, the menu item, and the whole Permissions settings section. A sandboxed app can never hold FDA, so every one of those is a dead end that would send users to System Settings for nothing. Versions go to 1.4 / build 10, ahead of both the 1.0 on the store and the v1.3.0 tag on the DMG track. --- MacDirStat.xcodeproj/project.pbxproj | 115 +++++++++++++++++++- MacDirStat/Info-AppStore.plist | 39 +++++++ MacDirStat/MacDirStat-AppStore.entitlements | 14 +++ Sources/App/ContentView.swift | 10 +- Sources/App/MacDirStatApp.swift | 27 ++++- docs/adr/0002-two-distribution-tracks.md | 74 +++++++++++++ 6 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 MacDirStat/Info-AppStore.plist create mode 100644 MacDirStat/MacDirStat-AppStore.entitlements create mode 100644 docs/adr/0002-two-distribution-tracks.md diff --git a/MacDirStat.xcodeproj/project.pbxproj b/MacDirStat.xcodeproj/project.pbxproj index b3b3ce2..a55c5a8 100644 --- a/MacDirStat.xcodeproj/project.pbxproj +++ b/MacDirStat.xcodeproj/project.pbxproj @@ -299,6 +299,7 @@ AA0000000000000000000002 /* Frameworks */, 65FA6119905E087A5E53253D /* Sources */, 7997A58CB6F97DD5FFDE7900 /* Resources */, + A5C0DE0000000000000003AA /* Strip Sparkle (App Store) */, ); buildRules = ( ); @@ -359,6 +360,23 @@ runOnlyForDeploymentPostprocessing = 0; }; /* 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 */ = { @@ -414,7 +432,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = L4UH9K7AW4; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; @@ -425,7 +443,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.macdirstat.app; PRODUCT_NAME = DirStat; SWIFT_EMIT_LOC_STRINGS = YES; @@ -433,6 +451,39 @@ }; name = Release; }; + 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_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 10; + 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; + 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; + }; 683105B965893FCC643C4ECF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -442,7 +493,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = L4UH9K7AW4; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; @@ -453,7 +504,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.macdirstat.app; PRODUCT_NAME = DirStat; SWIFT_EMIT_LOC_STRINGS = YES; @@ -579,6 +630,60 @@ }; name = Release; }; + 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; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -587,6 +692,7 @@ buildConfigurations = ( B74382A4D110BEFC1DEB55E4 /* Debug */, F901A40D62D95EAB754CE15B /* Release */, + A5C0DE0000000000000002AA /* AppStore */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -596,6 +702,7 @@ buildConfigurations = ( 683105B965893FCC643C4ECF /* Debug */, 4DC7B479A29173674AFD7BAC /* Release */, + A5C0DE0000000000000001AA /* AppStore */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/MacDirStat/Info-AppStore.plist b/MacDirStat/Info-AppStore.plist new file mode 100644 index 0000000..7265166 --- /dev/null +++ b/MacDirStat/Info-AppStore.plist @@ -0,0 +1,39 @@ + + + + + + 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 + CFBundleDocumentTypes + + + 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/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index 6924962..49cb4ee 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -202,7 +202,10 @@ 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. + if !Build.isAppStore, !vm.hasFullDiskAccess || (vm.deniedCount > 0 && !vm.isScanning) { FullDiskAccessBanner(hasFullDiskAccess: vm.hasFullDiskAccess, deniedCount: vm.deniedCount) { vm.showFDASheet = true } @@ -552,6 +555,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") { @@ -579,6 +586,7 @@ private struct DashboardSettingsView: View { caption("Without it, protected folders scan as 0 bytes and land in “Hidden & Unreadable Space”.") } } + } Divider() diff --git a/Sources/App/MacDirStatApp.swift b/Sources/App/MacDirStatApp.swift index d76d2a1..45210d7 100644 --- a/Sources/App/MacDirStatApp.swift +++ b/Sources/App/MacDirStatApp.swift @@ -1,15 +1,34 @@ 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 +} + @main struct MacDirStatApp: App { @StateObject private var vm = ScanViewModel() + #if !APPSTORE private let updaterController = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil ) + #endif var body: some Scene { WindowGroup("DirStat") { @@ -51,16 +70,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/docs/adr/0002-two-distribution-tracks.md b/docs/adr/0002-two-distribution-tracks.md new file mode 100644 index 0000000..4cf1db0 --- /dev/null +++ b/docs/adr/0002-two-distribution-tracks.md @@ -0,0 +1,74 @@ +# 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. + +## 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`. From 73aeac900309e171940a83d7b1988af9ef1b5b6c Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 25 Aug 2026 12:00:07 +0300 Subject: [PATCH 02/19] fix(sandbox): resume the last scanned folder from a security-scoped bookmark "Auto-scan last folder" stored a bare path and reopened it on launch. A sandboxed build loses access to that folder the moment the process exits, so in an App Store build the setting would silently open onto an empty chart. rememberLastScannedFolder stores a security-scoped bookmark alongside the path while the scope is still held; resolveLastScannedFolder reads the bookmark in App Store builds and the path elsewhere, so the Developer ID behaviour is unchanged. Reopening a .mdscan archive clears the bookmark: it records the archive's scannedPath but grants no access to that folder, so leaving a older bookmark in place would auto-scan a different folder than the one the stored path names. --- Sources/ViewModels/ScanViewModel.swift | 59 ++++++++++++++++++++++++-- Tests/LastScannedFolderTests.swift | 51 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 Tests/LastScannedFolderTests.swift diff --git a/Sources/ViewModels/ScanViewModel.swift b/Sources/ViewModels/ScanViewModel.swift index 7e95fca..397bcc7 100644 --- a/Sources/ViewModels/ScanViewModel.swift +++ b/Sources/ViewModels/ScanViewModel.swift @@ -87,17 +87,64 @@ 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: - 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() { + // 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 + } // 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) @@ -171,7 +218,7 @@ public final class ScanViewModel: ObservableObject { hasStaleResults = false layoutGeneration += 1 // invalidate any in-progress layout scanURL = url - UserDefaults.standard.set(url.path, forKey: "lastScannedPath") + Self.rememberLastScannedFolder(url) isReadOnlySnapshot = false snapshotDate = nil tree = nil @@ -1208,6 +1255,10 @@ public final class ScanViewModel: ObservableObject { highlightedExtension = nil errorMessage = nil UserDefaults.standard.set(metadata.scannedPath, forKey: "lastScannedPath") + // Reopening an archive gives no access to the folder it describes, so any + // bookmark from an earlier live scan is now out of step with the recorded + // path. Drop it rather than let auto-scan resume a different folder. + UserDefaults.standard.removeObject(forKey: Self.lastScannedBookmarkKey) self.tree = tree let rootNode = FileNode(tree: tree, index: tree.rootIndex) 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()) + } +} From 9e77373573da783c57f6d6bee9949a547a0e2a70 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 25 Aug 2026 12:00:07 +0300 Subject: [PATCH 03/19] build: check in both distribution schemes Archiving needs to pick a configuration, and the auto-generated scheme archived Release, so Product > Archive would have quietly produced the Developer ID build. "DirStat (App Store)" archives the AppStore configuration instead. MacDirStat has to be checked in alongside it: Xcode stops auto-generating schemes as soon as one is shared, and release.yml archives -scheme MacDirStat to build the notarized DMG. --- .../xcschemes/DirStat (App Store).xcscheme | 80 ++++++++++++++++++ .../xcschemes/MacDirStat.xcscheme | 81 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 MacDirStat.xcodeproj/xcshareddata/xcschemes/DirStat (App Store).xcscheme create mode 100644 MacDirStat.xcodeproj/xcshareddata/xcschemes/MacDirStat.xcscheme 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 3fcd1d4450cdae4d583c16d8f240c44bdd0aed3b Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 25 Aug 2026 12:07:41 +0300 Subject: [PATCH 04/19] build(appstore): match how 1.0 was actually configured Checked ~/Documents/Projects/MacDirStat-appstore, the second clone the shipped store builds were cut from. Its App Store adaptation lives as uncommitted working-tree edits, and three of them were missing here: - quit on last window closed. The store build did this; the Developer ID build must not, because its Sparkle updater needs to stay resident. One AppDelegate, returning Build.isAppStore. - an in-app Privacy Policy link. App Review wants a reachable policy; added unconditionally since the Developer ID build benefits too (https://ti-03.github.io/MacDirStat/privacy, verified 200). - no CFBundleDocumentTypes. It was an empty array either way. Deliberate deviation: that clone requests files.user-selected.read-only, which 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 keeps read-write. Flip one line in MacDirStat-AppStore.entitlements to match 1.0 exactly instead. --- MacDirStat.xcodeproj/project.pbxproj | 145 ++++++++++++++------------- MacDirStat/Info-AppStore.plist | 2 - Sources/App/ContentView.swift | 4 + Sources/App/MacDirStatApp.swift | 10 ++ 4 files changed, 87 insertions(+), 74 deletions(-) diff --git a/MacDirStat.xcodeproj/project.pbxproj b/MacDirStat.xcodeproj/project.pbxproj index a55c5a8..5218a57 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 = ( @@ -310,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 +360,7 @@ runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ + /* Begin PBXShellScriptBuildPhase section */ A5C0DE0000000000000003AA /* Strip Sparkle (App Store) */ = { isa = PBXShellScriptBuildPhase; @@ -451,20 +452,20 @@ }; name = Release; }; - A5C0DE0000000000000001AA /* AppStore */ = { + 683105B965893FCC643C4ECF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = MacDirStat/MacDirStat-AppStore.entitlements; + CODE_SIGN_ENTITLEMENTS = MacDirStat/MacDirStat.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = L4UH9K7AW4; ENABLE_HARDENED_RUNTIME = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "MacDirStat/Info-AppStore.plist"; + INFOPLIST_FILE = MacDirStat/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -472,32 +473,28 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.4; - 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; + name = Debug; }; - 683105B965893FCC643C4ECF /* Debug */ = { + A5C0DE0000000000000001AA /* AppStore */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = MacDirStat/MacDirStat.entitlements; + 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 = 10; DEVELOPMENT_TEAM = L4UH9K7AW4; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = MacDirStat/Info.plist; + INFOPLIST_FILE = "MacDirStat/Info-AppStore.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -505,14 +502,19 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.4; + 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 = Debug; + name = AppStore; }; - B74382A4D110BEFC1DEB55E4 /* Debug */ = { + A5C0DE0000000000000002AA /* AppStore */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -546,18 +548,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -566,16 +561,14 @@ GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; }; - name = Debug; + name = AppStore; }; - F901A40D62D95EAB754CE15B /* Release */ = { + B74382A4D110BEFC1DEB55E4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -609,12 +602,18 @@ 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; + DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -623,14 +622,16 @@ GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; - name = Release; + name = Debug; }; - A5C0DE0000000000000002AA /* AppStore */ = { + F901A40D62D95EAB754CE15B /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -667,6 +668,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -682,7 +684,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; - name = AppStore; + name = Release; }; /* End XCBuildConfiguration section */ @@ -727,7 +729,6 @@ productName = Sparkle; }; /* End XCSwiftPackageProductDependency section */ - }; rootObject = DB931C7330675EECD13E725C /* Project object */; } diff --git a/MacDirStat/Info-AppStore.plist b/MacDirStat/Info-AppStore.plist index 7265166..cb8c385 100644 --- a/MacDirStat/Info-AppStore.plist +++ b/MacDirStat/Info-AppStore.plist @@ -33,7 +33,5 @@ public.app-category.utilities NSPrincipalClass NSApplication - CFBundleDocumentTypes - diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index 49cb4ee..28a6ae1 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -612,6 +612,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 45210d7..04b1222 100644 --- a/Sources/App/MacDirStatApp.swift +++ b/Sources/App/MacDirStatApp.swift @@ -19,8 +19,18 @@ enum Build { #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( From 81ae9153277518d0e6c44c3aea64c072f88c49e9 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 25 Aug 2026 12:08:07 +0300 Subject: [PATCH 05/19] docs(adr): record the superseded MacDirStat-appstore clone and its read-only entitlement --- docs/adr/0002-two-distribution-tracks.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/adr/0002-two-distribution-tracks.md b/docs/adr/0002-two-distribution-tracks.md index 4cf1db0..e16d5a5 100644 --- a/docs/adr/0002-two-distribution-tracks.md +++ b/docs/adr/0002-two-distribution-tracks.md @@ -22,6 +22,16 @@ 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` @@ -72,3 +82,11 @@ ever gets fragile, the second target is the upgrade path. "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. From 97ef5a2fb1c55195266a662c1d7319e9c1d40f50 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 25 Aug 2026 12:10:27 +0300 Subject: [PATCH 06/19] build(appstore): declare ITSAppUsesNonExemptEncryption=false The only crypto is CryptoKit SHA256 fingerprinting for duplicate detection. Hashing is not encryption and nothing else encrypts, so the answer is no every time; declaring it skips the export-compliance question on each upload. --- MacDirStat/Info-AppStore.plist | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MacDirStat/Info-AppStore.plist b/MacDirStat/Info-AppStore.plist index cb8c385..d3ad9e5 100644 --- a/MacDirStat/Info-AppStore.plist +++ b/MacDirStat/Info-AppStore.plist @@ -33,5 +33,11 @@ public.app-category.utilities NSPrincipalClass NSApplication + + ITSAppUsesNonExemptEncryption + From 863a5ac1b168fb2717908abc7a5e2df854bb1bed Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 8 Sep 2026 10:13:24 +0300 Subject: [PATCH 07/19] fix(scan): count every byte by default; collapse generated trees instead of excluding them The 1.3 factory defaults excluded .git, node_modules, DerivedData and .Trash and skipped every dotfile, so a real 7.5 GB project scanned as 40 MB. Nothing is excluded now and hidden entries are counted; known generated trees (node_modules, .git, Pods, .next, .venv, ...) are auto-summarized into one node so totals are right and the treemap stays browsable. Because shipped builds persisted the old list into the preferences file, a one-time migration resets exactly that legacy value; customised lists and the per-user hidden-files toggle are left alone. Shared ScanDefaults replaces four hand-copied default strings, and the splice-refresh guard now uses the same generated-tree list as the scanner. Verified against du on a 7.5 GB project: identical byte total. --- Sources/App/ContentView.swift | 6 +- Sources/Scanner/AtomicDirectorySummary.swift | 13 ++- Sources/Scanner/FileScanner.swift | 26 ++++- Sources/ViewModels/ScanViewModel.swift | 33 ++++-- Tests/AutoSummaryTests.swift | 7 +- Tests/DefaultScanCoverageTests.swift | 108 +++++++++++++++++++ 6 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 Tests/DefaultScanCoverageTests.swift diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index 28a6ae1..5892e70 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -479,8 +479,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" @@ -541,7 +541,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.") } } 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..b05b224 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). diff --git a/Sources/ViewModels/ScanViewModel.swift b/Sources/ViewModels/ScanViewModel.swift index 397bcc7..371f86a 100644 --- a/Sources/ViewModels/ScanViewModel.swift +++ b/Sources/ViewModels/ScanViewModel.swift @@ -60,14 +60,15 @@ public final class ScanViewModel: ObservableObject { private var fdaSheetShownThisLaunch = false 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() @@ -92,6 +93,22 @@ public final class ScanViewModel: ObservableObject { } } + // MARK: - Legacy defaults migration + + /// Releases up to 1.3 shipped `.git,node_modules,DerivedData,.Trash` as the + /// factory exclusion list and persisted it into the preferences file, so a + /// new factory default alone never reaches existing installs. Resets that + /// exact legacy value once; a user who has customised the list, or who + /// re-enters the legacy list after the migration ran, is left alone. + nonisolated static func migrateLegacyExclusionDefault(in defaults: UserDefaults) { + let flag = "legacyExclusionDefaultMigrated" + guard !defaults.bool(forKey: flag) else { return } + defaults.set(true, forKey: flag) + if defaults.string(forKey: "excludedFolderNames") == ".git,node_modules,DerivedData,.Trash" { + defaults.removeObject(forKey: "excludedFolderNames") + } + } + // MARK: - Last scanned folder static let lastScannedBookmarkKey = "lastScannedBookmark" @@ -471,7 +488,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 @@ -517,7 +534,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) @@ -602,7 +619,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 } } @@ -728,9 +745,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. @@ -739,7 +754,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, 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..e7f0206 --- /dev/null +++ b/Tests/DefaultScanCoverageTests.swift @@ -0,0 +1,108 @@ +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_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") + } + + func test_shared_defaults_are_used_everywhere() { + XCTAssertEqual(ScanDefaults.excludedFolderNames, "") + XCTAssertTrue(ScanDefaults.showHiddenFiles) + XCTAssertTrue(knownGeneratedDirectoryNames.isSuperset(of: ["node_modules", ".git", "Pods", ".next"])) + } +} From 4b9f71689e4cae96855ea59d56795f0e496aa116 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 8 Sep 2026 10:17:37 +0300 Subject: [PATCH 08/19] fix(scan): resolve symlinked roots, probe Full Disk Access with open(2), stop counting purgeable space as unreadable - A symlinked scan root (/tmp, a Finder alias folder) failed with a raw SkipError description; it is now resolved with realpath(3) and scanned as its target. Subdirectory symlinks are still never followed. - Full Disk Access is detected by actually opening a TCC-protected file, on either the system or the per-user TCC database, instead of access(2) on one path (issue #24: grant present, warning still shown). - The synthetic root child for a volume scan now subtracts the 'important usage' availability Finder reports, so purgeable space (local snapshots, evictable caches) is no longer reported as unreadable. --- Sources/Scanner/FileScanner.swift | 17 +++++++++++++++ Sources/ViewModels/ScanViewModel.swift | 30 +++++++++++++++++++++----- Tests/DefaultScanCoverageTests.swift | 15 +++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/Sources/Scanner/FileScanner.swift b/Sources/Scanner/FileScanner.swift index b05b224..ddcdd32 100644 --- a/Sources/Scanner/FileScanner.swift +++ b/Sources/Scanner/FileScanner.swift @@ -121,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() @@ -153,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 371f86a..536d48d 100644 --- a/Sources/ViewModels/ScanViewModel.swift +++ b/Sources/ViewModels/ScanViewModel.swift @@ -162,9 +162,23 @@ public final class ScanViewModel: ObservableObject { hasFullDiskAccess = true return } - // 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) + 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 @@ -935,9 +949,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( diff --git a/Tests/DefaultScanCoverageTests.swift b/Tests/DefaultScanCoverageTests.swift index e7f0206..755bccc 100644 --- a/Tests/DefaultScanCoverageTests.swift +++ b/Tests/DefaultScanCoverageTests.swift @@ -80,6 +80,21 @@ final class DefaultScanCoverageTests: XCTestCase { 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)! From cd092d219e9372492cb984aa7d76312214c201f7 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 8 Sep 2026 10:22:26 +0300 Subject: [PATCH 09/19] fix(ui): surface errors, keep the chart drawable and navigable, stop dead-end actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every failure (scan, save, open, compare, export, trash) set errorMessage but no view ever showed it; ContentView now presents it as an alert, and trash failures report which items could not be moved. - Radial layout skipped any arc under 0.018 rad, so a flat folder with ~350+ similar files drew an empty chart labelled 'no files'. The cutoff is now a 0.75 pt arc length at the outer edge; the empty state keys off the node's children instead of the cell list; the center Back control always shows. - Auto-summarized folders (node_modules, .git, ...) can no longer be drilled into (empty chart, hidden Back); they are selected instead. Tooltips and rows show '12,431 files, summarized' rather than '0 items'. - Clicking empty canvas clears the selection, which also pauses the 30 fps pulse timer that otherwise ran forever after the first click. - The synthetic 'Hidden & Unreadable Space' node no longer offers Reveal, Copy Path or Move to Trash anywhere, and no longer lands in the legend as '(directory)'; extensionless files are labelled '(no extension)'. - Legend recolors with the color scheme; sizes refresh when the unit style changes; ByteFormatter steps up at unit boundaries (no more '1000.0 KB'). - File list: readable selected rows in light mode, expansion state resets when the tree is replaced, sidebar Duplicates link that stranded the user in the detail column removed, duplicate groups keyed by stable group id. - Export CSV disabled with no scan loaded; toolbar shows 'Comparing…'. --- Sources/App/ContentView.swift | 20 ++++++- Sources/App/MacDirStatApp.swift | 1 + Sources/Layout/TreemapLayout.swift | 10 +++- Sources/Model/FileNode.swift | 16 ++++++ Sources/ViewModels/ScanViewModel.swift | 36 +++++++++---- .../DirectoryTree/DirectoryTreeView.swift | 53 ++++++++++--------- Sources/Views/Duplicates/DuplicatesView.swift | 21 +++++--- .../ExtensionList/ExtensionListView.swift | 18 ++++--- Sources/Views/Shared/ByteFormatter.swift | 43 +++++++-------- Sources/Views/Sidebar/SidebarView.swift | 16 ------ Sources/Views/Treemap/TreemapView.swift | 52 +++++++++++------- Tests/TreemapLayoutTests.swift | 17 ++++++ 12 files changed, 189 insertions(+), 114 deletions(-) diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index 5892e70..85bc8ee 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 } } diff --git a/Sources/App/MacDirStatApp.swift b/Sources/App/MacDirStatApp.swift index 04b1222..5ef6aa0 100644 --- a/Sources/App/MacDirStatApp.swift +++ b/Sources/App/MacDirStatApp.swift @@ -72,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) 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/ViewModels/ScanViewModel.swift b/Sources/ViewModels/ScanViewModel.swift index 536d48d..eb8c8d5 100644 --- a/Sources/ViewModels/ScanViewModel.swift +++ b/Sources/ViewModels/ScanViewModel.swift @@ -994,7 +994,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() } } @@ -1014,9 +1016,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) @@ -1045,16 +1056,23 @@ public final class ScanViewModel: ObservableObject { var trashedIndices: [Int] = [] trashedIndices.reserveCapacity(nodes.count) - for node in nodes where ObjectIdentifier(node.tree) == ObjectIdentifier(currentTree) { + var failures: [String] = [] + for node in nodes where ObjectIdentifier(node.tree) == ObjectIdentifier(currentTree) && !node.isSynthetic { 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. + // 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("\(node.name): \(error.localizedDescription)") } } + if !failures.isEmpty { + let shown = failures.prefix(3).joined(separator: "\n") + let more = failures.count > 3 ? "\n…and \(failures.count - 3) more" : "" + errorMessage = "Couldn't move to Trash:\n\(shown)\(more)" + } guard !trashedIndices.isEmpty else { return false } pruneTree(afterTrashing: trashedIndices, from: currentTree) @@ -1167,7 +1185,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 } @@ -1175,7 +1193,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, diff --git a/Sources/Views/DirectoryTree/DirectoryTreeView.swift b/Sources/Views/DirectoryTree/DirectoryTreeView.swift index 187c675..39db2c8 100644 --- a/Sources/Views/DirectoryTree/DirectoryTreeView.swift +++ b/Sources/Views/DirectoryTree/DirectoryTreeView.swift @@ -14,25 +14,27 @@ struct DirectoryTreeView: View { Text(node.name).fontWeight(.semibold) Text(ByteFormatter.string(from: node.size)) .foregroundStyle(.secondary) - 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") - } - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(node.name, forType: .string) - } label: { - Label("Copy Name", systemImage: "textformat") + if !node.isSynthetic { + 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") + } + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(node.name, forType: .string) + } label: { + Label("Copy Name", systemImage: "textformat") + } } - if node.isDirectory { + if node.isDrillable { Divider() Button { vm.drillDown(into: node) @@ -40,7 +42,7 @@ struct DirectoryTreeView: View { Label("Open in Chart", systemImage: "arrow.down.right.circle") } } - if !vm.isReadOnlySnapshot { + if !vm.isReadOnlySnapshot && !node.isSynthetic { Divider() Button(role: .destructive) { vm.trashNode(node) @@ -50,6 +52,7 @@ struct DirectoryTreeView: View { } } } + .id(ObjectIdentifier(root.tree)) .listStyle(.inset) .scrollContentBackground(.hidden) .background(.ultraThinMaterial) @@ -99,17 +102,17 @@ private struct NodeRow: View { .font(.system(size: 12.5)) .lineLimit(1) .truncationMode(.tail) - .foregroundStyle(isSelected ? .white : .primary) + .foregroundStyle(.primary) // Inline extension badge for files if !node.isDirectory, !node.fileExtension.isEmpty { Text(node.fileExtension.uppercased()) .font(.system(size: 7.5, weight: .bold, design: .monospaced)) - .foregroundStyle(isSelected ? Color.white.opacity(0.65) : Color.secondary.opacity(0.5)) + .foregroundStyle(Color.secondary.opacity(0.5)) .padding(.horizontal, 3) .padding(.vertical, 1.5) .background( - isSelected ? Color.white.opacity(0.15) : Color.primary.opacity(0.07), + Color.primary.opacity(0.07), in: RoundedRectangle(cornerRadius: 3) ) .fixedSize() @@ -140,7 +143,7 @@ private struct NodeRow: View { if node.isAutoSummarized { Text("(\(node.descendantFileCount) files, summarized)") .font(.system(size: 9.5)) - .foregroundStyle(isSelected ? Color.white.opacity(0.55) : Color.secondary.opacity(0.65)) + .foregroundStyle(Color.secondary.opacity(0.65)) .lineLimit(1) .fixedSize() .help("Collapsed to keep the scan fast: contents aren't browsable individually") @@ -149,7 +152,7 @@ private struct NodeRow: View { // Size Text(ByteFormatter.string(from: node.size)) .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(isSelected ? Color.white.opacity(0.9) : .primary) + .foregroundStyle(.primary) .monospacedDigit() .lineLimit(1) .fixedSize() @@ -157,7 +160,7 @@ private struct NodeRow: View { // Percentage of parent — fixed width so sizes stay aligned Text(percentageOfParent ?? "") .font(.system(size: 10)) - .foregroundStyle(isSelected ? Color.white.opacity(0.55) : Color.secondary.opacity(0.65)) + .foregroundStyle(Color.secondary.opacity(0.65)) .monospacedDigit() .frame(width: 36, alignment: .trailing) .lineLimit(1) diff --git a/Sources/Views/Duplicates/DuplicatesView.swift b/Sources/Views/Duplicates/DuplicatesView.swift index 1a64fc5..dd5b782 100644 --- a/Sources/Views/Duplicates/DuplicatesView.swift +++ b/Sources/Views/Duplicates/DuplicatesView.swift @@ -3,7 +3,9 @@ import SwiftUI struct DuplicatesView: View { @EnvironmentObject private var vm: ScanViewModel // Expanded state lives here so LazyVStack rows don't need @State on init - @State private var expanded: Set = [] + // 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/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/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, From 4ebf13e39edd76bb39183805ddce33273d75b6f6 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 8 Sep 2026 10:26:27 +0300 Subject: [PATCH 10/19] fix(vm): stop stale async work from clobbering a newer scan; keep duplicate finder honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Duplicate detection grouped by allocated size and treated a 64 KB quick hash as conclusive whenever that size was small. Sparse or APFS-compressed files with identical heads and different tails were reported as duplicates, so 'Delete All Duplicates' could trash a distinct file. The quick hash now records whether it reached end-of-file and only then counts as final. - A scan generation counter guards every main-actor resume after an await: a scan that finishes tagging after the user started another no longer installs its tree, watches the old folder or leaves its hashing running; a slow archive decode no longer flips a newer live scan read-only. - Live-refresh splices (disk walk + full record copy per FSEvents batch) run off the main actor, and are dropped if the tree changed meanwhile, so a splice can no longer resurrect a folder the user just trashed. - Trashing while duplicate detection was still running used to leave 'Scanning for duplicates…' spinning forever; detection now finishes first. - Duplicate rows and list rows that outlive a splice are resolved by path when trashing instead of being silently ignored. - Opening an archive cancels the running scanner, and no longer rewrites the auto-scan breadcrumb to the archive's recorded path. - recomputeLayout clears the busy flag when it has no size to lay out yet (auto-scan on launch showed 'Building treemap…' indefinitely). --- Sources/Duplicates/DuplicateDetector.swift | 29 ++++-- Sources/ViewModels/ScanViewModel.swift | 103 ++++++++++++++++----- Tests/DuplicateDetectorTests.swift | 42 +++++++++ 3 files changed, 144 insertions(+), 30 deletions(-) diff --git a/Sources/Duplicates/DuplicateDetector.swift b/Sources/Duplicates/DuplicateDetector.swift index d271050..cc3f55e 100644 --- a/Sources/Duplicates/DuplicateDetector.swift +++ b/Sources/Duplicates/DuplicateDetector.swift @@ -40,13 +40,16 @@ public actor DuplicateDetector { 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) @@ -133,7 +136,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 +148,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/ViewModels/ScanViewModel.swift b/Sources/ViewModels/ScanViewModel.swift index eb8c8d5..814ecc8 100644 --- a/Sources/ViewModels/ScanViewModel.swift +++ b/Sources/ViewModels/ScanViewModel.swift @@ -58,6 +58,11 @@ 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) @@ -248,6 +253,8 @@ public final class ScanViewModel: ObservableObject { isWatching = false hasStaleResults = false layoutGeneration += 1 // invalidate any in-progress layout + scanGeneration += 1 + let generation = scanGeneration scanURL = url Self.rememberLastScannedFolder(url) isReadOnlySnapshot = false @@ -309,12 +316,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") { @@ -352,6 +362,7 @@ public final class ScanViewModel: ObservableObject { public func cancelScan() { Task { await scanner.cancel() } + scanGeneration += 1 scanTask?.cancel() extensionTask?.cancel() duplicateTask?.cancel() @@ -434,18 +445,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 } @@ -666,6 +690,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) { @@ -1057,10 +1084,21 @@ public final class ScanViewModel: ObservableObject { var trashedIndices: [Int] = [] trashedIndices.reserveCapacity(nodes.count) var failures: [String] = [] - for node in nodes where ObjectIdentifier(node.tree) == ObjectIdentifier(currentTree) && !node.isSynthetic { + 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 + } do { - try FileManager.default.trashItem(at: node.url, resultingItemURL: nil) - trashedIndices.append(node.index) + try FileManager.default.trashItem(at: URL(fileURLWithPath: currentTree.path(of: index)), resultingItemURL: nil) + trashedIndices.append(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 @@ -1125,11 +1163,20 @@ public final class ScanViewModel: ObservableObject { await MainActor.run { self?.extensionSummaries = summaries } } + // 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. + let needsDetection = !duplicatesReady duplicateTask?.cancel() duplicateTask = Task.detached(priority: .utility) { [weak self] in + if needsDetection { await DuplicateDetector().detect(in: newTree) } + guard !Task.isCancelled else { return } let groups = Self.buildDuplicateGroups(tree: newTree) guard !Task.isCancelled else { return } - await MainActor.run { self?.duplicateGroups = groups } + await MainActor.run { + self?.duplicatesReady = true + self?.duplicateGroups = groups + } } Task { await recomputeLayout() } @@ -1205,7 +1252,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 @@ -1262,6 +1314,9 @@ public final class ScanViewModel: ObservableObject { // surfaces as `errorMessage` rather than crashing or hanging — see // `ScanArchive.validate()`. public func openArchive(from url: URL) { + Task { await scanner.cancel() } + scanGeneration += 1 + let generation = scanGeneration scanTask?.cancel() extensionTask?.cancel() duplicateTask?.cancel() @@ -1283,11 +1338,15 @@ public final class ScanViewModel: ObservableObject { let archive = try decoder.decode(ScanArchive.self, from: data) try archive.validate() let tree = archive.makeTree() - await MainActor.run { self?.applyOpenedArchive(tree: tree, metadata: archive.metadata) } + await MainActor.run { + guard let self, self.scanGeneration == generation else { return } + self.applyOpenedArchive(tree: tree, metadata: archive.metadata) + } } catch { await MainActor.run { - self?.isScanning = false - self?.errorMessage = "Couldn't open scan: \(error.localizedDescription)" + guard let self, self.scanGeneration == generation else { return } + self.isScanning = false + self.errorMessage = "Couldn't open scan: \(error.localizedDescription)" } } } @@ -1307,11 +1366,9 @@ public final class ScanViewModel: ObservableObject { drillStack = [] highlightedExtension = nil errorMessage = nil - UserDefaults.standard.set(metadata.scannedPath, forKey: "lastScannedPath") - // Reopening an archive gives no access to the folder it describes, so any - // bookmark from an earlier live scan is now out of step with the recorded - // path. Drop it rather than let auto-scan resume a different folder. - UserDefaults.standard.removeObject(forKey: Self.lastScannedBookmarkKey) + // Reopening an archive is not a scan of that folder: leave the + // "auto-scan last folder" breadcrumb alone so the next launch does not + // start walking whatever path the archive happened to record. self.tree = tree let rootNode = FileNode(tree: tree, index: tree.rootIndex) diff --git a/Tests/DuplicateDetectorTests.swift b/Tests/DuplicateDetectorTests.swift index 7912c2e..30c3e06 100644 --- a/Tests/DuplicateDetectorTests.swift +++ b/Tests/DuplicateDetectorTests.swift @@ -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) + await DuplicateDetector().detect(in: tree) + + 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) From 81a9ca9e7b1ba5ac7a2d129c8d04590082717fc5 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 8 Sep 2026 10:39:43 +0300 Subject: [PATCH 11/19] fix(chart): labels never overlap, spill, or hide under the center disc; wider file list Text was measured inside a capped width but drawn at its natural width, so long names painted over neighbouring arcs. Labels are now measured at natural size, middle-truncated to fit (dropped entirely when fewer than 7 characters or 30% of the name would survive), placed with collision avoidance against every label already drawn, and slid outward within their own band when they would run under the center disc. The file list panel min width goes from 220 to 290 pt so names keep about 120 pt next to the fixed size and percent columns. --- CHANGELOG.md | 54 ++++++++ Sources/App/ContentView.swift | 7 +- Sources/Views/Treemap/TreemapRenderer.swift | 143 ++++++++++++++------ 3 files changed, 158 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2a6fe..3b5388f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,60 @@ All notable changes to MacDirStat are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.4.0] - Unreleased + +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. +- 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.3.0] - 2026-08-04 A performance-focused release: the scanner, the in-memory tree, and live diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index 85bc8ee..d89ca54 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -241,10 +241,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() diff --git a/Sources/Views/Treemap/TreemapRenderer.swift b/Sources/Views/Treemap/TreemapRenderer.swift index ec8864d..f46a413 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,115 @@ 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) + let pt = CGPoint(x: center.x + r * cos(cell.midAngle), y: center.y + r * sin(cell.midAngle)) + 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) * cos(cell.midAngle), y: center.y + (r + shift) * sin(cell.midAngle)) + 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 From 7647f0d76e69a11fc48a4bf9a9327dcf771a38a4 Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 8 Sep 2026 10:52:04 +0300 Subject: [PATCH 12/19] fix: incremental duplicate detection, trash off-main, summarized dirs in comparisons, honest gap label - DuplicateDetector.detect returns assignments instead of writing records off the main actor; callers apply them on main. An optional focus set limits a live-refresh re-run to the size buckets the changed subtree touches instead of re-hashing the whole tree every FSEvents batch. - trashNodes performs the moves in a detached task and prunes when they land; the tree is marked stale if it was replaced meanwhile. - ScanComparison suppresses descendants of a directory that is auto-summarized on either side and reports that directory's own grew/shrank row, instead of thousands of false 'removed' rows. - The volume-scan gap row is renamed 'System & Unreadable Space': with hidden files scanned, what remains is other container volumes (Preboot, Recovery, VM), purgeable snapshot space and metadata. Full boot-volume diagnostic: 854.9 GB over 8.5M items against a 907 GB container whose reachable volumes (Data + System) hold 885 GB. --- CHANGELOG.md | 15 +++ Sources/App/ContentView.swift | 2 +- Sources/Duplicates/DuplicateDetector.swift | 30 ++++- Sources/Model/FileTree.swift | 8 ++ Sources/Model/ScanComparison.swift | 29 ++++- Sources/ViewModels/ScanViewModel.swift | 139 +++++++++++++-------- Tests/DuplicateDetectorTests.swift | 39 +++++- Tests/FileTreePruneTests.swift | 7 +- Tests/ScanComparisonTests.swift | 37 ++++++ 9 files changed, 236 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5388f..d34d50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,21 @@ list of user-visible bugs found in an end-to-end audit are fixed. 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. +- 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 diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index d89ca54..a3673cc 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -602,7 +602,7 @@ 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”.") } } } diff --git a/Sources/Duplicates/DuplicateDetector.swift b/Sources/Duplicates/DuplicateDetector.swift index cc3f55e..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,7 +53,7 @@ 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) @@ -62,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) @@ -71,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]) { 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/ViewModels/ScanViewModel.swift b/Sources/ViewModels/ScanViewModel.swift index 814ecc8..93ceaaf 100644 --- a/Sources/ViewModels/ScanViewModel.swift +++ b/Sources/ViewModels/ScanViewModel.swift @@ -339,18 +339,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 @@ -478,7 +467,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, @@ -682,7 +671,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) } @@ -719,21 +708,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. @@ -964,6 +979,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 @@ -993,7 +1014,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. @@ -1077,13 +1098,16 @@ 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) - var failures: [String] = [] + 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 @@ -1096,24 +1120,41 @@ public final class ScanViewModel: ObservableObject { } else { continue } - do { - try FileManager.default.trashItem(at: URL(fileURLWithPath: currentTree.path(of: index)), resultingItemURL: nil) - trashedIndices.append(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("\(node.name): \(error.localizedDescription)") - } + targets.append((index, currentTree.path(of: index), node.name)) } - if !failures.isEmpty { - let shown = failures.prefix(3).joined(separator: "\n") - let more = failures.count > 3 ? "\n…and \(failures.count - 3) more" : "" - errorMessage = "Couldn't move to Trash:\n\(shown)\(more)" + 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 } @@ -1166,17 +1207,15 @@ public final class ScanViewModel: ObservableObject { // 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. - let needsDetection = !duplicatesReady - duplicateTask?.cancel() - duplicateTask = Task.detached(priority: .utility) { [weak self] in - if needsDetection { await DuplicateDetector().detect(in: newTree) } - guard !Task.isCancelled else { return } - let groups = Self.buildDuplicateGroups(tree: newTree) - guard !Task.isCancelled else { return } - await MainActor.run { - self?.duplicatesReady = true - self?.duplicateGroups = groups + 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() } diff --git a/Tests/DuplicateDetectorTests.swift b/Tests/DuplicateDetectorTests.swift index 30c3e06..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")! @@ -80,7 +80,7 @@ final class DuplicateDetectorTests: XCTestCase { root.size += child.size } let tree = FileTreeBuilder.build(from: root, rootPath: tmp.path) - await DuplicateDetector().detect(in: tree) + 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) @@ -107,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")! @@ -140,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")! @@ -171,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")! @@ -217,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 { @@ -234,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/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) + } +} From 4a7c0d8c663b1bffa4c824369a3c91b2e3d49bcf Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Tue, 8 Sep 2026 11:13:47 +0300 Subject: [PATCH 13/19] fix: migrate the persisted hide-hidden-files default, drop the banner once FDA is held, fast safety tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified in the installed Developer ID-signed build against the whole boot volume. The non-sandboxed app's preferences still carried 1.3's persisted showHiddenFiles=false, so 260 GB of dot-folders (~/.cache, ~/.ollama, .git, .Trash) were skipped; the one-time migration is now versioned and resets that legacy value too. With it the app shows 855.5 GB of files plus 25.7 GB of other-volume/snapshot space, 881.2 GB total, matching Finder. The 'Full Disk Access required' banner is no longer shown when the grant is held: the remaining denied folders are root-only (issue #24). Safety tagging walks the tree once building paths incrementally instead of allocating a URL per node, which had kept a 5.8M-item scan on 'Scanning…' for two minutes after the walk finished; the panel now says 'Preparing…' during that phase. --- CHANGELOG.md | 14 +++++- Sources/App/ContentView.swift | 10 ++-- Sources/Safety/SafetyAnalyzer.swift | 2 +- Sources/ViewModels/ScanViewModel.swift | 47 +++++++++++++------ .../DirectoryTree/DirectoryTreeView.swift | 5 +- Tests/DefaultScanCoverageTests.swift | 19 ++++++++ 6 files changed, 76 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d34d50d..b5dde01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,19 @@ list of user-visible bugs found in an end-to-end audit are fixed. 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. + 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 diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index a3673cc..6962001 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -220,8 +220,12 @@ struct ContentView: View { } // 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. - if !Build.isAppStore, !vm.hasFullDiskAccess || (vm.deniedCount > 0 && !vm.isScanning) { + // 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 } @@ -552,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") 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/ViewModels/ScanViewModel.swift b/Sources/ViewModels/ScanViewModel.swift index 93ceaaf..24962ff 100644 --- a/Sources/ViewModels/ScanViewModel.swift +++ b/Sources/ViewModels/ScanViewModel.swift @@ -101,17 +101,26 @@ public final class ScanViewModel: ObservableObject { // MARK: - Legacy defaults migration /// Releases up to 1.3 shipped `.git,node_modules,DerivedData,.Trash` as the - /// factory exclusion list and persisted it into the preferences file, so a - /// new factory default alone never reaches existing installs. Resets that - /// exact legacy value once; a user who has customised the list, or who - /// re-enters the legacy list after the migration ran, is left alone. + /// 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 flag = "legacyExclusionDefaultMigrated" - guard !defaults.bool(forKey: flag) else { return } - defaults.set(true, forKey: flag) - if defaults.string(forKey: "excludedFolderNames") == ".git,node_modules,DerivedData,.Trash" { + 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 @@ -1310,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..