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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
run: make test

- name: Build app
run: make build CONFIG=Release
run: make build CONFIG=Release BUMP=0 INSTALL=0

- name: Verify bundle
run: |
Expand Down
372 changes: 188 additions & 184 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.
372 changes: 188 additions & 184 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.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@

## 未发布

### 2026-10-09

#### 修复

- 合并显示时菜单栏的小标签颜色过淡:图标中带有彩色网速圆点时,CPU、内存等标签会直接画成灰色,在深色菜单栏上不容易看清。标签和网速箭头现在与数值使用同一种前景色,仍通过字号区分标签与读数。跟进 [Issue #5](https://github.com/gentpan/OpenStats/issues/5)。
- 应用卸载的残留查找增强:读取应用真实名称、内嵌辅助组件的包名和签名中的应用组,按容器元数据识别 UUID 命名的沙盒数据;最近文档、CrashReporter、Sparkle、SentryCrash 和厂商目录内的应用子项也会检查。补上 VS Code、Insiders、Arc、Chrome 的明确数据位置。共享容器、按名称找到的 Sentry 数据和可能共用的辅助组件数据会标明并默认不勾选,厂商共享父目录始终保留。扫描及回收前重查路径,跳过符号链接重定向。参考 Pearcleaner 的查找思路,跟进 [Issue #4](https://github.com/gentpan/OpenStats/issues/4)。
- 应用卸载有部分文件未能移到废纸篓时,只统计实际成功的项目与空间,保留失败项供重试;只有应用本体成功移除才清除程序坞图标和累计卸载次数。

### 2026-10-01

#### 新增
Expand Down
2 changes: 1 addition & 1 deletion Packages/OpenStatsKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ let package = Package(
.testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]),
.testTarget(name: "UpdatesTests", dependencies: ["Updates"]),
.testTarget(name: "AccountSyncTests", dependencies: ["AccountSync"]),
.testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics", "AccountSync"]),
.testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics", "AccountSync", "Cleaner"]),
.testTarget(name: "LocalizationTests", dependencies: ["Localization"]),
],
swiftLanguageModes: [.v6]
Expand Down
151 changes: 151 additions & 0 deletions Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import Foundation
import Security

/// 只从应用本身与受限的嵌套 bundle 中收集身份,不用开发团队或通用 helper 名称推断归属。
struct AppUninstallIdentity: Sendable {
let identifiers: Set<String>
let names: Set<String>
let applicationGroups: Set<String>
let teamIdentifier: String?

init(identifiers: Set<String>, names: Set<String>, applicationGroups: Set<String> = [], teamIdentifier: String? = nil) {
self.identifiers = Set(identifiers.compactMap(Self.validIdentifier))
self.names = Set(names.compactMap(Self.validName))
self.applicationGroups = Set(applicationGroups.compactMap(Self.validIdentifier))
self.teamIdentifier = teamIdentifier.flatMap(Self.validTeamIdentifier)
}

init(app: InstalledApp) {
var identifiers = Set([app.bundleIdentifier])
var names = Set([app.name, app.url.deletingPathExtension().lastPathComponent])
var groups = Set<String>()
var teamIdentifier: String?

// 父目录的系统别名(例如 /var)可以规范化,应用本身与内部路径不接受符号链接。
if let values = try? app.url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]),
values.isDirectory == true, values.isSymbolicLink != true {
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"] {
if let name = metadata[key] as? String { names.insert(name) }
}
}
if let signature = Self.signature(at: root) {
groups.formUnion(signature.groups)
teamIdentifier = signature.teamIdentifier
}
for bundle in Self.embeddedBundles(in: root) {
if let metadata = Self.metadata(at: bundle, inside: root),
let identifier = metadata["CFBundleIdentifier"] as? String {
identifiers.insert(identifier)
if let signature = Self.signature(at: bundle) { groups.formUnion(signature.groups) }
}
}
}

self.init(identifiers: identifiers, names: names, applicationGroups: groups, teamIdentifier: teamIdentifier)
}

