import Darwin import Foundation import Security public struct UninstallCandidate: Identifiable, Sendable { public var id: URL { url } public let url: URL public let size: Int64? public let isSelectable: Bool public let defaultSelected: Bool public let reason: String? public init(url: URL, size: Int64?, isSelectable: Bool, defaultSelected: Bool, reason: String?) { self.url = url self.size = size self.isSelectable = isSelectable self.defaultSelected = isSelectable && defaultSelected self.reason = reason } } public enum UninstallDiscovery { /// Performs filesystem and signing reads synchronously; callers must run this off the UI thread. public static func scan( application: Application, home: URL = FileManager.default.homeDirectoryForCurrentUser, ownApplicationURL: URL? = Bundle.main.bundleURL ) throws -> [UninstallCandidate] { let app = application.url.standardizedFileURL let ownApp = ownApplicationURL?.standardizedFileURL.resolvingSymlinksInPath() let invalidReason = applicationProblem(at: app, ownApp: ownApp) let appCandidate = candidate(at: app, shared: false, ownApp: ownApp, problem: invalidReason) guard invalidReason == nil, let identifier = bundleIdentifier(at: app) else { return [appCandidate] } let extensions = embeddedExtensions(in: app) let identifiers = Set([identifier] + extensions.compactMap { bundleIdentifier(at: $0) }) if let ownApp, let ownIdentifier = bundleIdentifier(at: ownApp), identifiers.contains(ownIdentifier) { return [candidate( at: app, shared: false, ownApp: ownApp, problem: "This application shares Conjure's bundle identifier and could remove Conjure's own data." )] } let library = home.standardizedFileURL.appendingPathComponent("Library", isDirectory: true) var paths: [URL: Bool] = [:] for identifier in identifiers { let relativePaths = [ "Preferences/\(identifier).plist", "Caches/\(identifier)", "Application Support/\(identifier)", "Logs/\(identifier)", "Saved Application State/\(identifier).savedState", "Application Scripts/\(identifier)", "Containers/\(identifier)", ] for path in relativePaths { paths[library.appendingPathComponent(path)] = false } } for bundle in [app] + extensions { for group in signedApplicationGroups(at: bundle) { paths[library.appendingPathComponent("Group Containers/\(group)")] = true paths[library.appendingPathComponent("Application Scripts/\(group)")] = true } } let associated = paths.keys.sorted { $0.path < $1.path }.compactMap { url -> UninstallCandidate? in // Inspect ancestors before existence/size queries, so a linked Library never reads its target. guard ancestryProblem(at: url, includeLeaf: false) == nil, fileStatus(at: url) != nil else { return nil } return candidate(at: url, shared: paths[url] == true, ownApp: ownApp) } return [appCandidate] + associated } private static func applicationProblem(at url: URL, ownApp: URL?) -> String? { if let problem = protectedProblem(at: url, ownApp: ownApp) { return problem } if let problem = ancestryProblem(at: url) { return problem } guard url.isFileURL, url.pathExtension.lowercased() == "app", let status = fileStatus(at: url), status.st_mode & S_IFMT == S_IFDIR, let info = bundleInfo(at: url), info["CFBundlePackageType"] as? String == "APPL", let executable = info["CFBundleExecutable"] as? String, safeComponent(executable), let identifier = info["CFBundleIdentifier"] as? String, safeIdentifier(identifier) else { return "This application does not have a valid application bundle and bundle identifier." } let binary = url.appendingPathComponent("Contents/MacOS/\(executable)") guard ancestryProblem(at: binary) == nil, let binaryStatus = fileStatus(at: binary), binaryStatus.st_mode & S_IFMT == S_IFREG, FileManager.default.isExecutableFile(atPath: binary.path) else { return "This application does not have a valid executable." } return nil } private static func bundleInfo(at url: URL) -> [String: Any]? { let infoURL = url.appendingPathComponent("Contents/Info.plist") guard ancestryProblem(at: infoURL) == nil, let status = fileStatus(at: infoURL), status.st_mode & S_IFMT == S_IFREG, let data = try? Data(contentsOf: infoURL), let info = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) else { return nil } return info as? [String: Any] } private static func bundleIdentifier(at url: URL) -> String? { guard let identifier = bundleInfo(at: url)?["CFBundleIdentifier"] as? String, safeIdentifier(identifier) else { return nil } return identifier } private static func safeIdentifier(_ value: String) -> Bool { !value.isEmpty && value.utf8.count <= 255 && value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { !$0.isEmpty } && value.utf8.allSatisfy { (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 46 } } private static func safeComponent(_ value: String) -> Bool { !value.isEmpty && value != "." && value != ".." && !value.contains("/") && !value.contains("\0") } private static func embeddedExtensions(in app: URL) -> [URL] { var pending = [app] var result: [URL] = [] while let bundle = pending.popLast() { for relativePath in ["Contents/PlugIns", "Contents/Extensions"] { let directory = bundle.appendingPathComponent(relativePath) guard ancestryProblem(at: directory) == nil, let entries = try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: nil, options: [] ) else { continue } for entry in entries where entry.pathExtension.lowercased() == "appex" { guard ancestryProblem(at: entry) == nil, let status = fileStatus(at: entry), status.st_mode & S_IFMT == S_IFDIR, bundleIdentifier(at: entry) != nil else { continue } result.append(entry) pending.append(entry) } } } return result } private static func signedApplicationGroups(at url: URL) -> [String] { var code: SecStaticCode? guard SecStaticCodeCreateWithPath(url as CFURL, SecCSFlags(rawValue: 0), &code) == errSecSuccess, let code, SecStaticCodeCheckValidity(code, SecCSFlags(rawValue: 0), nil) == errSecSuccess else { return [] } var information: CFDictionary? guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess, let info = information as? [String: Any], let entitlements = info[kSecCodeInfoEntitlementsDict as String] as? [String: Any], let groups = entitlements["com.apple.security.application-groups"] as? [String] else { return [] } return groups.filter(safeIdentifier) } private static func candidate(at url: URL, shared: Bool, ownApp: URL?, problem: String? = nil) -> UninstallCandidate { let lock = problem ?? protectedProblem(at: url, ownApp: ownApp) ?? ancestryProblem(at: url) ?? removalProblem(at: url) let canReadSize = protectedProblem(at: url, ownApp: ownApp) == nil && ancestryProblem(at: url, includeLeaf: false) == nil let size = canReadSize ? logicalSize(at: url) : nil return UninstallCandidate( url: url, size: size, isSelectable: lock == nil, defaultSelected: !shared, reason: lock ?? (shared ? "Shared app-group data may be used by other applications; left unchecked." : nil) ) } private static func protectedProblem(at url: URL, ownApp: URL?) -> String? { for root in ["/System", "/usr", "/bin", "/sbin", "/Library/Apple"] { if contains(URL(fileURLWithPath: root), url) { return "System-managed applications and files cannot be uninstalled." } } if let ownApp, contains(url, ownApp) || contains(ownApp, url) { return "Conjure cannot uninstall itself or its own files." } return nil } private static func contains(_ parent: URL, _ child: URL) -> Bool { child.path == parent.path || child.path.hasPrefix(parent.path + "/") } private static func ancestryProblem(at url: URL, includeLeaf: Bool = true) -> String? { guard url.isFileURL, url.path.hasPrefix("/") else { return "This is not a local filesystem path." } let path = includeLeaf ? url : url.deletingLastPathComponent() var current = URL(fileURLWithPath: "/", isDirectory: true) for component in path.pathComponents.dropFirst() { current.appendPathComponent(component) if let status = fileStatus(at: current), status.st_mode & S_IFMT == S_IFLNK { return "This path contains a symbolic link; its target will not be followed or removed." } } return nil } private static func removalProblem(at url: URL) -> String? { guard let status = fileStatus(at: url) else { return "This item no longer exists or cannot be inspected." } guard status.st_uid == geteuid() else { return "This item is owned by another user or an administrator and cannot be uninstalled without authorization." } let immutableFlags = UInt32(UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND) guard status.st_flags & immutableFlags == 0 else { return "This item is locked or immutable and cannot be moved to Trash." } let parent = url.deletingLastPathComponent() if let parentStatus = fileStatus(at: parent), parentStatus.st_flags & immutableFlags != 0 { return "The containing folder is locked or immutable." } guard access(parent.path, W_OK | X_OK) == 0 else { return "You do not have permission to remove this item from its containing folder." } return nil } private static func fileStatus(at url: URL) -> stat? { var status = stat() return lstat(url.path, &status) == 0 ? status : nil } private static func logicalSize(at url: URL) -> Int64? { guard let status = fileStatus(at: url) else { return nil } if status.st_mode & S_IFMT != S_IFDIR { return Int64(status.st_size) } guard let children = try? FileManager.default.contentsOfDirectory( at: url, includingPropertiesForKeys: nil, options: [] ) else { return nil } var total: Int64 = 0 for child in children { guard let size = logicalSize(at: child) else { return nil } let sum = total.addingReportingOverflow(size) guard !sum.overflow else { return nil } total = sum.partialValue } return total } }