From 8414701b29f9edbb8ee3d82c045d7eccdc9ec86c Mon Sep 17 00:00:00 2001 From: Brandon Tuttle Date: Sun, 6 Sep 2026 12:46:41 -0600 Subject: [PATCH] fix: detect Full Disk Access without the user TCC.db path On macOS 27 the user TCC database no longer lives at ~/Library/Application Support/com.apple.TCC/TCC.db, so fileExists always failed and onboarding stayed on "Not enabled yet". Probe a real read of the system TCC database first, then fall back to listing protected app-data directories. Missing paths are skipped instead of treated as denial. Fixes #40 --- CHANGELOG.md | 4 + Sources/ClearDisk/DiskMonitor.swift | 14 +- Sources/ClearDisk/FullDiskAccessProbe.swift | 130 ++++++++++++++++++ .../FullDiskAccessProbeTests.swift | 67 +++++++++ 4 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 Sources/ClearDisk/FullDiskAccessProbe.swift create mode 100644 Tests/ClearDiskTests/FullDiskAccessProbeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2d54f..df4e450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to ClearDisk are documented here. +## [2.3.2] - 2026-09-06 +### Fixed +- Full Disk Access detection no longer depends on the user TCC database at `~/Library/Application Support/com.apple.TCC/TCC.db`, which does not exist on macOS 27. The probe now reads the system TCC database when present, then falls back to listing protected locations such as Safari, Mail, and Stocks app data. + ## [2.3.1] - 2026-09-03 ### Added - Turkish localization across the cleaner, disk scanner, settings, alerts, and runtime status messages. diff --git a/Sources/ClearDisk/DiskMonitor.swift b/Sources/ClearDisk/DiskMonitor.swift index b01d025..def3a8f 100644 --- a/Sources/ClearDisk/DiskMonitor.swift +++ b/Sources/ClearDisk/DiskMonitor.swift @@ -163,19 +163,9 @@ class DiskMonitor: ObservableObject { } } - /// macOS has no public API that reports Full Disk Access directly. Opening the current user's - /// TCC database is a small, read-only probe: it succeeds only after the installed app identity - /// has been enabled in Privacy & Security > Full Disk Access. No folder-specific consent dialog - /// is generated by this check. + /// macOS has no public API that reports Full Disk Access. See `FullDiskAccessProbe`. private static func canReadFullDiskAccessProbe() -> Bool { - let probeURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/com.apple.TCC/TCC.db") - guard FileManager.default.fileExists(atPath: probeURL.path), - let handle = FileHandle(forReadingAtPath: probeURL.path) else { - return false - } - handle.closeFile() - return true + FullDiskAccessProbe.isGranted() } func checkFullDiskAccess(completion: ((Bool) -> Void)? = nil) { diff --git a/Sources/ClearDisk/FullDiskAccessProbe.swift b/Sources/ClearDisk/FullDiskAccessProbe.swift new file mode 100644 index 0000000..a30a4f3 --- /dev/null +++ b/Sources/ClearDisk/FullDiskAccessProbe.swift @@ -0,0 +1,130 @@ +import Foundation + +/// macOS has no public API that reports Full Disk Access. ClearDisk used to treat a +/// successful open of `~/Library/Application Support/com.apple.TCC/TCC.db` as proof. +/// That user database is gone on macOS 27 (it moved into a ProtectedSystem container), +/// so `fileExists` always failed and onboarding stayed on "Not enabled yet". +/// +/// Probe real reads instead. Missing paths are skipped. The first existing candidate +/// decides: a successful read means granted, `Operation not permitted` means denied. +enum FullDiskAccessProbe { + enum Outcome { + case granted + case denied + case missing + } + + static var defaultCandidates: [URL] { + let home = FileManager.default.homeDirectoryForCurrentUser + return [ + URL(fileURLWithPath: "/Library/Application Support/com.apple.TCC/TCC.db"), + home.appendingPathComponent("Library/Application Support/com.apple.TCC/TCC.db"), + home.appendingPathComponent("Library/Safari"), + home.appendingPathComponent("Library/Mail"), + home.appendingPathComponent("Library/Containers/com.apple.stocks"), + ] + } + + static func isGranted(candidates: [URL] = defaultCandidates) -> Bool { + for url in candidates { + switch evaluate(url) { + case .granted: + return true + case .denied: + return false + case .missing: + continue + } + } + return false + } + + static func evaluate(_ url: URL, fileManager: FileManager = .default) -> Outcome { + do { + _ = try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + options: [] + ) + return .granted + } catch { + if isMissing(error) { + return readFileProbe(url) + } + if isNotADirectory(error) { + return readFileProbe(url) + } + if isPermissionDenied(error) { + return .denied + } + return readFileProbe(url) + } + } + + private static let sqliteHeader = Data("SQLite format 3\0".utf8) + + private static func readFileProbe(_ url: URL) -> Outcome { + do { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let header = try handle.read(upToCount: sqliteHeader.count) ?? Data() + if header.isEmpty { + return .denied + } + if url.pathExtension == "db" { + return header == sqliteHeader ? .granted : .denied + } + return .granted + } catch { + if isMissing(error) { + return .missing + } + return .denied + } + } + + private static func posixCode(_ error: Error) -> Int32? { + let nsError = error as NSError + if nsError.domain == NSPOSIXErrorDomain { + return Int32(nsError.code) + } + if let posix = error as? POSIXError { + return posix.code.rawValue + } + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + underlying.domain == NSPOSIXErrorDomain { + return Int32(underlying.code) + } + return nil + } + + private static func isMissing(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain { + if nsError.code == NSFileReadNoSuchFileError || nsError.code == NSFileNoSuchFileError { + return true + } + } + return posixCode(error) == ENOENT + } + + private static func isPermissionDenied(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain, nsError.code == NSFileReadNoPermissionError { + return true + } + if let code = posixCode(error), code == EPERM || code == EACCES { + return true + } + let text = nsError.localizedDescription.lowercased() + return text.contains("not permitted") || text.contains("permission denied") + } + + private static func isNotADirectory(_ error: Error) -> Bool { + if posixCode(error) == ENOTDIR { + return true + } + let text = (error as NSError).localizedDescription.lowercased() + return text.contains("not a directory") || text.contains("isn’t a directory") + } +} diff --git a/Tests/ClearDiskTests/FullDiskAccessProbeTests.swift b/Tests/ClearDiskTests/FullDiskAccessProbeTests.swift new file mode 100644 index 0000000..0de7b7d --- /dev/null +++ b/Tests/ClearDiskTests/FullDiskAccessProbeTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import ClearDisk + +final class FullDiskAccessProbeTests: XCTestCase { + private var scratch: URL! + + override func setUpWithError() throws { + scratch = FileManager.default.temporaryDirectory + .appendingPathComponent("ClearDiskFDA-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: scratch) + } + + func testMissingCandidatesAreDenied() { + let missing = scratch.appendingPathComponent("does-not-exist") + XCTAssertEqual(FullDiskAccessProbe.evaluate(missing), .missing) + XCTAssertFalse(FullDiskAccessProbe.isGranted(candidates: [missing])) + } + + func testReadableDirectoryCountsAsGranted() throws { + let dir = scratch.appendingPathComponent("Safari", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data("ok".utf8).write(to: dir.appendingPathComponent("Bookmarks.plist")) + XCTAssertEqual(FullDiskAccessProbe.evaluate(dir), .granted) + XCTAssertTrue(FullDiskAccessProbe.isGranted(candidates: [ + scratch.appendingPathComponent("missing-tcc.db"), + dir, + ])) + } + + func testSQLiteHeaderOnTCCDatabaseCountsAsGranted() throws { + let db = scratch.appendingPathComponent("TCC.db") + try Data("SQLite format 3\0more".utf8).write(to: db) + XCTAssertEqual(FullDiskAccessProbe.evaluate(db), .granted) + XCTAssertTrue(FullDiskAccessProbe.isGranted(candidates: [db])) + } + + func testEmptyProtectedFileCountsAsDenied() throws { + let db = scratch.appendingPathComponent("TCC.db") + try Data().write(to: db) + XCTAssertEqual(FullDiskAccessProbe.evaluate(db), .denied) + XCTAssertFalse(FullDiskAccessProbe.isGranted(candidates: [db])) + } + + func testMissingPathDoesNotMaskALaterReadableProbe() throws { + let dir = scratch.appendingPathComponent("Mail", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + XCTAssertTrue(FullDiskAccessProbe.isGranted(candidates: [ + scratch.appendingPathComponent("Library/Application Support/com.apple.TCC/TCC.db"), + dir, + ])) + } + + func testUnreadableDirectoryCountsAsDenied() throws { + let dir = scratch.appendingPathComponent("stocks", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: dir.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path) + } + XCTAssertEqual(FullDiskAccessProbe.evaluate(dir), .denied) + XCTAssertFalse(FullDiskAccessProbe.isGranted(candidates: [dir])) + } +}