static func validIdentifier(_ value: String) -> String? {
guard !value.isEmpty, value.utf8.count <= 255 else { return nil }
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")
let alphanumeric = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
guard parts.count >= 2, parts.allSatisfy({ part in
!part.isEmpty && part.unicodeScalars.allSatisfy(allowed.contains)
&& part.unicodeScalars.contains(where: alphanumeric.contains)
}) else { return nil }
return value
}

static func validName(_ value: String) -> String? {
guard value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { return nil }
let name = value.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty, name != ".", name != "..", name.utf8.count <= 255,
!name.contains("/"), !name.contains("\\") else { return nil }
return name
}

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 }
return value
}

static func applicationGroups(from entitlements: [String: Any]) -> Set<String> {
let groups = entitlements["com.apple.security.application-groups"] as? [Any] ?? []
return Set(groups.compactMap { ($0 as? String).flatMap(validIdentifier) })
}

/// 必须实际位于根 bundle 内,且整个内部路径不能经由符号链接。
static func isSafeURL(_ url: URL, inside root: URL) -> Bool {
let path = url.standardizedFileURL.path
let rootPath = root.standardizedFileURL.path
guard path == rootPath || path.hasPrefix(rootPath + "/"),
url.resolvingSymlinksInPath().standardizedFileURL.path == path,
let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]),
values.isSymbolicLink != true else { return false }
return true
}

static func metadata(at bundle: URL, inside root: URL) -> [String: Any]? {
let url = bundle.appendingPathComponent("Contents/Info.plist")
guard isSafeURL(bundle, inside: root), isSafeURL(url, inside: root),
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]),
values.isRegularFile == true, let size = values.fileSize, size <= 1_048_576,
let data = try? Data(contentsOf: url),
let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) else { return nil }
return plist as? [String: Any]
}

static func embeddedBundles(in root: URL) -> [URL] {
let locations = ["Contents/Frameworks", "Contents/XPCServices", "Contents/PlugIns"]
let ignoredDirectories: Set<String> = ["Resources", "_CodeSignature", "MacOS", "Headers", "Modules"]
var pending = locations.map { (url: root.appendingPathComponent($0), depth: 0) }
var visited = Set<String>()
var results: [URL] = []
var examined = 0
let manager = FileManager.default

while !pending.isEmpty, examined < 512, results.count < 64 {
let (directory, depth) = pending.removeFirst()
guard depth <= 8, isSafeURL(directory, inside: root), visited.insert(directory.path).inserted,
let children = try? manager.contentsOfDirectory(at: directory,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: .skipsHiddenFiles) else { continue }
for child in children.sorted(by: { $0.path < $1.path }) {
guard examined < 512, results.count < 64 else { break }
examined += 1
guard isSafeURL(child, inside: root),
let values = try? child.resourceValues(forKeys: [.isDirectoryKey]), values.isDirectory == true else { continue }
if ["app", "xpc", "appex"].contains(child.pathExtension.lowercased()),
let info = metadata(at: child, inside: root), let identifier = info["CFBundleIdentifier"] as? String,
validIdentifier(identifier) != nil {
results.append(child)
}
if depth < 8, !ignoredDirectories.contains(child.lastPathComponent) {
pending.append((child, depth + 1))
}
}
}
return results
}

private struct Signature {
let groups: Set<String>
let teamIdentifier: String?
}

private static func signature(at url: URL) -> Signature? {
var code: SecStaticCode?
guard SecStaticCodeCreateWithPath(url as CFURL, [], &code) == errSecSuccess, let code else { return nil }
let validationFlags = SecCSFlags(rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures)
guard SecStaticCodeCheckValidity(code, validationFlags, nil) == errSecSuccess else { return nil }
var information: CFDictionary?
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
let dictionary = information as? [String: Any] else { return nil }
let entitlements = dictionary[kSecCodeInfoEntitlementsDict as String] as? [String: Any] ?? [:]
let team = (dictionary[kSecCodeInfoTeamIdentifier as String] as? String).flatMap(validTeamIdentifier)
return Signature(groups: applicationGroups(from: entitlements), teamIdentifier: team)
}
}
Loading
Loading