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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Assets/readme/activity.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions Assets/readme/activity.zh.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 49 additions & 4 deletions Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import Security
struct AppUninstallIdentity: Sendable {
let identifiers: Set<String>
let names: Set<String>
/// 小写的完整名称候选;只用于受限目录中的精确匹配,名称本身不能证明独占归属。
let nameVariants: Set<String>
let applicationGroups: Set<String>
let teamIdentifier: String?

init(identifiers: Set<String>, names: Set<String>, applicationGroups: Set<String> = [], 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)
}
Expand All @@ -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) }
}
}
Expand Down Expand Up @@ -67,6 +71,46 @@ struct AppUninstallIdentity: Sendable {
return name
}

/// 保留产品的版本和渠道,只转换名称里的空格;不从包名、厂商名或通用 helper 名推测产品。
static func safeNameVariants(for value: String) -> Set<String> {
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<String> = [
"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 }
Expand All @@ -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
Expand All @@ -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<String> = ["Resources", "_CodeSignature", "MacOS", "Headers", "Modules"]
var pending = locations.map { (url: root.appendingPathComponent($0), depth: 0) }
var visited = Set<String>()
Expand Down
Loading
Loading