Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 2 additions & 12 deletions Sources/ClearDisk/DiskMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
130 changes: 130 additions & 0 deletions Sources/ClearDisk/FullDiskAccessProbe.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
67 changes: 67 additions & 0 deletions Tests/ClearDiskTests/FullDiskAccessProbeTests.swift
Original file line number Diff line number Diff line change
@@ -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]))
}
}