- 账号与同步:GitHub / Google / Apple 登录后把菜单栏项目与风格等偏好同步到 getopenstats.com,换一台 Mac 登录即恢复(AccountSync 模块、SyncController、SettingsDocument、账号设置页,openstats:// 回调) - 磁盘页成为工具页:已用 / 可清除 / 可用分段容量条、空间分析、文件系统检查、本地快照清理(辅助工具协议 v4)、其他磁盘推出 - 公网 IP:归属地数据源可选 CleanIP.io / ipapi.is / DB-IP / ipinfo.io / 本地 GeoLite2,每台 Mac 自己查、本地缓存 7 天;IPv4 / IPv6 双栈各自查询与切换;弹窗显示 ASN、运营方、原生 / 住宅等徽章与纯净度评分色带;接口区块可重置上传 / 下载统计 - 弹窗:右上角按钮改为该指标自己的图标 + 总设置;主窗口页面标题栏的开关点不动已修复;滚动条改为悬浮式,内容不再偏左;网络弹窗卡片被标题行撑宽的问题已修复 - 设置:⌘ 拖动排序说明;每个菜单栏项目单独选风格并带实时预览;网络与磁盘的进程榜单固定 5 行并平滑排序;CPU 持续高负载通知 - 设计系统:分段条、评分色带、标签徽章、胶囊切换、刷新按钮等组件与徽章配色 token - 更新日志、架构说明、README 与译文同步
350 lines
16 KiB
Swift
350 lines
16 KiB
Swift
import Foundation
|
||
import Localization
|
||
|
||
// MARK: - Time Machine 本地快照
|
||
|
||
/// Time Machine 在本机保留的快照。它们计入“可清除”空间,系统缺空间时会自动删,也可以手动清掉
|
||
public struct LocalSnapshot: Sendable, Equatable, Identifiable {
|
||
public let name: String
|
||
/// 形如 2026-09-14-120000,删除时用它指定快照
|
||
public let identifier: String
|
||
public let date: Date?
|
||
|
||
public var id: String { name }
|
||
}
|
||
|
||
public enum LocalSnapshots {
|
||
static let prefix = "com.apple.TimeMachine."
|
||
static let suffix = ".local"
|
||
|
||
/// 解析 `tmutil listlocalsnapshots /` 的输出
|
||
public static func parse(_ output: String) -> [LocalSnapshot] {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||
formatter.timeZone = .current
|
||
return output.split(whereSeparator: \.isNewline).compactMap { line in
|
||
let name = line.trimmingCharacters(in: .whitespaces)
|
||
guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { return nil }
|
||
let identifier = String(name.dropFirst(prefix.count).dropLast(suffix.count))
|
||
return LocalSnapshot(name: name, identifier: identifier, date: formatter.date(from: identifier))
|
||
}
|
||
.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) }
|
||
}
|
||
|
||
/// 列出启动盘上的本地快照;tmutil 只读列出不需要权限
|
||
public static func list() -> [LocalSnapshot] {
|
||
parse(ShellTool.run("/usr/bin/tmutil", ["listlocalsnapshots", "/"]).output)
|
||
}
|
||
}
|
||
|
||
// MARK: - 文件系统检查
|
||
|
||
public struct VolumeVerification: Sendable, Equatable {
|
||
public let ok: Bool
|
||
/// 一句话结论
|
||
public let summary: String
|
||
/// diskutil 的完整输出
|
||
public let log: String
|
||
public let date: Date
|
||
|
||
public init(ok: Bool, summary: String, log: String, date: Date = Date()) {
|
||
self.ok = ok
|
||
self.summary = summary
|
||
self.log = log
|
||
self.date = date
|
||
}
|
||
}
|
||
|
||
/// 用系统自带的 diskutil 做一次只读的文件系统检查,相当于“磁盘工具”里的急救但不修改任何东西。
|
||
/// 检查启动盘不需要管理员权限,通常几秒到几十秒
|
||
public enum VolumeVerifier {
|
||
public static func verify(volume: String = "/") -> VolumeVerification {
|
||
let result = ShellTool.run("/usr/sbin/diskutil", ["verifyVolume", volume])
|
||
return interpret(status: result.status, output: result.output)
|
||
}
|
||
|
||
static func interpret(status: Int32, output: String) -> VolumeVerification {
|
||
let lines = output.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }
|
||
let ok = status == 0 && lines.contains { $0.contains("appears to be OK") }
|
||
let summary: String
|
||
if ok {
|
||
summary = tr("文件系统结构完好,没有发现错误")
|
||
} else if let problem = lines.first(where: { $0.lowercased().contains("error") || $0.lowercased().contains("invalid") || $0.lowercased().contains("corrupt") }) {
|
||
summary = tr("发现问题:\(problem)")
|
||
} else if status != 0 {
|
||
summary = tr("检查未能完成(退出码 \(status))")
|
||
} else {
|
||
summary = tr("检查完成,但没有得到明确结论")
|
||
}
|
||
return VolumeVerification(ok: ok, summary: summary, log: output)
|
||
}
|
||
}
|
||
|
||
// MARK: - 已装载的卷宗
|
||
|
||
public struct MountedVolume: Sendable, Equatable, Identifiable {
|
||
public let url: URL
|
||
public let name: String
|
||
public let total: UInt64
|
||
public let available: UInt64
|
||
public let isInternal: Bool
|
||
public let isRemovable: Bool
|
||
public let isEjectable: Bool
|
||
public let isLocal: Bool
|
||
|
||
public var id: String { url.path }
|
||
public var used: UInt64 { total > available ? total - available : 0 }
|
||
public var usedFraction: Double { total == 0 ? 0 : Double(used) / Double(total) }
|
||
}
|
||
|
||
public enum MountedVolumes {
|
||
/// 启动盘之外、访达里能看到的卷:外置硬盘、U 盘、镜像、网络共享
|
||
public static func list() -> [MountedVolume] {
|
||
let keys: Set<URLResourceKey> = [.volumeNameKey, .volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey,
|
||
.volumeIsInternalKey, .volumeIsRemovableKey, .volumeIsEjectableKey,
|
||
.volumeIsRootFileSystemKey, .volumeIsBrowsableKey, .volumeIsLocalKey]
|
||
let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: Array(keys), options: [.skipHiddenVolumes]) ?? []
|
||
return urls.compactMap { url in
|
||
guard let values = try? url.resourceValues(forKeys: keys),
|
||
values.volumeIsRootFileSystem != true, values.volumeIsBrowsable != false,
|
||
let total = values.volumeTotalCapacity, total > 0 else { return nil }
|
||
return MountedVolume(url: url,
|
||
name: values.volumeName ?? url.lastPathComponent,
|
||
total: UInt64(total),
|
||
available: UInt64(max(0, values.volumeAvailableCapacityForImportantUsage ?? 0)),
|
||
isInternal: values.volumeIsInternal ?? false,
|
||
isRemovable: values.volumeIsRemovable ?? false,
|
||
isEjectable: values.volumeIsEjectable ?? false,
|
||
isLocal: values.volumeIsLocal ?? true)
|
||
}
|
||
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||
}
|
||
}
|
||
|
||
// MARK: - 空间占用分析
|
||
|
||
public struct SpaceScanResult: Sendable, Equatable {
|
||
public struct Folder: Sendable, Equatable, Identifiable {
|
||
public let url: URL
|
||
public let name: String
|
||
public let bytes: UInt64
|
||
public let items: Int
|
||
public var id: String { url.path }
|
||
}
|
||
|
||
public struct File: Sendable, Equatable, Identifiable {
|
||
public let url: URL
|
||
public let bytes: UInt64
|
||
public let modified: Date?
|
||
public let isPackage: Bool
|
||
public var id: String { url.path }
|
||
public var name: String { url.lastPathComponent }
|
||
}
|
||
|
||
public let root: URL
|
||
/// 根目录下每个一级条目,按大小降序
|
||
public let folders: [Folder]
|
||
/// 最大的文件(应用、照片图库等包按整体算一个),按大小降序
|
||
public let largestFiles: [File]
|
||
public let scannedItems: Int
|
||
public let totalBytes: UInt64
|
||
public let date: Date
|
||
|
||
public init(root: URL, folders: [Folder], largestFiles: [File], scannedItems: Int, totalBytes: UInt64, date: Date) {
|
||
self.root = root
|
||
self.folders = folders
|
||
self.largestFiles = largestFiles
|
||
self.scannedItems = scannedItems
|
||
self.totalBytes = totalBytes
|
||
self.date = date
|
||
}
|
||
}
|
||
|
||
public struct SpaceScanProgress: Sendable, Equatable {
|
||
public var items: Int
|
||
public var bytes: UInt64
|
||
public var current: String
|
||
|
||
public init(items: Int, bytes: UInt64, current: String) {
|
||
self.items = items
|
||
self.bytes = bytes
|
||
self.current = current
|
||
}
|
||
}
|
||
|
||
/// 扫描一个目录(默认家目录):算出每个一级条目占多少,并找出最大的文件。
|
||
/// 用 fts 逐层遍历,比 FileManager 的枚举器快一个数量级;只读,不跟随符号链接、不跨越挂载点,
|
||
/// 没有权限的目录跳过。扫描在调用方的任务里进行,任务取消即停止
|
||
public enum SpaceScanner {
|
||
public static let largestFileCount = 20
|
||
/// 小于这个体积的文件不进“最大的文件”
|
||
public static let largestFileFloor: UInt64 = 50_000_000
|
||
static let progressEvery = 5_000
|
||
/// 按整体算一个条目的包:应用、各种图库、工程与文档包
|
||
static let packageExtensions: Set<String> = [
|
||
"app", "appex", "bundle", "framework", "plugin", "kext", "xpc", "qlgenerator", "prefpane",
|
||
"photoslibrary", "musiclibrary", "tvlibrary", "imovielibrary", "fcpbundle", "logicx", "band",
|
||
"xcodeproj", "xcworkspace", "playground", "xcarchive", "dSYM",
|
||
"pages", "numbers", "key", "rtfd", "scriv", "sparsebundle", "pvm", "vmwarevm", "utm", "pkg", "mpkg",
|
||
]
|
||
|
||
public static func scan(root: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||
progress: @Sendable (SpaceScanProgress) -> Void = { _ in }) throws -> SpaceScanResult {
|
||
let manager = FileManager.default
|
||
let keys: Set<URLResourceKey> = [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey,
|
||
.totalFileAllocatedSizeKey, .contentModificationDateKey]
|
||
let entries = (try? manager.contentsOfDirectory(at: root, includingPropertiesForKeys: Array(keys), options: [])) ?? []
|
||
|
||
var folders: [SpaceScanResult.Folder] = []
|
||
var candidates: [SpaceScanResult.File] = []
|
||
var scanned = 0
|
||
var totalBytes: UInt64 = 0
|
||
|
||
func consider(_ file: SpaceScanResult.File) {
|
||
guard file.bytes >= largestFileFloor else { return }
|
||
if candidates.count < largestFileCount {
|
||
candidates.append(file)
|
||
} else if let smallest = candidates.indices.min(by: { candidates[$0].bytes < candidates[$1].bytes }),
|
||
candidates[smallest].bytes < file.bytes {
|
||
candidates[smallest] = file
|
||
}
|
||
}
|
||
|
||
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||
try Task.checkCancellation()
|
||
guard let values = try? entry.resourceValues(forKeys: keys), values.isSymbolicLink != true else { continue }
|
||
var bytes: UInt64 = 0
|
||
var items = 0
|
||
if values.isDirectory == true {
|
||
let walked = try walk(entry, scannedBefore: scanned, bytesBefore: totalBytes, consider: consider, progress: progress)
|
||
bytes = walked.bytes
|
||
items = walked.items
|
||
} else if values.isRegularFile == true {
|
||
bytes = UInt64(values.totalFileAllocatedSize ?? 0)
|
||
items = 1
|
||
consider(SpaceScanResult.File(url: entry, bytes: bytes, modified: values.contentModificationDate, isPackage: false))
|
||
} else {
|
||
continue
|
||
}
|
||
scanned += items
|
||
totalBytes += bytes
|
||
folders.append(SpaceScanResult.Folder(url: entry, name: entry.lastPathComponent, bytes: bytes, items: items))
|
||
progress(SpaceScanProgress(items: scanned, bytes: totalBytes, current: entry.lastPathComponent))
|
||
}
|
||
|
||
return SpaceScanResult(root: root,
|
||
folders: folders.sorted { $0.bytes > $1.bytes },
|
||
largestFiles: candidates.sorted { $0.bytes > $1.bytes },
|
||
scannedItems: scanned, totalBytes: totalBytes, date: Date())
|
||
}
|
||
|
||
/// 用 fts 遍历一个目录:返回占用与条目数;包整体作为一个候选,包里的文件不单独进候选
|
||
private static func walk(_ directory: URL, scannedBefore: Int, bytesBefore: UInt64,
|
||
consider: (SpaceScanResult.File) -> Void,
|
||
progress: (SpaceScanProgress) -> Void) throws -> (bytes: UInt64, items: Int) {
|
||
var argv: [UnsafeMutablePointer<CChar>?] = [strdup(directory.path), nil]
|
||
defer { free(argv[0]) }
|
||
guard let stream = fts_open(&argv, FTS_PHYSICAL | FTS_NOCHDIR | FTS_XDEV, nil) else { return (0, 0) }
|
||
defer { fts_close(stream) }
|
||
|
||
var bytes: UInt64 = 0
|
||
var items = 0
|
||
// 正在经过的包(最外层在前);内层包的体积并入外层
|
||
var packages: [(path: String, bytes: UInt64, modified: Date?)] = []
|
||
let name = directory.lastPathComponent
|
||
|
||
while let entry = fts_read(stream) {
|
||
let info = Int32(entry.pointee.fts_info)
|
||
// 顶层目录本身不算条目
|
||
if entry.pointee.fts_level == 0 { continue }
|
||
switch info {
|
||
case FTS_D:
|
||
items += 1
|
||
let path = String(cString: entry.pointee.fts_path)
|
||
if isPackage(path) {
|
||
let stat = entry.pointee.fts_statp.pointee
|
||
packages.append((path, 0, Date(timeIntervalSince1970: TimeInterval(stat.st_mtimespec.tv_sec))))
|
||
}
|
||
case FTS_DP:
|
||
let path = String(cString: entry.pointee.fts_path)
|
||
if let last = packages.last, last.path == path {
|
||
packages.removeLast()
|
||
if packages.isEmpty {
|
||
consider(SpaceScanResult.File(url: URL(fileURLWithPath: path), bytes: last.bytes, modified: last.modified, isPackage: true))
|
||
} else {
|
||
packages[packages.count - 1].bytes += last.bytes
|
||
}
|
||
}
|
||
case FTS_F:
|
||
items += 1
|
||
let stat = entry.pointee.fts_statp.pointee
|
||
let size = UInt64(max(0, stat.st_blocks)) * 512
|
||
bytes += size
|
||
if packages.isEmpty {
|
||
consider(SpaceScanResult.File(url: URL(fileURLWithPath: String(cString: entry.pointee.fts_path)), bytes: size,
|
||
modified: Date(timeIntervalSince1970: TimeInterval(stat.st_mtimespec.tv_sec)), isPackage: false))
|
||
} else {
|
||
packages[packages.count - 1].bytes += size
|
||
}
|
||
default:
|
||
// 符号链接、没权限的目录、读取出错的条目都跳过
|
||
continue
|
||
}
|
||
if items % progressEvery == 0 {
|
||
try Task.checkCancellation()
|
||
progress(SpaceScanProgress(items: scannedBefore + items, bytes: bytesBefore + bytes, current: name))
|
||
}
|
||
}
|
||
return (bytes, items)
|
||
}
|
||
|
||
static func isPackage(_ path: String) -> Bool {
|
||
let ext = (path as NSString).pathExtension
|
||
return !ext.isEmpty && packageExtensions.contains(ext.lowercased()) || packageExtensions.contains(ext)
|
||
}
|
||
|
||
/// 能不能把扫描出来的文件移到废纸篓:必须在家目录里、不在 Library 里、路径里没有隐藏目录,
|
||
/// 解析符号链接后仍然满足。不满足的只提供“在访达中显示”
|
||
public static func canTrash(_ url: URL, home: URL = FileManager.default.homeDirectoryForCurrentUser) -> Bool {
|
||
let homePath = home.standardizedFileURL.path
|
||
for candidate in [url.standardizedFileURL, url.resolvingSymlinksInPath().standardizedFileURL] {
|
||
let path = candidate.path
|
||
guard path.hasPrefix(homePath + "/") else { return false }
|
||
let relative = path.dropFirst(homePath.count + 1)
|
||
let components = relative.split(separator: "/")
|
||
guard components.count >= 2 else { return false }
|
||
guard components.first != "Library" else { return false }
|
||
guard !components.contains(where: { $0.hasPrefix(".") }) else { return false }
|
||
}
|
||
return true
|
||
}
|
||
|
||
/// 移到废纸篓(可恢复);不允许的路径直接报错
|
||
public static func trash(_ url: URL) throws {
|
||
guard canTrash(url) else { throw CocoaError(.fileWriteNoPermission) }
|
||
try FileManager.default.trashItem(at: url, resultingItemURL: nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - 外部命令
|
||
|
||
enum ShellTool {
|
||
static func run(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) {
|
||
let process = Process()
|
||
process.executableURL = URL(fileURLWithPath: path)
|
||
process.arguments = arguments
|
||
let pipe = Pipe()
|
||
process.standardOutput = pipe
|
||
process.standardError = pipe
|
||
do {
|
||
try process.run()
|
||
} catch {
|
||
return (-1, error.localizedDescription)
|
||
}
|
||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||
process.waitUntilExit()
|
||
return (process.terminationStatus, String(decoding: data, as: UTF8.self))
|
||
}
|
||
}
|