diff --git a/Assets/readme/activity.svg b/Assets/readme/activity.svg index d3f471e..c6f3774 100644 --- a/Assets/readme/activity.svg +++ b/Assets/readme/activity.svg @@ -1,5 +1,5 @@ -94 commits in the last 26 weeks +99 commits in the last 26 weeks May Jun @@ -191,8 +191,9 @@ 0 commits on 2026-10-06 0 commits on 2026-10-07 0 commits on 2026-10-08 +5 commits on 2026-10-09 -94 commits in the last 26 weeks · through 2026-10-08 +99 commits in the last 26 weeks · through 2026-10-09 Less diff --git a/Assets/readme/activity.zh.svg b/Assets/readme/activity.zh.svg index c4debf8..34e52db 100644 --- a/Assets/readme/activity.zh.svg +++ b/Assets/readme/activity.zh.svg @@ -1,5 +1,5 @@ -近 26 周共 94 次提交 +近 26 周共 99 次提交 5月 6月 @@ -191,8 +191,9 @@ 2026-10-06:0 次提交 2026-10-07:0 次提交 2026-10-08:0 次提交 +2026-10-09:5 次提交 -近 26 周共 94 次提交 · 截至 2026-10-08 +近 26 周共 99 次提交 · 截至 2026-10-09 少 diff --git a/CHANGELOG.md b/CHANGELOG.md index 99d8f6a..1036a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ 记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。 官网、构建与发布脚本等不影响应用本身的改动不在此记录。 +## 未发布 + +### 2026-10-10 + +#### 修复 + +- 卸载扫描不会再把磁盘路径的大小写规范化误当成符号链接:实际资源库目录为 `library` 时,0.6.3 会漏掉所有资源库残留,只显示应用本体。现在逐级检查真实链接,恢复扫描并保留路径保护;无法读取或被链接阻挡的目录会显示提示。跟进 [Issue #4](https://github.com/gentpan/OpenStats/issues/4)。 +- 卸载后部分残留移动失败时,可继续重试;同一应用有其他安装副本时,只移除所选应用,保留其他副本仍在使用的数据,回收前也会重新检查。 + +#### 改进 + +- 参考 [Mole](https://github.com/tw93/Mole) 的公开卸载规则,独立实现更多残留识别:登录项组件的真实标识、完整产品名称变体、可执行文件名对应的崩溃报告、WebKit 与 NSURLSession 的应用子缓存,以及当前用户的临时缓存。共享父目录保留,名称匹配项默认不勾选。 +- OpenCode 增加 `.config/opencode` 与 `.cache/opencode` 候选;这些目录可能与命令行共用,默认保留,供用户逐项选择。 + +#### 新增 + +- 合并菜单栏可在「设置 · 菜单栏 · 显示项目」用上移、下移按钮调整图标顺序,立即更新,并保存和同步设置;关闭项目后再开启仍记住位置。回应 [Issue #7](https://github.com/gentpan/OpenStats/issues/7)。 + ## 0.6.3 · 2026-10-09 ### 2026-10-09 diff --git a/Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift index 86e361a..23cca4d 100644 --- a/Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift +++ b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift @@ -5,12 +5,16 @@ import Security struct AppUninstallIdentity: Sendable { let identifiers: Set let names: Set + /// 小写的完整名称候选;只用于受限目录中的精确匹配,名称本身不能证明独占归属。 + let nameVariants: Set let applicationGroups: Set let teamIdentifier: String? init(identifiers: Set, names: Set, applicationGroups: Set = [], teamIdentifier: String? = nil) { + let validNames = Set(names.compactMap(Self.validName)) self.identifiers = Set(identifiers.compactMap(Self.validIdentifier)) - self.names = Set(names.compactMap(Self.validName)) + self.names = validNames + self.nameVariants = Set(validNames.flatMap { Self.safeNameVariants(for: $0) }) self.applicationGroups = Set(applicationGroups.compactMap(Self.validIdentifier)) self.teamIdentifier = teamIdentifier.flatMap(Self.validTeamIdentifier) } @@ -27,7 +31,7 @@ struct AppUninstallIdentity: Sendable { let root = app.url.resolvingSymlinksInPath().standardizedFileURL if let metadata = Self.metadata(at: root, inside: root) { if let identifier = metadata["CFBundleIdentifier"] as? String { identifiers.insert(identifier) } - for key in ["CFBundleName", "CFBundleDisplayName"] { + for key in ["CFBundleName", "CFBundleDisplayName", "CFBundleExecutable"] { if let name = metadata[key] as? String { names.insert(name) } } } @@ -67,6 +71,46 @@ struct AppUninstallIdentity: Sendable { return name } + /// 保留产品的版本和渠道,只转换名称里的空格;不从包名、厂商名或通用 helper 名推测产品。 + static func safeNameVariants(for value: String) -> Set { + guard let name = validName(value), name.count >= 2, + !genericCompactNames.contains(compactName(name)) else { return [] } + let words = name.split(whereSeparator: { $0.isWhitespace }).map(String.init) + let variants = [name, words.joined(separator: " "), words.joined(), + words.joined(separator: "-"), words.joined(separator: "_")] + return Set(variants.compactMap { variant in + let lower = variant.lowercased() + guard lower.count >= 2, !genericCompactNames.contains(compactName(lower)) else { return nil } + return lower + }) + } + + /// 调用方先按目录规则去掉允许的文件后缀,再进行完整名称比较。 + func matchesName(_ candidate: String) -> Bool { + guard let name = Self.validName(candidate) else { return false } + return nameVariants.contains(name.lowercased()) + } + + /// 报告文件的产品名后必须有独立分隔符,FooBar 不能成为 Foo 的报告。 + func matchesReportName(_ stem: String) -> Bool { + guard let name = Self.validName(stem) else { return false } + let lower = name.lowercased() + return nameVariants.contains { lower == $0 || lower.hasPrefix($0 + "-") || lower.hasPrefix($0 + "_") } + } + + private static func compactName(_ value: String) -> String { + value.lowercased().filter { !$0.isWhitespace && $0 != "-" && $0 != "_" } + } + + /// 这些词常作为共享状态目录或多个产品的运行进程名,不能单独用来推断残留归属。 + private static let genericNames: Set = [ + "app", "application", "applications", "application support", "cache", "caches", "code", "config", "configuration", + "container", "containers", "crashhandler", "crash handler", "data", "electron", "electron helper", "extension", + "framework", "group containers", "helper", "library", "log", "logs", "plugin", "preferences", "runner", "service", + "shared", "system", "updater", "widget", + ] + private static let genericCompactNames = Set(genericNames.map(compactName)) + static func validTeamIdentifier(_ value: String) -> String? { let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") guard !value.isEmpty, value.utf8.count <= 64, value.unicodeScalars.allSatisfy(allowed.contains) else { return nil } @@ -83,7 +127,8 @@ struct AppUninstallIdentity: Sendable { let path = url.standardizedFileURL.path let rootPath = root.standardizedFileURL.path guard path == rootPath || path.hasPrefix(rootPath + "/"), - url.resolvingSymlinksInPath().standardizedFileURL.path == path, + AppUninstaller.isDirectoryWithoutSymlink(root), + AppUninstaller.isUnredirected(url, home: rootPath), let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]), values.isSymbolicLink != true else { return false } return true @@ -100,7 +145,7 @@ struct AppUninstallIdentity: Sendable { } static func embeddedBundles(in root: URL) -> [URL] { - let locations = ["Contents/Frameworks", "Contents/XPCServices", "Contents/PlugIns"] + let locations = ["Contents/Frameworks", "Contents/XPCServices", "Contents/PlugIns", "Contents/Library/LoginItems"] let ignoredDirectories: Set = ["Resources", "_CodeSignature", "MacOS", "Headers", "Modules"] var pending = locations.map { (url: root.appendingPathComponent($0), depth: 0) } var visited = Set() diff --git a/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift index 859fced..7c5f25e 100644 --- a/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift +++ b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import Localization @@ -55,6 +56,21 @@ public struct AppLeftover: Sendable, Identifiable, Hashable { } } +/// 扫描未能读取的目录单独返回,避免把不完整扫描显示为“只有应用本体”。 +public struct AppUninstallScan: Sendable { + public let items: [AppLeftover] + public let unreadableDirectories: [URL] + public let otherInstalledCopies: [URL] + let identity: AppUninstallIdentity + let applicationURL: URL +} + +public struct AppInstallationCheck: Sendable { + public let otherInstalledCopies: [URL] + public let unreadableDirectories: [URL] + public var preservesSharedData: Bool { !otherInstalledCopies.isEmpty || !unreadableDirectories.isEmpty } +} + public enum AppUninstallError: Error, Sendable, Equatable, CustomStringConvertible { case systemApp case running @@ -79,16 +95,28 @@ public enum AppUninstaller { /// /Applications 与 ~/Applications 下的应用(含一层子文件夹),按名称排序 public static func installedApps(home: String = NSHomeDirectory(), excluding excludedIdentifiers: Set = []) -> [InstalledApp] { + var unreadable: [URL] = [] + return installedApps(home: home, excluding: excludedIdentifiers, unreadable: &unreadable) + } + + private static func installedApps(home: String, excluding excludedIdentifiers: Set = [], unreadable: inout [URL]) -> [InstalledApp] { var apps: [InstalledApp] = [] let manager = FileManager.default + func entries(_ directory: URL) -> [URL] { + do { + return try manager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) + } catch { + let failure = error as NSError + if failure.domain != NSCocoaErrorDomain || failure.code != NSFileReadNoSuchFileError { unreadable.append(directory) } + return [] + } + } for directory in applicationDirectories(home: home) { - let entries = (try? manager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] - for entry in entries { + for entry in entries(directory) { if entry.pathExtension == "app" { if let app = app(at: entry) { apps.append(app) } } else if (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true { - let nested = (try? manager.contentsOfDirectory(at: entry, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] - apps += nested.filter { $0.pathExtension == "app" }.compactMap(app(at:)) + apps += entries(entry).filter { $0.pathExtension == "app" }.compactMap(app(at:)) } } } @@ -114,21 +142,57 @@ public enum AppUninstaller { if (try? app.url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true { throw AppUninstallError.unsafePath(path) } if path.hasPrefix("/System/") || app.bundleIdentifier.hasPrefix("com.apple.") { throw AppUninstallError.systemApp } let original = app.url.standardizedFileURL.path - let homeApplications = URL(fileURLWithPath: home).resolvingSymlinksInPath().appendingPathComponent("Applications").path + "/" + let homeApplications = URL(fileURLWithPath: home).resolvingSymlinksInPath() + .appendingPathComponent("Applications").resolvingSymlinksInPath().path + "/" // /Applications 和 home 以下的 Applications 不能通过解析可替换的根目录来扩大白名单。 - let inSystemApplications = original.hasPrefix("/Applications/") && path == original + let applications = URL(fileURLWithPath: "/Applications") + let inSystemApplications = original.hasPrefix("/Applications/") + && isDirectoryWithoutSymlink(applications) + && path.caseInsensitiveCompare(original) == .orderedSame + && isUnredirected(app.url.deletingLastPathComponent(), home: applications.path) let inHomeApplications = path.hasPrefix(homeApplications) && isUnredirected(app.url, home: home) guard inSystemApplications || inHomeApplications else { throw AppUninstallError.unsafePath(path) } } public static func leftovers(for app: InstalledApp, home: String = NSHomeDirectory()) -> [AppLeftover] { - leftovers(for: app, home: home, identity: AppUninstallIdentity(app: app)) + scan(for: app, home: home).items + } + + public static func scan(for app: InstalledApp, home: String = NSHomeDirectory()) -> AppUninstallScan { + var unreadable: [URL] = [] + let installed = installedApps(home: home, unreadable: &unreadable) + return scan(for: app, home: home, identity: AppUninstallIdentity(app: app), + installed: installed, userCache: userCacheDirectory(home: home), unreadableInstallations: unreadable) + } + + public static func otherInstalledCopies(of app: InstalledApp, home: String = NSHomeDirectory()) -> [URL] { + checkOtherInstallations(of: app, home: home).otherInstalledCopies + } + + public static func checkOtherInstallations(of app: InstalledApp, home: String = NSHomeDirectory()) -> AppInstallationCheck { + var unreadable: [URL] = [] + let installed = installedApps(home: home, unreadable: &unreadable) + return AppInstallationCheck(otherInstalledCopies: otherInstalledCopies(of: app, in: installed), unreadableDirectories: unreadable) + } + + private static func otherInstalledCopies(of app: InstalledApp, in installed: [InstalledApp]) -> [URL] { + installed.filter { + $0.bundleIdentifier.caseInsensitiveCompare(app.bundleIdentifier) == .orderedSame + && $0.url.resolvingSymlinksInPath().path != app.url.resolvingSymlinksInPath().path + }.map(\.url) } static func leftovers(for app: InstalledApp, home: String, identity: AppUninstallIdentity) -> [AppLeftover] { + scan(for: app, home: home, identity: identity, installed: [], userCache: nil).items + } + + static func scan(for app: InstalledApp, home: String, identity: AppUninstallIdentity, + installed: [InstalledApp], userCache: URL?, unreadableInstallations: [URL] = []) -> AppUninstallScan { let library = URL(fileURLWithPath: home).appendingPathComponent("Library") let manager = FileManager.default var results: [AppLeftover] = [] + var unreadable: Set = [] + let copies = otherInstalledCopies(of: app, in: installed) func matches(_ name: String) -> Bool { identity.identifiers.contains { matchesIdentifier(name, $0) } @@ -141,7 +205,7 @@ public enum AppUninstaller { func add(_ url: URL, _ kind: AppLeftover.Kind, review: Bool = false) { let path = url.standardizedFileURL.path guard manager.fileExists(atPath: path), - (try? validateLeftover(url, for: app, home: home)) != nil, + (try? validateLeftover(url, for: app, home: home, userCache: userCache, identity: identity)) != nil, !results.contains(where: { path.hasPrefix($0.url.standardizedFileURL.path + "/") }) else { return } if let index = results.firstIndex(where: { $0.url.standardizedFileURL.path == path }) { if review && !results[index].requiresReview { @@ -158,17 +222,27 @@ public enum AppUninstaller { results.append(AppLeftover(url: url, kind: kind, size: CleanEngine.allocatedSize(of: url), requiresReview: requiresReview)) } + func children(_ directory: URL, base: String) -> [URL] { + guard manager.fileExists(atPath: directory.path) else { return [] } + guard isUnredirected(directory, home: base) else { unreadable.insert(directory); return [] } + do { + return try manager.contentsOfDirectory(atPath: directory.path).sorted() + .map { directory.appendingPathComponent($0) } + } catch { + unreadable.insert(directory) + return [] + } + } + func entries(_ relative: String) -> [URL] { let directory = library.appendingPathComponent(relative) - guard isUnredirected(directory, home: home) else { return [] } // 保留调用方的 home 路径写法;FileManager 的 URL 形式可能把 /var 转成 /private/var。 - return ((try? manager.contentsOfDirectory(atPath: directory.path)) ?? []) - .sorted().map { directory.appendingPathComponent($0) } + return children(directory, base: home) } func scan(_ relative: String, _ kind: AppLeftover.Kind, names: Bool = false, review: Bool = false) { for entry in entries(relative) { - let named = names && identity.names.contains(entry.lastPathComponent) + let named = names && identity.matchesName(entry.lastPathComponent) if matches(entry.lastPathComponent) || named { add(entry, kind, review: review || auxiliaryMatch(entry.lastPathComponent) || (named && !matches(entry.lastPathComponent))) } @@ -180,21 +254,26 @@ public enum AppUninstaller { for parent in entries(relative) { guard (try? parent.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, !results.contains(where: { $0.url == parent }), isUnredirected(parent, home: home) else { continue } - let children = ((try? manager.contentsOfDirectory(atPath: parent.path)) ?? []) - .map { parent.appendingPathComponent($0) } - for child in children where matches(child.lastPathComponent) { add(child, kind, review: auxiliaryMatch(child.lastPathComponent)) } + for child in children(parent, base: home) where matches(child.lastPathComponent) { + add(child, kind, review: auxiliaryMatch(child.lastPathComponent)) + } } } add(app.url, .application) + // 剩余副本仍使用相同数据:此次只移除所选本体。 + guard copies.isEmpty, unreadableInstallations.isEmpty else { + return AppUninstallScan(items: results, unreadableDirectories: unreadableInstallations, otherInstalledCopies: copies, + identity: identity, applicationURL: app.url) + } scan("Application Support", .support, names: true) scan("Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments", .support) scan("Application Support/CrashReporter", .logs) - scan("Caches", .caches) + scan("Caches", .caches, names: true) scan("Caches/org.sparkle-project.Sparkle", .caches) scan("Caches/SentryCrash", .caches) // Sentry 的目录使用 CFBundleName 而非包名;同名数据可能共用,默认不选。 - for entry in entries("Caches/SentryCrash") where identity.names.contains(entry.lastPathComponent) { + for entry in entries("Caches/SentryCrash") where identity.matchesName(entry.lastPathComponent) { add(entry, .caches, review: true) } scanNested("Application Support", .support) @@ -202,11 +281,17 @@ public enum AppUninstaller { scanNested("Logs", .logs) scan("HTTPStorages", .caches) scan("Preferences", .preferences) + for entry in entries("Preferences") where entry.pathExtension.lowercased() == "plist" + && identity.matchesName(entry.deletingPathExtension().lastPathComponent) { + add(entry, .preferences, review: !matches(entry.lastPathComponent)) + } scan("Preferences/ByHost", .preferences) for entry in entries("Containers") { let owner = containerIdentifier(at: entry, home: home) if let owner { - if identity.identifiers.contains(owner) { add(entry, .containers, review: owner != app.bundleIdentifier && !owner.hasPrefix(app.bundleIdentifier + ".")) } + if identity.identifiers.contains(where: { $0.caseInsensitiveCompare(owner) == .orderedSame }) { + add(entry, .containers, review: owner.caseInsensitiveCompare(app.bundleIdentifier) != .orderedSame) + } } else if matches(entry.lastPathComponent) { add(entry, .containers, review: auxiliaryMatch(entry.lastPathComponent)) } } scan("Application Scripts", .containers) @@ -223,18 +308,38 @@ public enum AppUninstaller { } } scan("Saved Application State", .savedState) + for entry in entries("Saved Application State") where entry.pathExtension.lowercased() == "savedstate" + && identity.matchesName(entry.deletingPathExtension().lastPathComponent) { + add(entry, .savedState, review: !matches(entry.lastPathComponent)) + } scan("Logs", .logs, names: true) - for entry in entries("Logs/DiagnosticReports") where identity.names.contains(where: { - entry.lastPathComponent.hasPrefix($0 + "-") || entry.lastPathComponent.hasPrefix($0 + "_") - }) { add(entry, .logs, review: !matches(entry.lastPathComponent)) } + for (relative, extensions) in [("Application Support/CrashReporter", Set(["plist"])), + ("Logs/DiagnosticReports", Set(["ips", "crash", "spin", "diag"]))] { + for entry in entries(relative) where extensions.contains(entry.pathExtension.lowercased()) + && identity.matchesReportName(entry.deletingPathExtension().lastPathComponent) { + add(entry, .logs, review: !matches(entry.lastPathComponent)) + } + } scan("WebKit", .webData) + scan("WebKit/com.apple.WebKit.WebContent", .webData) + scan("Caches/com.apple.nsurlsessiond/Downloads", .caches) scan("Cookies", .webData) scan("LaunchAgents", .launchAgents) for path in knownPaths(for: app.bundleIdentifier) { add(URL(fileURLWithPath: home).appendingPathComponent(path.relative), path.kind, review: path.review) } - return results + if let userCache { + if isDirectoryWithoutSymlink(userCache) { + for entry in children(userCache, base: userCache.path) where matches(entry.lastPathComponent) { + add(entry, .caches, review: auxiliaryMatch(entry.lastPathComponent)) + } + } else if manager.fileExists(atPath: userCache.path) { + unreadable.insert(userCache) + } + } + return AppUninstallScan(items: results, unreadableDirectories: unreadable.sorted { $0.path < $1.path }, otherInstalledCopies: [], + identity: identity, applicationURL: app.url) } private static func containerIdentifier(at directory: URL, home: String) -> String? { @@ -257,6 +362,9 @@ public enum AppUninstaller { [("Library/Application Support/Arc", .support, false), ("Library/Caches/Arc", .caches, false)] case "com.google.chrome": [("Library/Application Support/Google/Chrome", .support, false), ("Library/Caches/Google/Chrome", .caches, false)] + case "ai.opencode.desktop": + // 桌面版与命令行可能共用这些目录,由用户确认后选择。 + [(".config/opencode", .support, true), (".cache/opencode", .caches, true)] default: [] } } @@ -264,34 +372,106 @@ public enum AppUninstaller { private static let libraryRoots = ["Application Support", "Caches", "HTTPStorages", "Preferences", "Containers", "Application Scripts", "Group Containers", "Saved Application State", "Logs", "WebKit", "Cookies", "LaunchAgents"] private static let sharedNames: Set = ["google", "microsoft", "mozilla", "jetbrains", "adobe", "bravesoftware", "opera software", - "steam", "objective-see", "apple", "crashreporter", "sentrycrash", "org.sparkle-project.sparkle", "com.apple.sharedfilelist"] + "steam", "objective-see", "apple", "crashreporter", "sentrycrash", "org.sparkle-project.sparkle", "com.apple.sharedfilelist", + "com.apple.webkit.webcontent", "com.apple.nsurlsessiond"] /// 在扫描和回收前重查:只允许固定目录内的应用条目,拒绝共享根及 home 以下的符号链接重定向。 public static func validateLeftover(_ url: URL, for app: InstalledApp, home: String = NSHomeDirectory()) throws { + try validateLeftover(url, for: app, home: home, userCache: userCacheDirectory(home: home)) + } + + /// 保留扫描时从真实 bundle 收集的组件身份;本体已移入废纸篓后仍可重试这些候选。 + public static func validateLeftover(_ url: URL, for app: InstalledApp, scan: AppUninstallScan, home: String = NSHomeDirectory()) throws { + try validateLeftover(url, for: app, scan: scan, home: home, userCache: userCacheDirectory(home: home)) + } + + static func validateLeftover(_ url: URL, for app: InstalledApp, scan: AppUninstallScan, home: String, userCache: URL?) throws { + guard scan.applicationURL.standardizedFileURL.path == app.url.standardizedFileURL.path, + scan.items.contains(where: { $0.url.standardizedFileURL.path == url.standardizedFileURL.path }) else { + throw AppUninstallError.unsafePath(url.path) + } + try validateLeftover(url, for: app, home: home, userCache: userCache, identity: scan.identity) + } + + static func validateLeftover(_ url: URL, for app: InstalledApp, home: String, userCache: URL?, identity: AppUninstallIdentity? = nil) throws { + let path = url.standardizedFileURL.path + guard !url.pathComponents.contains(".."), !path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { + throw AppUninstallError.unsafePath(path) + } if url.standardizedFileURL == app.url.standardizedFileURL { try validate(app, home: home) return } let base = URL(fileURLWithPath: home).standardizedFileURL.path - let path = url.standardizedFileURL.path - guard !url.pathComponents.contains(".."), !path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }), - path.hasPrefix(base + "/"), isUnredirected(url, home: home) else { throw AppUninstallError.unsafePath(path) } + if let userCache, url.deletingLastPathComponent().standardizedFileURL.path == userCache.standardizedFileURL.path, + isDirectoryWithoutSymlink(userCache), + isUnredirected(url, home: userCache.path), + (identity ?? AppUninstallIdentity(app: app)).identifiers.contains(where: { matchesIdentifier(url.lastPathComponent, $0) }) { + return + } + guard path.hasPrefix(base + "/"), isUnredirected(url, home: home) else { throw AppUninstallError.unsafePath(path) } let relative = String(path.dropFirst(base.count + 1)) if knownPaths(for: app.bundleIdentifier).contains(where: { $0.relative == relative }) { return } guard let root = libraryRoots.first(where: { relative.hasPrefix("Library/" + $0 + "/") }) else { throw AppUninstallError.unsafePath(path) } let inside = String(relative.dropFirst(("Library/" + root + "/").count)) if !inside.contains("/"), sharedNames.contains(inside.lowercased()) { throw AppUninstallError.unsafePath(path) } if relative == "Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments" - || relative == "Library/Preferences/ByHost" || relative == "Library/Logs/DiagnosticReports" { throw AppUninstallError.unsafePath(path) } + || relative == "Library/Preferences/ByHost" || relative == "Library/Logs/DiagnosticReports" + || relative == "Library/Caches/com.apple.nsurlsessiond/Downloads" + || relative == "Library/WebKit/com.apple.WebKit.WebContent" { throw AppUninstallError.unsafePath(path) } } - private static func isUnredirected(_ url: URL, home: String) -> Bool { - let originalHome = URL(fileURLWithPath: home).standardizedFileURL + static func isUnredirected(_ url: URL, home: String) -> Bool { + let suppliedHome = URL(fileURLWithPath: home).standardizedFileURL let path = url.standardizedFileURL.path - guard path.hasPrefix(originalHome.path + "/") else { return false } + let roots = [suppliedHome, suppliedHome.resolvingSymlinksInPath().standardizedFileURL] + guard let originalHome = roots.first(where: { path.hasPrefix($0.path + "/") || path == $0.path }) else { return false } let relative = String(path.dropFirst(originalHome.path.count + 1)) - let expected = originalHome.resolvingSymlinksInPath().appendingPathComponent(relative).standardizedFileURL.path - return url.resolvingSymlinksInPath().standardizedFileURL.path == expected + var current = originalHome + // APFS 解析路径会规范实际大小写,不能用字符串相等判定符号链接。 + // 从可信 home 向下逐级 lstat,允许大小写规范化,拒绝任何层级的真实链接。 + for component in relative.split(separator: "/") { + current.appendPathComponent(String(component)) + var information = stat() + guard current.path.withCString({ lstat($0, &information) }) == 0, + information.st_mode & S_IFMT != S_IFLNK else { return false } + } + return true + } + + /// URL 的资源属性可能缓存旧值;路径重查直接读取当前目录项。 + static func isDirectoryWithoutSymlink(_ url: URL) -> Bool { + var information = stat() + return url.path.withCString({ lstat($0, &information) }) == 0 && information.st_mode & S_IFMT == S_IFDIR + } + + /// 由系统提供当前用户的 /var/folders/.../C 路径;不扫描其他用户或整个临时根。 + private static func userCacheDirectory(home: String) -> URL? { + guard URL(fileURLWithPath: home).resolvingSymlinksInPath().path + == URL(fileURLWithPath: NSHomeDirectory()).resolvingSymlinksInPath().path else { return nil } + let length = confstr(_CS_DARWIN_USER_CACHE_DIR, nil, 0) + guard length > 1, length < 4096 else { return nil } + var buffer = [CChar](repeating: 0, count: length) + guard confstr(_CS_DARWIN_USER_CACHE_DIR, &buffer, length) == length else { return nil } + let directory = buffer.withUnsafeBufferPointer { pointer in + pointer.baseAddress.map { URL(fileURLWithPath: String(cString: $0)).standardizedFileURL } + } + guard let directory else { return nil } + let path = directory.path + let canonical: URL + if path.hasPrefix("/var/folders/") { + canonical = URL(fileURLWithPath: "/private" + path) + } else { + canonical = directory + } + let anchor = URL(fileURLWithPath: "/private/var/folders") + guard canonical.lastPathComponent == "C", canonical.path.hasPrefix(anchor.path + "/"), + isDirectoryWithoutSymlink(anchor), + isUnredirected(canonical, home: anchor.path) else { return nil } + var information = stat() + guard canonical.path.withCString({ lstat($0, &information) }) == 0, + information.st_mode & S_IFMT == S_IFDIR, information.st_uid == geteuid() else { return nil } + return canonical } /// 名字等于包名,或是包名后接“.”的派生名(com.example.app.plist、com.example.app.savedState、com.example.app.helper) diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index 1bdca51..d54ab09 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -7,6 +7,11 @@ extension Translations { } static let english = #""" +合并显示时,用每项右侧的上移、下移按钮调整菜单栏顺序。顺序会保存,关闭再开启项目也会保持。 In combined mode, use the up and down buttons next to each item to change the menu bar order. The order is saved, including when items are turned off and back on. +将{}向前移动 Move {} earlier +将{}向后移动 Move {} later +检测到相同应用的其他安装副本;本次只移除所选应用,共用的数据会保留。 Another copy of this app is installed. Only the selected app will be removed; shared data will be kept. +部分目录无法读取或为符号链接,残留扫描可能不完整。可检查权限后重新扫描。 Some folders are unreadable or are symbolic links, so the scan may be incomplete. Check access permissions and scan again. {} 英寸 {}-inch · {} 项使用中 · {} in use · 移到废纸篓 · Moves to Trash diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift index 36c3b89..974e8fc 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift @@ -179,6 +179,20 @@ private struct AppDetailCard: View { } } + if !uninstaller.otherInstalledCopies.isEmpty { + InfoBanner(icon: "doc.on.doc", text: tr("检测到相同应用的其他安装副本;本次只移除所选应用,共用的数据会保留。"), tone: .neutral) + .help(uninstaller.otherInstalledCopies.map(\.path).joined(separator: "\n")) + } + if !uninstaller.unreadableDirectories.isEmpty { + InfoBanner(icon: "exclamationmark.triangle", text: tr("部分目录无法读取或为符号链接,残留扫描可能不完整。可检查权限后重新扫描。"), tone: .warning) { + Button(tr("去授权")) { uninstaller.openFullDiskAccessSettings() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + Button(tr("重新扫描")) { uninstaller.select(app) } + .buttonStyle(DSButtonStyle(kind: .secondary)) + } + .help(uninstaller.unreadableDirectories.map(\.path).joined(separator: "\n")) + } + HairlineDivider() HStack { Text(tr("将移到废纸篓")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index b4b3b20..1eae6bb 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -321,16 +321,20 @@ struct MenuBarSettings: View { SettingsGroup(caption: tr("显示项目")) { GroupRow(showsDivider: false) { - InfoBanner(icon: "hand.draw", - text: tr("菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。")) + if settings.menuBarLayout == .combined { + InfoBanner(icon: "arrow.up.arrow.down", + text: tr("合并显示时,用每项右侧的上移、下移按钮调整菜单栏顺序。顺序会保存,关闭再开启项目也会保持。")) + } else { + InfoBanner(icon: "hand.draw", + text: tr("菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。")) + } } - ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in + let order = settings.menuBarLayout == .combined ? settings.menuBarOrder : MenuBarItem.allCases + ForEach(Array(order.enumerated()), id: \.element) { index, item in GroupRow { VStack(alignment: .leading, spacing: DS.Space.s3) { SettingRow(title: item.title, subtitle: itemSubtitle(item), icon: item.symbol) { - DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, - set: { settings.setEnabled(item, $0) }), - label: item.title) + MenuBarItemControls(item: item, index: index) } if settings.isEnabled(item) { ItemStyleRow(item: item) @@ -376,6 +380,32 @@ struct MenuBarSettings: View { } +/// 合并显示的顺序在设置里调整;独立图标仍由系统的 ⌘ 拖动调整位置。 +private struct MenuBarItemControls: View { + @Environment(AppModel.self) private var model + let item: MenuBarItem + let index: Int + + var body: some View { + let settings = model.settings + HStack(spacing: DS.Space.s2) { + if settings.menuBarLayout == .combined { + MiniIconButton(systemName: "arrow.up", help: tr("将\(item.title)向前移动")) { + settings.moveMenuBarItem(item, by: -1) + } + .disabled(index == 0) + MiniIconButton(systemName: "arrow.down", help: tr("将\(item.title)向后移动")) { + settings.moveMenuBarItem(item, by: 1) + } + .disabled(index == settings.menuBarOrder.count - 1) + } + DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, + set: { settings.setEnabled(item, $0) }), + label: item.title) + } + } +} + /// 单个项目自己的菜单栏风格:网速有自己的三种样式;其他项目默认跟随整体,也可以单独选一种。 /// 旁边用实时数据画出这一项现在在菜单栏里的样子,改了立刻能看到 private struct ItemStyleRow: View { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index e14f992..7a23d5d 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -383,6 +383,10 @@ public final class AppSettings { public var menuBarItems: Set { didSet { defaults.set(menuBarItems.map(\.rawValue).sorted(), forKey: Keys.menuBarItems) } } + /// 合并显示时的指标顺序,保留未开启项目的位置,重新开启后仍在原处。 + public private(set) var menuBarOrder: [MenuBarItem] { + didSet { defaults.set(menuBarOrder.map(\.rawValue), forKey: Keys.menuBarOrder) } + } public var menuBarStyle: MenuBarStyle { didSet { defaults.set(menuBarStyle.rawValue, forKey: Keys.menuBarStyle) } } @@ -516,6 +520,8 @@ public final class AppSettings { self.defaults = defaults let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:)) menuBarItems = Set(items ?? [.cpu, .memory, .network]) + let order = defaults.stringArray(forKey: Keys.menuBarOrder)?.compactMap(MenuBarItem.init(rawValue:)) ?? [] + menuBarOrder = Self.normalizedMenuBarOrder(order) menuBarStyle = defaults.string(forKey: Keys.menuBarStyle).flatMap(MenuBarStyle.init(rawValue:)) ?? .stacked networkStyle = defaults.string(forKey: Keys.networkStyle).flatMap(NetworkMenuStyle.init(rawValue:)) ?? .dots let storedOverrides = defaults.dictionary(forKey: Keys.styleOverrides) as? [String: String] ?? [:] @@ -562,9 +568,30 @@ public final class AppSettings { ? defaults.integer(forKey: Keys.cpuChartSeconds) : 60 } - /// 按固定顺序返回已启用的菜单栏项目 + /// 合并显示用自定义顺序;每项独立的位置由 macOS 的 ⌘ 拖动与 autosaveName 记住。 var orderedMenuBarItems: [MenuBarItem] { - MenuBarItem.allCases.filter(menuBarItems.contains) + let order = menuBarLayout == .combined ? menuBarOrder : MenuBarItem.allCases + return order.filter(menuBarItems.contains) + } + + /// 旧设置或云端只带部分项目时补齐,重复项只保留第一次出现的位置。 + private static func normalizedMenuBarOrder(_ order: [MenuBarItem]) -> [MenuBarItem] { + var seen: Set = [] + return (order + MenuBarItem.allCases).filter { seen.insert($0).inserted } + } + + func setMenuBarOrder(_ order: [MenuBarItem]) { + let normalized = Self.normalizedMenuBarOrder(order) + if menuBarOrder != normalized { menuBarOrder = normalized } + } + + func moveMenuBarItem(_ item: MenuBarItem, by offset: Int) { + guard offset == -1 || offset == 1, let index = menuBarOrder.firstIndex(of: item) else { return } + let destination = index + offset + guard menuBarOrder.indices.contains(destination) else { return } + var order = menuBarOrder + order.swapAt(index, destination) + setMenuBarOrder(order) } /// 菜单栏上实际画出来的项目:“仅图标”时一项都不画,开着的项目保留,切回其他布局时恢复 @@ -601,6 +628,7 @@ public final class AppSettings { private enum Keys { static let menuBarItems = "menuBarItems" + static let menuBarOrder = "menuBarOrder" static let menuBarStyle = "menuBarStyle" static let networkStyle = "networkStyle" static let styleOverrides = "styleOverrides" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift index b5e357b..4585be3 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift @@ -7,6 +7,7 @@ import Localization public struct SettingsDocument: Codable, Equatable, Sendable { public var schema: Int? = 1 public var menuBarItems: [String]? + public var menuBarOrder: [String]? public var menuBarStyle: String? public var networkStyle: String? public var styleOverrides: [String: String]? @@ -41,6 +42,7 @@ extension AppSettings { public func exportDocument() -> SettingsDocument { var doc = SettingsDocument() doc.menuBarItems = menuBarItems.map(\.rawValue).sorted() + doc.menuBarOrder = menuBarOrder.map(\.rawValue) doc.menuBarStyle = menuBarStyle.rawValue doc.networkStyle = networkStyle.rawValue doc.styleOverrides = Dictionary(uniqueKeysWithValues: styleOverrides.map { ($0.key.rawValue, $0.value.rawValue) }) @@ -80,6 +82,7 @@ extension AppSettings { } assign(\.menuBarItems, doc.menuBarItems.map { Set($0.compactMap(MenuBarItem.init(rawValue:))) }) + if let order = doc.menuBarOrder { setMenuBarOrder(order.compactMap(MenuBarItem.init(rawValue:))) } assign(\.menuBarStyle, doc.menuBarStyle.flatMap(MenuBarStyle.init(rawValue:))) assign(\.networkStyle, doc.networkStyle.flatMap(NetworkMenuStyle.init(rawValue:))) assign(\.styleOverrides, doc.styleOverrides.map { overrides in diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift index 7cff548..146d300 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift @@ -38,14 +38,24 @@ public final class UninstallerController { public private(set) var leftovers: [AppLeftover] = [] public private(set) var isScanning = false public private(set) var isRemoving = false + public private(set) var unreadableDirectories: [URL] = [] + public private(set) var otherInstalledCopies: [URL] = [] public private(set) var outcome: (text: String, isError: Bool)? var chosen: Set = [] @ObservationIgnored var tally: CleanupTally? /// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新 private var runningRevision = 0 @ObservationIgnored private var observers: [NSObjectProtocol] = [] + @ObservationIgnored private let scanner: @Sendable (InstalledApp) -> AppUninstallScan + @ObservationIgnored private var selectedScan: AppUninstallScan? + private var scanGeneration = UUID() - public init() { + public convenience init() { + self.init(scanner: { AppUninstaller.scan(for: $0) }) + } + + init(scanner: @escaping @Sendable (InstalledApp) -> AppUninstallScan) { + self.scanner = scanner let center = NSWorkspace.shared.notificationCenter for name in [NSWorkspace.didLaunchApplicationNotification, NSWorkspace.didTerminateApplicationNotification] { observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in @@ -75,17 +85,27 @@ public final class UninstallerController { } func select(_ app: InstalledApp?) { + let generation = UUID() + scanGeneration = generation + selectedScan = nil selected = app leftovers = [] chosen = [] outcome = nil + unreadableDirectories = [] + otherInstalledCopies = [] + isScanning = false guard let app else { return } isScanning = true + let scanner = self.scanner Task { - let found = await Task.detached { AppUninstaller.leftovers(for: app) }.value - guard selected == app else { return } - leftovers = found - chosen = Self.initialSelection(for: found) + let scan = await Task.detached { scanner(app) }.value + guard scanGeneration == generation else { return } + leftovers = scan.items + selectedScan = scan + unreadableDirectories = scan.unreadableDirectories + otherInstalledCopies = scan.otherInstalledCopies + chosen = Self.initialSelection(for: scan.items) isScanning = false } } @@ -94,6 +114,11 @@ public final class UninstallerController { Set(items.filter { !$0.requiresReview }.map(\.id)) } + func openFullDiskAccessSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") else { return } + NSWorkspace.shared.open(url) + } + /// 拖进来的 .app func select(url: URL) { guard let app = AppUninstaller.app(at: url) else { @@ -125,11 +150,23 @@ public final class UninstallerController { func uninstall() { guard let app = selected, !isRemoving else { return } - let requested = leftovers.filter { chosen.contains($0.id) } + var requested = leftovers.filter { chosen.contains($0.id) } + // 用户查看清单后可能又安装了一份副本,回收前重查,保留副本仍在使用的数据。 + let installations = AppUninstaller.checkOtherInstallations(of: app) + otherInstalledCopies = installations.otherInstalledCopies + if installations.preservesSharedData { + unreadableDirectories = Array(Set(unreadableDirectories + installations.unreadableDirectories)).sorted { $0.path < $1.path } + requested.removeAll { $0.kind != .application } + leftovers.removeAll { $0.kind != .application } + chosen = Self.initialSelection(for: leftovers) + } do { - try AppUninstaller.validate(app) + if requested.contains(where: { $0.kind == .application }) { try AppUninstaller.validate(app) } guard !isRunning(app) else { throw AppUninstallError.running } - for item in requested { try AppUninstaller.validateLeftover(item.url, for: app) } + for item in requested { + if let selectedScan { try AppUninstaller.validateLeftover(item.url, for: app, scan: selectedScan) } + else { try AppUninstaller.validateLeftover(item.url, for: app) } + } } catch { outcome = ("\(error)", true) return @@ -139,11 +176,12 @@ public final class UninstallerController { return } let urls = requested.map(\.url) + let requestedItems = requested isRemoving = true outcome = nil // NSWorkspace 负责需要管理员权限的情况(例如 root 拥有的应用),并能在废纸篓中“放回原处” NSWorkspace.shared.recycle(urls) { [weak self] moved, error in - let summary = UninstallResultSummary(app: app, requested: requested, movedSources: Set(moved.keys)) + let summary = UninstallResultSummary(app: app, requested: requestedItems, movedSources: Set(moved.keys)) let message = error?.localizedDescription Task { @MainActor in guard let self else { return } diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallIdentityTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallIdentityTests.swift index 801153f..fa6961b 100644 --- a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallIdentityTests.swift +++ b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallIdentityTests.swift @@ -9,11 +9,12 @@ import Testing return url.resolvingSymlinksInPath() } - private func makeBundle(at url: URL, identifier: String, name: String, displayName: String? = nil) throws { + private func makeBundle(at url: URL, identifier: String, name: String, displayName: String? = nil, executable: String? = nil) throws { let contents = url.appendingPathComponent("Contents") try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) var plist: [String: Any] = ["CFBundleIdentifier": identifier, "CFBundleName": name] if let displayName { plist["CFBundleDisplayName"] = displayName } + if let executable { plist["CFBundleExecutable"] = executable } // 未签名的 Info.plist 中宣称的 entitlement 不能用来选择共享容器。 plist["com.apple.security.application-groups"] = ["group.untrusted.plist"] let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) @@ -28,11 +29,12 @@ import Testing let directory = try makeDirectory() defer { try? FileManager.default.removeItem(at: directory) } let root = directory.appendingPathComponent("File Name.app") - try makeBundle(at: root, identifier: "com.example.foo", name: "Sentry Name", displayName: "Localized Name") + try makeBundle(at: root, identifier: "com.example.foo", name: "Sentry Name", displayName: "Localized Name", executable: "Main Process") let bundles = [ ("Contents/Frameworks/Widget.framework/Versions/A/Helpers/Worker.app", "com.example.worker", "CrashHandler"), ("Contents/XPCServices/Service.xpc", "com.example.service", "Service"), ("Contents/PlugIns/Extension.appex", "com.example.extension", "Extension"), + ("Contents/Library/LoginItems/Launcher.app", "com.vendor.loginlauncher", "Login Helper"), ] for (path, identifier, name) in bundles { try makeBundle(at: root.appendingPathComponent(path), identifier: identifier, name: name) @@ -41,8 +43,10 @@ import Testing try makeBundle(at: root.appendingPathComponent("Contents/Resources/Other.app"), identifier: "com.other.resource", name: "Other") let identity = AppUninstallIdentity(app: app(at: root)) - #expect(identity.identifiers == ["com.example.foo", "com.example.worker", "com.example.service", "com.example.extension"]) - #expect(identity.names == ["Displayed App", "File Name", "Sentry Name", "Localized Name"]) + #expect(identity.identifiers == ["com.example.foo", "com.example.worker", "com.example.service", "com.example.extension", "com.vendor.loginlauncher"]) + #expect(identity.names == ["Displayed App", "File Name", "Sentry Name", "Localized Name", "Main Process"]) + #expect(identity.matchesName("main-process")) + #expect(!identity.matchesName("login-helper")) #expect(identity.applicationGroups.isEmpty) #expect(identity.teamIdentifier == nil) } @@ -81,6 +85,11 @@ import Testing try makeBundle(at: externalServices.appendingPathComponent("Other.xpc"), identifier: "com.other.service", name: "Other") try manager.createSymbolicLink(at: root.appendingPathComponent("Contents/XPCServices"), withDestinationURL: externalServices) + let externalLoginItems = directory.appendingPathComponent("ExternalLoginItems") + try makeBundle(at: externalLoginItems.appendingPathComponent("Launcher.app"), identifier: "com.other.launcher", name: "Launcher") + try manager.createDirectory(at: root.appendingPathComponent("Contents/Library"), withIntermediateDirectories: true) + try manager.createSymbolicLink(at: root.appendingPathComponent("Contents/Library/LoginItems"), withDestinationURL: externalLoginItems) + let linkedMetadata = root.appendingPathComponent("Contents/PlugIns/Linked.appex/Contents") try manager.createDirectory(at: linkedMetadata, withIntermediateDirectories: true) try manager.createSymbolicLink(at: linkedMetadata.appendingPathComponent("Info.plist"), @@ -93,6 +102,92 @@ import Testing #expect(!AppUninstallIdentity.isSafeURL(frameworks.appendingPathComponent("Linked.app"), inside: root)) } + @Test func recognizesWholeNameFormatsWithoutDroppingChannelsOrProductBoundaries() { + let identity = AppUninstallIdentity(identifiers: ["com.example.studio"], names: ["Maestro Studio", "Zed Nightly", "QQ", "微信"]) + + for name in ["Maestro Studio", "maestro studio", "MAESTROSTUDIO", "maestro-studio", "Maestro_Studio"] { + #expect(identity.matchesName(name)) + } + #expect(identity.matchesName("zed-nightly")) + #expect(!identity.matchesName("Zed")) + #expect(!identity.matchesName("Maestro Studio Beta")) + #expect(!identity.matchesName("MaestroStudioOther")) + #expect(identity.matchesName("QQ")) + #expect(identity.matchesName("微信")) + #expect(!identity.matchesName("../Maestro Studio")) + } + + @Test func reportNamesRequireACompleteProductNameOrAnExplicitBoundary() { + let identity = AppUninstallIdentity(identifiers: ["com.example.foo"], names: ["Foo App"]) + + #expect(identity.matchesReportName("Foo App")) + #expect(identity.matchesReportName("FooApp_12345678-1234-1234-1234-123456789ABC")) + #expect(identity.matchesReportName("FOO-APP-2026-10-10")) + #expect(!identity.matchesReportName("FooApplication_2026-10-10")) + #expect(!identity.matchesReportName("Foo AppOther_2026-10-10")) + #expect(!identity.matchesReportName("Other Foo App_2026-10-10")) + #expect(!identity.matchesReportName("Foo App/Outside")) + } + + @Test func rejectsGenericRuntimeAndSharedRootNamesBeforeGeneratingVariants() { + let identity = AppUninstallIdentity(identifiers: ["com.example.foo"], + names: ["A", "X", "Helper", "Runner", "Electron", "Electron Helper", "Application Support", "Group Containers", "Code", "Foo"]) + + #expect(identity.nameVariants == ["foo"]) + #expect(!identity.matchesReportName("Helper_1234")) + #expect(!identity.matchesName("electronhelper")) + #expect(!identity.matchesName("application-support")) + #expect(AppUninstallIdentity.safeNameVariants(for: "crash-handler").isEmpty) + #expect(AppUninstallIdentity.safeNameVariants(for: "../Outside").isEmpty) + #expect(AppUninstallIdentity.safeNameVariants(for: "Foo\nBar").isEmpty) + } + + @Test func mainExecutableNamesRejectPathsAndGenericProcessNames() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let root = directory.appendingPathComponent("Foo.app") + try makeBundle(at: root, identifier: "com.example.foo", name: "Foo", executable: "../Outside") + #expect(!AppUninstallIdentity(app: app(at: root, name: "Foo")).names.contains("../Outside")) + + try makeBundle(at: root, identifier: "com.example.foo", name: "Foo", executable: "Electron") + let identity = AppUninstallIdentity(app: app(at: root, name: "Foo")) + #expect(identity.names.contains("Electron")) + #expect(!identity.matchesName("electron")) + #expect(identity.matchesName("Foo")) + } + + @Test func readsLowercaseBundleMetadataAndLoginItemsOnCaseInsensitiveVolumes() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let root = directory.appendingPathComponent("Foo.app") + let contents = root.appendingPathComponent("contents") + let loginHelper = contents.appendingPathComponent("library/loginitems/Startup.app") + let helperContents = loginHelper.appendingPathComponent("contents") + try FileManager.default.createDirectory(at: helperContents, withIntermediateDirectories: true) + let metadata: [String: Any] = [ + "CFBundleIdentifier": "com.example.foo", + "CFBundleName": "Product Name", + "CFBundleExecutable": "Main Product", + ] + try PropertyListSerialization.data(fromPropertyList: metadata, format: .xml, options: 0) + .write(to: contents.appendingPathComponent("info.plist")) + try PropertyListSerialization.data(fromPropertyList: [ + "CFBundleIdentifier": "com.vendor.startup", + "CFBundleName": "Shared Helper", + ], format: .xml, options: 0).write(to: helperContents.appendingPathComponent("info.plist")) + let usualMetadataPath = root.appendingPathComponent("Contents/Info.plist") + // 此回归针对 macOS 默认大小写不敏感卷;其他卷没有这个等价路径。 + guard FileManager.default.fileExists(atPath: usualMetadataPath.path) else { return } + + let identity = AppUninstallIdentity(app: app(at: root, name: "Foo")) + + #expect(AppUninstallIdentity.isSafeURL(usualMetadataPath, inside: root)) + #expect(identity.identifiers == ["com.example.foo", "com.vendor.startup"]) + #expect(identity.names.contains("Product Name")) + #expect(identity.matchesName("main-product")) + #expect(!identity.names.contains("Shared Helper")) + } + @Test func refusesLinkedMainBundleButPreservesSuppliedBasicIdentity() throws { let directory = try makeDirectory() defer { try? FileManager.default.removeItem(at: directory) } diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerEnhancedTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerEnhancedTests.swift index a6d5405..95eaa91 100644 --- a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerEnhancedTests.swift +++ b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerEnhancedTests.swift @@ -298,6 +298,56 @@ import Testing #expect(!found.contains { $0.url.path == child.path }) } + @Test func caseNormalizationDoesNotHideOpenCodeLibraryLeftovers() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app(name: "OpenCode", identifier: "ai.opencode.desktop") + let support = try fixture.directory("library/Application Support/ai.opencode.desktop") + let storage = try fixture.directory("library/HTTPStorages/ai.opencode.desktop") + let preference = try fixture.file("library/Preferences/ai.opencode.desktop.plist") + let hostPreference = try fixture.file("library/Preferences/ByHost/ai.opencode.desktop.Shift.ABCD.plist") + let webData = try fixture.directory("library/WebKit/ai.opencode.desktop") + let recent = try fixture.file("library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/ai.opencode.desktop.sfl4") + let libraryAlias = fixture.home.appendingPathComponent("Library") + // 大小写敏感卷上没有这个别名;此回归针对正常可读取的大小写不敏感 macOS 卷。 + guard FileManager.default.fileExists(atPath: libraryAlias.path) else { return } + #expect((try? FileManager.default.contentsOfDirectory(atPath: libraryAlias.path)) != nil) + #expect((try? libraryAlias.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) != true) + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: [app.name]) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + let expectedPaths = Set([app.url, support, storage, preference, hostPreference, webData, recent] + .map { $0.resolvingSymlinksInPath().standardizedFileURL.path }) + #expect(Set(found.map { $0.url.resolvingSymlinksInPath().standardizedFileURL.path }) == expectedPaths) + #expect(found.count == 7) + for item in found { + #expect(throws: Never.self) { try AppUninstaller.validateLeftover(item.url, for: app, home: fixture.home.path) } + } + } + + @Test func aRealLibrarySymlinkCannotAuthorizeExternalOpenCodeData() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app(name: "OpenCode", identifier: "ai.opencode.desktop") + let externalLibrary = fixture.outside.appendingPathComponent("Library") + let support = externalLibrary.appendingPathComponent("Application Support/ai.opencode.desktop") + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + let preserved = support.appendingPathComponent("keep") + try Data("external data must remain".utf8).write(to: preserved) + let link = try fixture.symlink("Library", to: externalLibrary) + let redirectedCandidate = link.appendingPathComponent("Application Support/ai.opencode.desktop") + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: [app.name]) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == [app.url.path]) + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(redirectedCandidate, for: app, home: fixture.home.path) + } + #expect(FileManager.default.fileExists(atPath: preserved.path)) + } + @Test func applicationRootReplacedAfterScanningCannotAuthorizeAnOutsideApplication() throws { let fixture = try Fixture() defer { fixture.remove() } diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerMoleTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerMoleTests.swift new file mode 100644 index 0000000..a7d147d --- /dev/null +++ b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerMoleTests.swift @@ -0,0 +1,295 @@ +import Foundation +import Testing +@testable import Cleaner + +@Suite struct AppUninstallerMoleTests { + private struct Fixture { + let root: URL + let home: URL + + init() throws { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent("uninstall-mole-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + root = temporary.resolvingSymlinksInPath() + home = root.appendingPathComponent("Home") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + } + + func remove() { try? FileManager.default.removeItem(at: root) } + + func app(name: String = "OpenCode", identifier: String = "ai.opencode.desktop") throws -> InstalledApp { + let url = try directory("Applications/\(name).app/Contents").deletingLastPathComponent() + let metadata = ["CFBundleIdentifier": identifier, "CFBundleName": name, "CFBundleDisplayName": name, + "CFBundlePackageType": "APPL", "CFBundleVersion": "1"] + try PropertyListSerialization.data(fromPropertyList: metadata, format: .xml, options: 0) + .write(to: url.appendingPathComponent("Contents/Info.plist")) + return InstalledApp(url: url, name: name, bundleIdentifier: identifier, version: "1", teamIdentifier: nil) + } + + func embeddedHelper(in app: InstalledApp, identifier: String) throws { + let contents = app.url.appendingPathComponent("Contents/XPCServices/FixtureHelper.xpc/Contents") + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: ["CFBundleIdentifier": identifier, + "CFBundleName": "Fixture Helper", "CFBundlePackageType": "XPC!"], format: .xml, options: 0) + .write(to: contents.appendingPathComponent("Info.plist")) + } + + @discardableResult + func directory(_ relative: String) throws -> URL { + let url = home.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @discardableResult + func file(_ relative: String) throws -> URL { + let url = home.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("fixture".utf8).write(to: url) + return url + } + + func scan(_ app: InstalledApp, installed: [InstalledApp] = [], userCache: URL? = nil, + unreadableInstallations: [URL] = []) -> AppUninstallScan { + AppUninstaller.scan(for: app, home: home.path, identity: AppUninstallIdentity(app: app), + installed: installed, userCache: userCache, unreadableInstallations: unreadableInstallations) + } + } + + @Test func openCodeConfigurationIsSpecificToItsDesktopIdentifierAndRequiresReview() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let config = try fixture.directory(".config/opencode") + let cache = try fixture.directory(".cache/opencode") + let preserved = try fixture.file(".config/opencode-other/settings.json") + let result = fixture.scan(app) + + #expect(Set(result.items.map { $0.url.path }) == [app.url.path, config.path, cache.path]) + for target in [config, cache] { + #expect(result.items.first { $0.url.path == target.path }?.requiresReview == true) + #expect(throws: Never.self) { try AppUninstaller.validateLeftover(target, for: app, home: fixture.home.path, userCache: nil) } + } + let other = try fixture.app(name: "OpenCode Lookalike", identifier: "ai.opencode.desktop.other") + #expect(fixture.scan(other).items.map { $0.url.path } == [other.url.path]) + for parent in [".config", ".cache"] { + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(fixture.home.appendingPathComponent(parent), for: app, home: fixture.home.path, userCache: nil) + } + } + #expect(FileManager.default.fileExists(atPath: preserved.path)) + } + + @Test func nameOnlyCachesPreferencesAndSavedStateRequireReviewWithExactNames() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let targets = [try fixture.directory("Library/Caches/opencode"), + try fixture.file("Library/Preferences/OpenCode.plist"), + try fixture.directory("Library/Saved Application State/OpenCode.savedState")] + let neighbors = [try fixture.directory("Library/Caches/OpenCodePlus"), + try fixture.file("Library/Preferences/OpenCodePlus.plist"), + try fixture.file("Library/Preferences/OpenCode.json"), + try fixture.directory("Library/Saved Application State/OpenCodePlus.savedState")] + let result = fixture.scan(app) + + #expect(Set(result.items.map { $0.url.path }) == Set([app.url.path] + targets.map(\.path))) + for target in targets { #expect(result.items.first { $0.url.path == target.path }?.requiresReview == true) } + for neighbor in neighbors { #expect(!result.items.contains { $0.url.path == neighbor.path }) } + } + + @Test func crashReportsUseNameBoundariesAndRecognizedReportExtensions() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let targets = [ + try fixture.file("Library/Application Support/CrashReporter/OpenCode_ABC.plist"), + try fixture.file("Library/Logs/DiagnosticReports/OpenCode-2026-10-10.ips"), + try fixture.file("Library/Logs/DiagnosticReports/OpenCode_2026-10-10.crash"), + try fixture.file("Library/Logs/DiagnosticReports/OpenCode.spin"), + try fixture.file("Library/Logs/DiagnosticReports/OpenCode.diag"), + ] + for path in ["Library/Application Support/CrashReporter/OpenCodePlus_ABC.plist", + "Library/Application Support/CrashReporter/OpenCode_ABC.json", + "Library/Logs/DiagnosticReports/OpenCodePlus-2026.ips", + "Library/Logs/DiagnosticReports/OpenCode-2026.txt", + "Library/Logs/DiagnosticReports/OpenCode-2026.plist"] { try fixture.file(path) } + let result = fixture.scan(app) + + #expect(Set(result.items.map { $0.url.path }) == Set([app.url.path] + targets.map(\.path))) + for target in targets { + #expect(result.items.first { $0.url.path == target.path }?.kind == .logs) + #expect(result.items.first { $0.url.path == target.path }?.requiresReview == true) + } + } + + @Test func webContentAndURLSessionDownloadsSelectOwnedChildrenAndKeepSharedRoots() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let roots = ["Library/WebKit/com.apple.WebKit.WebContent", "Library/Caches/com.apple.nsurlsessiond/Downloads"] + var targets: [URL] = [] + for root in roots { + targets.append(try fixture.directory(root + "/ai.opencode.desktop")) + try fixture.directory(root + "/ai.opencode.desktop-other") + try fixture.directory(root + "/ai.opencode.desktopplus") + try fixture.directory(root + "/com.other.app") + } + let result = fixture.scan(app) + + #expect(Set(result.items.map { $0.url.path }) == Set([app.url.path] + targets.map(\.path))) + for target in targets { #expect(result.items.first { $0.url.path == target.path }?.requiresReview == false) } + for root in roots + ["Library/Caches/com.apple.nsurlsessiond"] { + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(fixture.home.appendingPathComponent(root), for: app, home: fixture.home.path, userCache: nil) + } + } + } + + @Test(arguments: [true, false]) + func anotherInstalledCopyKeepsSharedDataOnlyWhenItsIdentifierMatches(_ sameIdentifier: Bool) throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let other = try fixture.app(name: "Second App", identifier: sameIdentifier ? app.bundleIdentifier : "com.other.app") + let support = try fixture.directory("Library/Application Support/ai.opencode.desktop") + let result = fixture.scan(app, installed: [app, other]) + + if sameIdentifier { + #expect(result.items.map { $0.url.path } == [app.url.path]) + #expect(result.otherInstalledCopies.map(\.path) == [other.url.path]) + } else { + #expect(Set(result.items.map { $0.url.path }) == [app.url.path, support.path]) + #expect(result.otherInstalledCopies.isEmpty) + } + #expect(FileManager.default.fileExists(atPath: support.path)) + } + + @Test func containerMetadataRecognizesDerivedHelperOwnershipButRequiresReview() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let helperIdentifier = app.bundleIdentifier + ".helper" + try fixture.embeddedHelper(in: app, identifier: helperIdentifier) + #expect(AppUninstallIdentity(app: app).identifiers.contains(helperIdentifier)) + let helper = try fixture.directory("Library/Containers/12345678-ABCD-4567-ABCD-123456789ABC") + let unrelated = try fixture.directory("Library/Containers/ABCDEF12-ABCD-4567-ABCD-123456789ABC") + let prefixProduct = try fixture.directory("Library/Containers/98765432-ABCD-4567-ABCD-123456789ABC") + for (directory, identifier) in [(helper, helperIdentifier), (unrelated, "ai.opencode.desktopplus.helper"), + (prefixProduct, app.bundleIdentifier + ".OtherProduct")] { + try PropertyListSerialization.data(fromPropertyList: ["MCMMetadataIdentifier": identifier], format: .binary, options: 0) + .write(to: directory.appendingPathComponent(".com.apple.containermanagerd.metadata.plist")) + } + let result = fixture.scan(app) + + #expect(Set(result.items.map { $0.url.path }) == [app.url.path, helper.path]) + #expect(result.items.first { $0.url.path == helper.path }?.requiresReview == true) + for rejected in [unrelated, prefixProduct] { #expect(!result.items.contains { $0.url.path == rejected.path }) } + } + + @Test func blockedScanRootsAreReportedInsteadOfSilentlyLookingEmpty() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let external = fixture.root.appendingPathComponent("ExternalCaches") + try FileManager.default.createDirectory(at: external.appendingPathComponent(app.bundleIdentifier), withIntermediateDirectories: true) + try fixture.directory("Library") + let cacheRoot = fixture.home.appendingPathComponent("Library/Caches") + try FileManager.default.createSymbolicLink(at: cacheRoot, withDestinationURL: external) + let result = fixture.scan(app) + + #expect(result.items.map { $0.url.path } == [app.url.path]) + #expect(result.unreadableDirectories.contains { $0.path == cacheRoot.path }) + } + + @Test func darwinUserCacheSelectsIdentifierChildrenAndRechecksItsRoot() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let userCache = fixture.root.appendingPathComponent("C") + let owned = userCache.appendingPathComponent(app.bundleIdentifier) + let helper = userCache.appendingPathComponent(app.bundleIdentifier + ".helper") + let neighbor = userCache.appendingPathComponent(app.bundleIdentifier + "plus") + for directory in [owned, helper, neighbor] { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } + let identity = AppUninstallIdentity(app: app) + let result = fixture.scan(app, userCache: userCache) + + #expect(Set(result.items.map { $0.url.path }) == [app.url.path, owned.path, helper.path]) + #expect(result.items.first { $0.url.path == owned.path }?.requiresReview == false) + #expect(!result.items.contains { $0.url.path == neighbor.path }) + #expect(throws: Never.self) { try AppUninstaller.validateLeftover(owned, for: app, home: fixture.home.path, userCache: userCache, identity: identity) } + for rejected in [userCache, neighbor] { + #expect(throws: AppUninstallError.self) { try AppUninstaller.validateLeftover(rejected, for: app, home: fixture.home.path, userCache: userCache, identity: identity) } + } + + // 同一 C 路径在扫描后被替换成链接,不能重新授权链接目标中的数据。 + let relocated = fixture.root.appendingPathComponent("RelocatedCache") + try FileManager.default.moveItem(at: userCache, to: relocated) + try FileManager.default.createSymbolicLink(at: userCache, withDestinationURL: relocated) + let redirected = fixture.scan(app, userCache: userCache) + #expect(redirected.items.map { $0.url.path } == [app.url.path]) + #expect(redirected.unreadableDirectories.contains { $0.path == userCache.path }) + #expect(throws: AppUninstallError.self) { try AppUninstaller.validateLeftover(owned, for: app, home: fixture.home.path, userCache: userCache, identity: identity) } + } + + @Test func unreadableInstallationDirectoryKeepsAllResidualDataUntilCopyStatusIsKnown() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let support = try fixture.directory("Library/Application Support/ai.opencode.desktop") + try fixture.directory(".config/opencode") + let unknown = fixture.home.appendingPathComponent("Applications/Unreadable") + + let result = fixture.scan(app, unreadableInstallations: [unknown]) + + #expect(result.items.map { $0.url.path } == [app.url.path]) + #expect(result.unreadableDirectories.map(\.path) == [unknown.path]) + #expect(result.otherInstalledCopies.isEmpty) + #expect(FileManager.default.fileExists(atPath: support.path)) + } + + @Test func scanEvidenceRetainsHelperOwnershipForRetryAndCannotAuthorizeOtherPaths() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let helperIdentifier = "com.vendor.helper" + try fixture.embeddedHelper(in: app, identifier: helperIdentifier) + let userCache = fixture.root.appendingPathComponent("C") + let helper = userCache.appendingPathComponent(helperIdentifier) + try FileManager.default.createDirectory(at: helper, withIntermediateDirectories: true) + let scan = fixture.scan(app, userCache: userCache) + #expect(Set(scan.items.map { $0.url.path }) == [app.url.path, helper.path]) + #expect(scan.items.first { $0.url.path == helper.path }?.requiresReview == true) + + try FileManager.default.removeItem(at: app.url) + #expect(!AppUninstallIdentity(app: app).identifiers.contains(helperIdentifier)) + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(helper, for: app, home: fixture.home.path, userCache: userCache) + } + #expect(throws: Never.self) { + try AppUninstaller.validateLeftover(helper, for: app, scan: scan, home: fixture.home.path, userCache: userCache) + } + + // 此路径虽符合 helper 的命名边界,但创建于扫描之后,不能沿用扫描证据授权。 + let newPath = userCache.appendingPathComponent(helperIdentifier + ".new") + try FileManager.default.createDirectory(at: newPath, withIntermediateDirectories: true) + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(newPath, for: app, scan: scan, home: fixture.home.path, userCache: userCache) + } + let other = try fixture.app(name: "Other App", identifier: "com.other.app") + let otherScan = fixture.scan(other, userCache: userCache) + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(helper, for: app, scan: otherScan, home: fixture.home.path, userCache: userCache) + } + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(helper, for: other, scan: scan, home: fixture.home.path, userCache: userCache) + } + + let relocated = fixture.root.appendingPathComponent("RelocatedCache") + try FileManager.default.moveItem(at: userCache, to: relocated) + try FileManager.default.createSymbolicLink(at: userCache, withDestinationURL: relocated) + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(helper, for: app, scan: scan, home: fixture.home.path, userCache: userCache) + } + } +} diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/MenuBarOrderTests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/MenuBarOrderTests.swift new file mode 100644 index 0000000..e58e46f --- /dev/null +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/MenuBarOrderTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Observation +import Testing +@testable import OpenStatsUI + +@MainActor +@Suite struct MenuBarOrderTests { + private func defaults() -> UserDefaults { + let name = "OpenStatsUITests.MenuBarOrder.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test func oldPreferencesKeepTheOriginalOrder() { + let settings = AppSettings(defaults: defaults()) + settings.menuBarLayout = .combined + #expect(settings.menuBarOrder == MenuBarItem.allCases) + #expect(settings.drawnMenuBarItems == [.cpu, .memory, .network]) + } + + @Test func invalidAndIncompleteStoredOrderIsCompletedWithoutDuplicates() { + let stored = defaults() + stored.set(["gpu", "unknown", "gpu", "cpu"], forKey: "menuBarOrder") + let settings = AppSettings(defaults: stored) + #expect(settings.menuBarOrder == [.gpu, .cpu, .memory, .network, .disk, .temperature, .fan, .battery]) + #expect(Set(settings.menuBarOrder) == Set(MenuBarItem.allCases)) + } + + @Test func reorderedItemsPersistAndOnlyAffectCombinedIndicators() { + let stored = defaults() + let settings = AppSettings(defaults: stored) + settings.menuBarItems = [.cpu, .memory, .gpu] + settings.menuBarLayout = .combined + settings.setMenuBarOrder([.gpu, .memory, .cpu]) + #expect(settings.drawnMenuBarItems == [.gpu, .memory, .cpu]) + + let reloaded = AppSettings(defaults: stored) + #expect(reloaded.menuBarOrder == settings.menuBarOrder) + #expect(reloaded.drawnMenuBarItems == [.gpu, .memory, .cpu]) + reloaded.menuBarLayout = .separate + #expect(reloaded.drawnMenuBarItems == [.cpu, .memory, .gpu]) + reloaded.menuBarLayout = .iconOnly + #expect(reloaded.drawnMenuBarItems.isEmpty) + reloaded.menuBarLayout = .combined + #expect(reloaded.drawnMenuBarItems == [.gpu, .memory, .cpu]) + } + + @Test func disablingAnItemRetainsItsPlaceForReenabling() { + let settings = AppSettings(defaults: defaults()) + settings.menuBarLayout = .combined + settings.setMenuBarOrder([.network, .cpu, .memory]) + settings.setEnabled(.cpu, false) + #expect(settings.drawnMenuBarItems == [.network, .memory]) + settings.setEnabled(.cpu, true) + #expect(settings.drawnMenuBarItems == [.network, .cpu, .memory]) + } + + @Test func movesSwapNeighborsAndStopAtTheEdges() { + let settings = AppSettings(defaults: defaults()) + settings.moveMenuBarItem(.cpu, by: -1) + settings.moveMenuBarItem(.battery, by: 1) + #expect(settings.menuBarOrder == MenuBarItem.allCases) + settings.moveMenuBarItem(.memory, by: -1) + #expect(Array(settings.menuBarOrder.prefix(3)) == [.memory, .cpu, .network]) + settings.moveMenuBarItem(.memory, by: 1) + #expect(settings.menuBarOrder == MenuBarItem.allCases) + } + + @Test func orderRoundTripsThroughSettingsSync() throws { + let source = AppSettings(defaults: defaults()) + source.menuBarLayout = .combined + source.setMenuBarOrder([.network, .gpu, .memory, .cpu]) + let data = try JSONEncoder().encode(source.exportDocument()) + let document = try JSONDecoder().decode(SettingsDocument.self, from: data) + let target = AppSettings(defaults: defaults()) + target.apply(document) + #expect(target.menuBarOrder == source.menuBarOrder) + #expect(target.drawnMenuBarItems == source.drawnMenuBarItems) + #expect(target.exportDocument() == source.exportDocument()) + } + + @Test func oldCloudDocumentsPreserveTheCustomizedOrder() throws { + let settings = AppSettings(defaults: defaults()) + settings.setMenuBarOrder([.gpu, .cpu]) + let order = settings.menuBarOrder + let oldDocument = try JSONDecoder().decode(SettingsDocument.self, from: Data(#"{"schema":1,"menuBarItems":["cpu"]}"#.utf8)) + #expect(oldDocument.menuBarOrder == nil) + settings.apply(oldDocument) + #expect(settings.menuBarOrder == order) + } + + @Test func cloudOrderDropsUnknownsAndCompletesMissingItems() { + let settings = AppSettings(defaults: defaults()) + var document = SettingsDocument() + document.menuBarOrder = ["fan", "future", "fan", "cpu"] + settings.apply(document) + #expect(settings.menuBarOrder == [.fan, .cpu, .memory, .network, .gpu, .disk, .temperature, .battery]) + } + + @Test func orderChangesInvalidateTheMenuBarObservation() async { + let settings = AppSettings(defaults: defaults()) + settings.menuBarLayout = .combined + await confirmation("menu bar redraw", expectedCount: 1) { confirm in + withObservationTracking { + _ = settings.drawnMenuBarItems + } onChange: { + confirm() + } + settings.moveMenuBarItem(.memory, by: -1) + } + } +} diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/UninstallerScanTests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/UninstallerScanTests.swift new file mode 100644 index 0000000..4e06af9 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/UninstallerScanTests.swift @@ -0,0 +1,145 @@ +import Dispatch +import Foundation +import Testing +@testable import Cleaner +@testable import OpenStatsUI + +/// 控制旧扫描何时返回,验证同一应用重新选择或取消后不会恢复旧详情。 +private final class ControlledUninstallScan: @unchecked Sendable { + let firstStarted = DispatchSemaphore(value: 0) + let releaseFirst = DispatchSemaphore(value: 0) + let firstReturned = DispatchSemaphore(value: 0) + private let first: AppUninstallScan + private let next: AppUninstallScan + private let lock = NSLock() + private var calls = 0 + + init(first: AppUninstallScan, next: AppUninstallScan) { + self.first = first + self.next = next + } + + func scan(_ app: InstalledApp) -> AppUninstallScan { + lock.lock() + calls += 1 + let current = calls + lock.unlock() + if current == 1 { + firstStarted.signal() + _ = releaseFirst.wait(timeout: .now() + 3) + firstReturned.signal() + return first + } + return next + } +} + +@MainActor +@Suite struct UninstallerScanTests { + private func app(at root: URL) -> InstalledApp { + InstalledApp(url: root.appendingPathComponent("Applications/Probe Editor.app"), name: "Probe Editor", + bundleIdentifier: "com.example.scan-probe", version: nil, teamIdentifier: nil) + } + + private func wait(_ semaphore: DispatchSemaphore) async -> Bool { + await Task.detached { Self.waitSynchronously(semaphore) }.value + } + + private nonisolated static func waitSynchronously(_ semaphore: DispatchSemaphore) -> Bool { + semaphore.wait(timeout: .now() + 3) == .success + } + + private func waitForScan(_ controller: UninstallerController) async -> Bool { + let deadline = Date().addingTimeInterval(3) + while controller.isScanning && Date() < deadline { + try? await Task.sleep(for: .milliseconds(5)) + } + return !controller.isScanning + } + + @Test func realScanPropagatesWarningsAndLeavesReviewCandidatesUnchecked() async throws { + let root = URL(fileURLWithPath: "/tmp").appendingPathComponent("openstats-scan-test-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("Home") + let selected = app(at: home) + let contents = selected.url.appendingPathComponent("Contents") + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + let metadata = ["CFBundleIdentifier": selected.bundleIdentifier, "CFBundleDisplayName": selected.name] + let info = try PropertyListSerialization.data(fromPropertyList: metadata, format: .binary, options: 0) + try info.write(to: contents.appendingPathComponent("Info.plist")) + let cache = home.appendingPathComponent("Library/Caches/\(selected.bundleIdentifier)") + let namedData = home.appendingPathComponent("Library/Application Support/\(selected.name)") + let outside = root.appendingPathComponent("Outside") + for directory in [cache, namedData, outside] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + let redirected = home.appendingPathComponent("Library/WebKit") + try FileManager.default.createSymbolicLink(at: redirected, withDestinationURL: outside) + + let controller = UninstallerController(scanner: { AppUninstaller.scan(for: $0, home: home.path) }) + controller.select(selected) + #expect(await waitForScan(controller)) + #expect(controller.selected == selected) + #expect(controller.unreadableDirectories.contains(redirected)) + #expect(controller.otherInstalledCopies.isEmpty) + let reviewItems = controller.leftovers.filter(\.requiresReview) + #expect(!reviewItems.isEmpty) + #expect(reviewItems.allSatisfy { !controller.chosen.contains($0.id) }) + #expect(controller.chosen.contains(cache.path)) + } + + @Test func reselectingTheSameAppDiscardsItsOlderScan() async { + let root = URL(fileURLWithPath: "/tmp/openstats-old-scan-fixture") + let selected = app(at: root) + let body = AppLeftover(url: selected.url, kind: .application, size: 100) + let stale = AppLeftover(url: root.appendingPathComponent("Library/Caches/old"), kind: .caches, size: 200) + let fresh = AppLeftover(url: root.appendingPathComponent("Library/Group Containers/fresh"), kind: .containers, size: 300, requiresReview: true) + let oldWarning = root.appendingPathComponent("Library/OldWarning") + let newWarning = root.appendingPathComponent("Library/NewWarning") + let copy = root.appendingPathComponent("Applications/Probe Copy.app") + let identity = AppUninstallIdentity(app: selected) + let scanner = ControlledUninstallScan( + first: AppUninstallScan(items: [body, stale], unreadableDirectories: [oldWarning], otherInstalledCopies: [copy], + identity: identity, applicationURL: selected.url), + next: AppUninstallScan(items: [body, fresh], unreadableDirectories: [newWarning], otherInstalledCopies: [], + identity: identity, applicationURL: selected.url)) + defer { scanner.releaseFirst.signal() } + let controller = UninstallerController(scanner: { scanner.scan($0) }) + controller.select(selected) + #expect(await wait(scanner.firstStarted)) + controller.select(selected) + #expect(await waitForScan(controller)) + scanner.releaseFirst.signal() + #expect(await wait(scanner.firstReturned)) + try? await Task.sleep(for: .milliseconds(30)) + + #expect(controller.leftovers == [body, fresh]) + #expect(controller.chosen == [body.id]) + #expect(controller.unreadableDirectories == [newWarning]) + #expect(controller.otherInstalledCopies.isEmpty) + } + + @Test func cancellingSelectionDiscardsThePendingScanAndWarnings() async { + let root = URL(fileURLWithPath: "/tmp/openstats-cancelled-scan-fixture") + let selected = app(at: root) + let body = AppLeftover(url: selected.url, kind: .application, size: 100) + let result = AppUninstallScan(items: [body], unreadableDirectories: [root.appendingPathComponent("Library/Warning")], + otherInstalledCopies: [root.appendingPathComponent("Applications/Copy.app")], + identity: AppUninstallIdentity(app: selected), applicationURL: selected.url) + let scanner = ControlledUninstallScan(first: result, next: result) + defer { scanner.releaseFirst.signal() } + let controller = UninstallerController(scanner: { scanner.scan($0) }) + controller.select(selected) + #expect(await wait(scanner.firstStarted)) + controller.select(nil) + #expect(!controller.isScanning) + scanner.releaseFirst.signal() + #expect(await wait(scanner.firstReturned)) + try? await Task.sleep(for: .milliseconds(30)) + + #expect(controller.selected == nil) + #expect(controller.leftovers.isEmpty && controller.chosen.isEmpty) + #expect(controller.unreadableDirectories.isEmpty && controller.otherInstalledCopies.isEmpty) + #expect(!controller.isScanning) + } +} diff --git a/README.md b/README.md index c79c7a8..d0c541b 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,28 @@ system language by default; switch it in Settings → General → Language. -Latest release **0.6.3** (2026-10-09) · [full changelog](CHANGELOG.md) (kept in Chinese) +Latest release **0.6.3** (2026-10-09) · **5** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
+
2026-10-10 · Unreleased · 2 fixed · 2 改进 · 1 added + +**Fixed** + +- 卸载扫描不会再把磁盘路径的大小写规范化误当成符号链接:实际资源库目录为 `library` 时,0.6.3 会漏掉所有资源库残留,只显示应用本体。现在逐级检查真实链接,恢复扫描并保留路径保护;无法读取或被链接阻挡的目录会显示提示。跟进 [Issue #4](https://github.com/gentpan/OpenStats/issues/4)。 +- 卸载后部分残留移动失败时,可继续重试;同一应用有其他安装副本时,只移除所选应用,保留其他副本仍在使用的数据,回收前也会重新检查。 + +**改进** + +- 参考 [Mole](https://github.com/tw93/Mole) 的公开卸载规则,独立实现更多残留识别:登录项组件的真实标识、完整产品名称变体、可执行文件名对应的崩溃报告、WebKit 与 NSURLSession 的应用子缓存,以及当前用户的临时缓存。共享父目录保留,名称匹配项默认不勾选。 +- OpenCode 增加 `.config/opencode` 与 `.cache/opencode` 候选;这些目录可能与命令行共用,默认保留,供用户逐项选择。 + +**Added** + +- 合并菜单栏可在「设置 · 菜单栏 · 显示项目」用上移、下移按钮调整图标顺序,立即更新,并保存和同步设置;关闭项目后再开启仍记住位置。回应 [Issue #7](https://github.com/gentpan/OpenStats/issues/7)。 + + + +
2026-10-09 · 0.6.3 · 3 fixed **Fixed** @@ -91,23 +110,6 @@ Latest release **0.6.3** (2026-10-09) · [full changelog](CHANGELOG.md) (kept in
-
-2026-09-24 · 0.6.3 · 2 added · 4 fixed - -**Added** - -- 网络测速可以选线路:开着 Surge、Clash 等 VPN 或代理时,窗口顶部出现“直连 / 经 Surge”切换。直连绑定物理网卡、绕开代理,测的是本机宽带;经代理测的是代理线路。下载、上传与延迟都按选的线路走,每张卡片标出这次走的是哪条。 -- 国内分省三网延迟改用电信、联通、移动的标志,不再用“电 / 联 / 移”字块。 - -**Fixed** - -- 开着 VPN 时网络测速的延迟全是假的(零点几到十几毫秒):TCP 建连被本机代理当场应答,测到的只是本机到代理这一段,与节点远近无关。现在直连时绕开代理计时;经代理时改用同一条连接上的 HTTP 往返,每次都要等目标真的回应。国内三网节点只允许测建连,始终直连测,VPN 不让绕开时直接提示,不再给出假数字。远距离线路偶尔丢握手包,国内节点多测一次、超时放宽到 4 秒。 -- 本机宽带的延迟扣掉 Cloudflare 响应头里报的服务器处理时间,与 Cloudflare 官方测速的算法一致。 -- 测速后顶栏出现“本次用掉”时,标题“网络测速”被挤成竖排。 -- 绕开代理解析域名时,Cloudflare DoH 连不上就换 Google、阿里的 DoH,出口与分流窗口的原始出口归属地也因此更容易查到。 - -
- ## Activity diff --git a/README.zh-CN.md b/README.zh-CN.md index 557ce6d..164572c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,9 +57,28 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 -最新版本 **0.6.3**(2026-10-09) · [完整更新日志](CHANGELOG.md) +最新版本 **0.6.3**(2026-10-09) · 开发中 **5** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
+2026-10-10 · 未发布 · 修复 2 · 改进 2 · 新增 1 + +**修复** + +- 卸载扫描不会再把磁盘路径的大小写规范化误当成符号链接:实际资源库目录为 `library` 时,0.6.3 会漏掉所有资源库残留,只显示应用本体。现在逐级检查真实链接,恢复扫描并保留路径保护;无法读取或被链接阻挡的目录会显示提示。跟进 [Issue #4](https://github.com/gentpan/OpenStats/issues/4)。 +- 卸载后部分残留移动失败时,可继续重试;同一应用有其他安装副本时,只移除所选应用,保留其他副本仍在使用的数据,回收前也会重新检查。 + +**改进** + +- 参考 [Mole](https://github.com/tw93/Mole) 的公开卸载规则,独立实现更多残留识别:登录项组件的真实标识、完整产品名称变体、可执行文件名对应的崩溃报告、WebKit 与 NSURLSession 的应用子缓存,以及当前用户的临时缓存。共享父目录保留,名称匹配项默认不勾选。 +- OpenCode 增加 `.config/opencode` 与 `.cache/opencode` 候选;这些目录可能与命令行共用,默认保留,供用户逐项选择。 + +**新增** + +- 合并菜单栏可在「设置 · 菜单栏 · 显示项目」用上移、下移按钮调整图标顺序,立即更新,并保存和同步设置;关闭项目后再开启仍记住位置。回应 [Issue #7](https://github.com/gentpan/OpenStats/issues/7)。 + +
+ +
2026-10-09 · 0.6.3 · 修复 3 **修复** @@ -86,23 +105,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
-
-2026-09-24 · 0.6.3 · 新增 2 · 修复 4 - -**新增** - -- 网络测速可以选线路:开着 Surge、Clash 等 VPN 或代理时,窗口顶部出现“直连 / 经 Surge”切换。直连绑定物理网卡、绕开代理,测的是本机宽带;经代理测的是代理线路。下载、上传与延迟都按选的线路走,每张卡片标出这次走的是哪条。 -- 国内分省三网延迟改用电信、联通、移动的标志,不再用“电 / 联 / 移”字块。 - -**修复** - -- 开着 VPN 时网络测速的延迟全是假的(零点几到十几毫秒):TCP 建连被本机代理当场应答,测到的只是本机到代理这一段,与节点远近无关。现在直连时绕开代理计时;经代理时改用同一条连接上的 HTTP 往返,每次都要等目标真的回应。国内三网节点只允许测建连,始终直连测,VPN 不让绕开时直接提示,不再给出假数字。远距离线路偶尔丢握手包,国内节点多测一次、超时放宽到 4 秒。 -- 本机宽带的延迟扣掉 Cloudflare 响应头里报的服务器处理时间,与 Cloudflare 官方测速的算法一致。 -- 测速后顶栏出现“本次用掉”时,标题“网络测速”被挤成竖排。 -- 绕开代理解析域名时,Cloudflare DoH 连不上就换 Google、阿里的 DoH,出口与分流窗口的原始出口归属地也因此更容易查到。 - -
- ## 活跃度