feat: add contextual actions and recoverable application uninstall
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user