账号同步、磁盘工具、IP 归属地数据源与纯净度、弹窗与设置调整
- 账号与同步: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 与译文同步
This commit is contained in:
1 parent
480a7b551d
commit
c9d722f20d
58 files changed
+5223
-820
No files matched your search
@@ -12,6 +12,7 @@ let package = Package(
|
||||
.library(name: "HelperShared", targets: ["HelperShared"]),
|
||||
.library(name: "Cleaner", targets: ["Cleaner"]),
|
||||
.library(name: "Updates", targets: ["Updates"]),
|
||||
.library(name: "AccountSync", targets: ["AccountSync"]),
|
||||
.library(name: "OpenStatsUI", targets: ["OpenStatsUI"]),
|
||||
],
|
||||
targets: [
|
||||
@@ -21,12 +22,14 @@ let package = Package(
|
||||
.target(name: "HelperShared", dependencies: ["Localization"]),
|
||||
.target(name: "Cleaner", dependencies: ["Localization"]),
|
||||
.target(name: "Updates", dependencies: ["Localization"]),
|
||||
.target(name: "OpenStatsUI", dependencies: ["Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates"],
|
||||
resources: [.copy("Resources/Flags")]),
|
||||
.target(name: "AccountSync", dependencies: ["Localization"]),
|
||||
.target(name: "OpenStatsUI", dependencies: ["Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "AccountSync"],
|
||||
resources: [.copy("Resources/Flags"), .copy("Resources/Logos")]),
|
||||
.testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]),
|
||||
.testTarget(name: "CleanerTests", dependencies: ["Cleaner"]),
|
||||
.testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]),
|
||||
.testTarget(name: "UpdatesTests", dependencies: ["Updates"]),
|
||||
.testTarget(name: "AccountSyncTests", dependencies: ["AccountSync"]),
|
||||
.testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics"]),
|
||||
.testTarget(name: "LocalizationTests", dependencies: ["Localization"]),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Localization
|
||||
|
||||
/// 支持的登录方式;服务器 /auth/providers 告诉应用哪些已经配置好
|
||||
public enum SyncProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case github, google, apple
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .github: "GitHub"
|
||||
case .google: "Google"
|
||||
case .apple: "Apple"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SyncUser: Codable, Equatable, Sendable {
|
||||
public var id: String
|
||||
public var email: String?
|
||||
public var name: String?
|
||||
public var avatar: URL?
|
||||
public var providers: [String]
|
||||
|
||||
public init(id: String, email: String? = nil, name: String? = nil, avatar: URL? = nil, providers: [String] = []) {
|
||||
self.id = id
|
||||
self.email = email
|
||||
self.name = name
|
||||
self.avatar = avatar
|
||||
self.providers = providers
|
||||
}
|
||||
|
||||
/// 界面上显示的名字:姓名,其次邮箱
|
||||
public var displayName: String { name?.isEmpty == false ? name! : (email ?? id) }
|
||||
}
|
||||
|
||||
/// 云端保存的一份设置:整份覆盖,版本号递增
|
||||
public struct RemoteSettings<Document: Codable & Sendable>: Codable, Sendable {
|
||||
public var version: Int
|
||||
public var updatedAt: Date
|
||||
public var device: String?
|
||||
public var document: Document
|
||||
}
|
||||
|
||||
public struct SettingsReceipt: Codable, Equatable, Sendable {
|
||||
public var version: Int
|
||||
public var updatedAt: Date
|
||||
}
|
||||
|
||||
public struct SyncSession: Codable, Equatable, Sendable {
|
||||
public var token: String
|
||||
public var user: SyncUser
|
||||
}
|
||||
|
||||
public enum SyncError: Error, Equatable, LocalizedError {
|
||||
/// 令牌已作废(退出登录、删除账号或服务器清理)
|
||||
case unauthorized
|
||||
case server(Int, String)
|
||||
case transport(String)
|
||||
case invalidCallback
|
||||
case cancelled
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unauthorized: tr("登录已失效,请重新登录")
|
||||
case .server(let status, let message): message.isEmpty ? tr("服务器返回 \(status)") : message
|
||||
case .transport(let message): message
|
||||
case .invalidCallback: tr("登录回调不正确")
|
||||
case .cancelled: tr("已取消登录")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步服务的 HTTP 客户端:只做请求与解码,不保存状态
|
||||
public struct SyncClient: Sendable {
|
||||
public static let defaultBaseURL = URL(string: "https://getopenstats.com/api/v1")!
|
||||
/// 应用在 Info.plist 里注册的 URL scheme,登录完成后浏览器跳回这里
|
||||
public static let callbackScheme = "openstats"
|
||||
|
||||
public let baseURL: URL
|
||||
private let session: URLSession
|
||||
|
||||
public init(baseURL: URL = SyncClient.defaultBaseURL, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: 登录
|
||||
|
||||
/// 浏览器打开的登录入口;challenge 是 PKCE verifier 的 S256 摘要
|
||||
public func loginURL(provider: SyncProvider, challenge: String) -> URL {
|
||||
var components = URLComponents(url: baseURL.appending(path: "auth/\(provider.rawValue)/start"), resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [URLQueryItem(name: "challenge", value: challenge)]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
/// 解析 openstats://auth/callback?code=… 或 ?error=…
|
||||
public static func parseCallback(_ url: URL) -> Result<String, SyncError> {
|
||||
guard url.scheme == callbackScheme,
|
||||
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else {
|
||||
return .failure(.invalidCallback)
|
||||
}
|
||||
if let code = items.first(where: { $0.name == "code" })?.value, !code.isEmpty {
|
||||
return .success(code)
|
||||
}
|
||||
let error = items.first(where: { $0.name == "error" })?.value ?? ""
|
||||
return .failure(error == "cancelled" ? .cancelled : .server(0, error))
|
||||
}
|
||||
|
||||
public func providers() async throws -> [SyncProvider] {
|
||||
struct Response: Decodable { var providers: [String] }
|
||||
let response: Response = try await send(request("GET", "auth/providers"))
|
||||
return response.providers.compactMap(SyncProvider.init(rawValue:))
|
||||
}
|
||||
|
||||
public func exchange(code: String, verifier: String, device: String) async throws -> SyncSession {
|
||||
struct Body: Encodable { var code: String; var verifier: String; var device: String }
|
||||
return try await send(request("POST", "auth/exchange", body: Body(code: code, verifier: verifier, device: device)))
|
||||
}
|
||||
|
||||
public func me(token: String) async throws -> SyncUser {
|
||||
try await send(request("GET", "me", token: token))
|
||||
}
|
||||
|
||||
public func logout(token: String) async throws {
|
||||
_ = try await sendRaw(request("POST", "auth/logout", token: token))
|
||||
}
|
||||
|
||||
public func deleteAccount(token: String) async throws {
|
||||
_ = try await sendRaw(request("DELETE", "account", token: token))
|
||||
}
|
||||
|
||||
// MARK: 设置
|
||||
|
||||
/// 没有保存过时返回 nil
|
||||
public func fetchSettings<Document: Codable & Sendable>(token: String, as type: Document.Type) async throws -> RemoteSettings<Document>? {
|
||||
let (data, status) = try await sendRaw(request("GET", "settings", token: token), allowing: [404])
|
||||
guard status != 404 else { return nil }
|
||||
return try Self.decoder.decode(RemoteSettings<Document>.self, from: data)
|
||||
}
|
||||
|
||||
public func putSettings<Document: Codable & Sendable>(token: String, document: Document, device: String) async throws -> SettingsReceipt {
|
||||
try await send(request("PUT", "settings", token: token, body: PutSettingsBody(document: document, device: device)))
|
||||
}
|
||||
|
||||
private struct PutSettingsBody<Document: Encodable>: Encodable {
|
||||
var document: Document
|
||||
var device: String
|
||||
}
|
||||
|
||||
// MARK: 请求
|
||||
|
||||
private static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}()
|
||||
|
||||
private func request(_ method: String, _ path: String, token: String? = nil, body: (some Encodable)? = nil as Data?) -> URLRequest {
|
||||
var request = URLRequest(url: baseURL.appending(path: path), cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
|
||||
request.httpMethod = method
|
||||
request.setValue("OpenStats", forHTTPHeaderField: "User-Agent")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONEncoder().encode(body)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private func send<Response: Decodable>(_ request: URLRequest) async throws -> Response {
|
||||
let (data, _) = try await sendRaw(request)
|
||||
do {
|
||||
return try Self.decoder.decode(Response.self, from: data)
|
||||
} catch {
|
||||
throw SyncError.server(200, tr("服务器返回的数据格式不正确"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回响应体与状态码;2xx 与 allowing 之外的状态码转成 SyncError
|
||||
private func sendRaw(_ request: URLRequest, allowing: Set<Int> = []) async throws -> (Data, Int) {
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
throw SyncError.transport(error.localizedDescription)
|
||||
}
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if (200..<300).contains(status) || allowing.contains(status) { return (data, status) }
|
||||
if status == 401 { throw SyncError.unauthorized }
|
||||
struct Failure: Decodable { var error: String }
|
||||
let message = (try? JSONDecoder().decode(Failure.self, from: data))?.error ?? ""
|
||||
throw SyncError.server(status, message)
|
||||
}
|
||||
}
|
||||
|
||||
/// PKCE(RFC 7636):verifier 留在应用里,只把 S256 摘要交给服务器
|
||||
public enum PKCE {
|
||||
public static func verifier() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return Data(bytes).base64URLEncoded
|
||||
}
|
||||
|
||||
public static func challenge(for verifier: String) -> String {
|
||||
Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
var base64URLEncoded: String {
|
||||
base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// 保存登录令牌的地方:正式环境是钥匙串,测试用内存
|
||||
public protocol TokenStore: Sendable {
|
||||
func read() -> String?
|
||||
func write(_ token: String) throws
|
||||
func delete()
|
||||
}
|
||||
|
||||
/// 登录钥匙串里的通用密码项。用文件式钥匙串而不是数据保护钥匙串:后者要求应用有 application-identifier,
|
||||
/// 本地 ad-hoc 签名的开发构建没有
|
||||
public struct KeychainTokenStore: TokenStore {
|
||||
public let service: String
|
||||
public let account: String
|
||||
|
||||
public init(service: String = "com.openstats.sync", account: String = "session") {
|
||||
self.service = service
|
||||
self.account = account
|
||||
}
|
||||
|
||||
private var query: [String: Any] {
|
||||
[kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account]
|
||||
}
|
||||
|
||||
public func read() -> String? {
|
||||
var query = query
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func write(_ token: String) throws {
|
||||
let data = Data(token.utf8)
|
||||
let update = [kSecValueData as String: data]
|
||||
var status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var item = query
|
||||
item[kSecValueData as String] = data
|
||||
item[kSecAttrLabel as String] = "OpenStats 同步"
|
||||
status = SecItemAdd(item as CFDictionary, nil)
|
||||
}
|
||||
guard status == errSecSuccess else { throw KeychainError(status: status) }
|
||||
}
|
||||
|
||||
public func delete() {
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeychainError: Error, LocalizedError {
|
||||
public let status: OSStatus
|
||||
|
||||
public var errorDescription: String? {
|
||||
(SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error \(status)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试与截图用的内存实现
|
||||
public final class MemoryTokenStore: TokenStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var token: String?
|
||||
|
||||
public init(token: String? = nil) { self.token = token }
|
||||
|
||||
public func read() -> String? { lock.withLock { token } }
|
||||
public func write(_ token: String) { lock.withLock { self.token = token } }
|
||||
public func delete() { lock.withLock { token = nil } }
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ public enum HelperConstants {
|
||||
public static let launchdPlistName = "com.openstats.helper.plist"
|
||||
public static let appBundleIdentifier = "com.openstats.app"
|
||||
/// 与 App 版本同步;App 发现辅助工具版本不一致时提示重新安装
|
||||
public static let protocolVersion = 3
|
||||
public static let protocolVersion = 4
|
||||
|
||||
/// 辅助工具对调用方的签名要求:与辅助工具自身同一团队签名的 OpenStats。
|
||||
/// ad-hoc 签名的开发构建没有团队,只能校验 bundle identifier。
|
||||
@@ -43,6 +43,8 @@ public enum CodeSigningInfo {
|
||||
func purgeMemory(reply: @escaping @Sendable (String?) -> Void)
|
||||
/// 为指定网络服务设置 DNS;`servers` 为空表示恢复自动获取。辅助工具会校验服务名与每个地址
|
||||
func setDNSServers(service: String, servers: [String], reply: @escaping @Sendable (String?) -> Void)
|
||||
/// 删除 Time Machine 本地快照;每个标识都按固定格式校验,逐个执行
|
||||
func deleteLocalSnapshots(identifiers: [String], reply: @escaping @Sendable (String?) -> Void)
|
||||
}
|
||||
|
||||
/// 需要管理员权限的系统维护命令。辅助工具与未安装辅助工具时的授权回退共用同一份固定命令
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Time Machine 本地快照的删除命令。辅助工具与未安装辅助工具时的授权回退共用同一份参数校验:
|
||||
/// 快照只能用形如 2026-09-14-120000 的时间标识指定,不接受任何其他字符
|
||||
public enum LocalSnapshotCommand {
|
||||
public static let tool = "/usr/bin/tmutil"
|
||||
public static let maxCount = 64
|
||||
|
||||
public static func isValidIdentifier(_ identifier: String) -> Bool {
|
||||
let scalars = Array(identifier.unicodeScalars)
|
||||
guard scalars.count == 17 else { return false }
|
||||
for (index, scalar) in scalars.enumerated() {
|
||||
if index == 4 || index == 7 || index == 10 {
|
||||
guard scalar == "-" else { return false }
|
||||
} else {
|
||||
guard ("0"..."9").contains(scalar) else { return false }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public static func arguments(identifier: String) -> [String] {
|
||||
["deletelocalsnapshots", identifier]
|
||||
}
|
||||
|
||||
/// 授权回退用的一整条 shell;调用前必须已用 isValidIdentifier 校验
|
||||
public static func shellCommand(identifiers: [String]) -> String {
|
||||
identifiers.map { "\(tool) deletelocalsnapshots \($0)" }.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,12 @@ extension Translations {
|
||||
· {} 项使用中 · {} in use
|
||||
· 移到废纸篓 · Moves to Trash
|
||||
# OpenStats 诊断信息 # OpenStats Diagnostics
|
||||
1 个传感器 1 sensor
|
||||
1 个应用,系统自带的不列出 1 app; built-in apps are not listed
|
||||
1 个进程 1 process
|
||||
1 分钟 1 min
|
||||
1 小时 1 hour
|
||||
1 项 1 item
|
||||
10 根柱子是最近 10 次采样(约 20 秒)的变化 10 bars show the last 10 samples (about 20 seconds)
|
||||
15 分钟 15 min
|
||||
2 小时 2 hours
|
||||
@@ -28,6 +32,9 @@ Apple 智能没有给出回答:{} Apple Intelligence didn't answer: {}
|
||||
Apple 智能解释 Apple Intelligence
|
||||
Apple(系统自带) Apple (built in)
|
||||
Application Support 中只允许清理缓存目录 Only cache folders inside Application Support can be cleaned
|
||||
CPU 占用 {},已持续超过 1 分钟。可以打开 CPU 详情,在“按应用汇总”里看看是哪个应用在占用。 CPU usage has been {} for over a minute. Open the CPU details and check By App to see which app is busy.
|
||||
CPU 总占用持续 1 分钟高于设定值,通常是某个应用卡住或在后台狂跑 Total CPU usage stays above the set level for 1 minute, usually an app that is stuck or running wild in the background
|
||||
CPU 持续高负载 CPU Under Sustained Load
|
||||
CPU 时间 CPU Time
|
||||
CPU 时间 {} CPU time {}
|
||||
CPU 最高 CPU max
|
||||
@@ -53,6 +60,8 @@ GPU 占用 GPU Usage
|
||||
Google(8.8.8.8) Google (8.8.8.8)
|
||||
IP 地址 IP Addresses
|
||||
IP 归属地数据库 IP Location Database
|
||||
IP 类型 IP type
|
||||
IP 纯净度 IP cleanliness
|
||||
OpenStats {} 已发布 OpenStats {} is available
|
||||
OpenStats 控制 Controlled by OpenStats
|
||||
OpenStats 控制中 Controlled by OpenStats
|
||||
@@ -62,6 +71,7 @@ OpenStats 通知测试 OpenStats Test Notification
|
||||
OpenStats 防休眠 OpenStats Keep Awake
|
||||
OpenStats 需要授权以安装新版本。 OpenStats needs permission to install the new version.
|
||||
OpenStats 需要管理员权限来修改“{}”的 DNS。 OpenStats needs administrator permission to change DNS for “{}”.
|
||||
OpenStats 需要管理员权限来删除 Time Machine 本地快照。 OpenStats needs administrator privileges to delete Time Machine local snapshots.
|
||||
OpenStats 需要管理员权限来刷新 DNS 缓存。 OpenStats needs administrator permission to flush the DNS cache.
|
||||
OpenStats 需要管理员权限来释放内存。 OpenStats needs administrator permission to free memory.
|
||||
OpenStats-诊断-{} OpenStats-Diagnostics-{}
|
||||
@@ -71,15 +81,18 @@ SMC [{}] 被固件拒绝 (0x{}) SMC [{}] rejected by firmware (0x{})
|
||||
SMC [{}] 调用失败 ({}) SMC [{}] call failed ({})
|
||||
SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License SMC access and the Apple Silicon fan unlock sequence are ported from this project · MIT License
|
||||
SSD 健康 SSD Health
|
||||
Time Machine 在备份之间会先在本机留下快照,它们占的空间计入“可清除”,系统缺空间时会自动删。手动删除不影响已完成的备份。 Between backups Time Machine keeps snapshots on this Mac. Their space counts as purgeable and macOS removes them when it runs low. Deleting them by hand does not affect completed backups.
|
||||
VPN / 代理 VPN / Proxy
|
||||
VPN 隧道 VPN Tunnel
|
||||
Xcode 归档 Xcode Archives
|
||||
Xcode 编译缓存 Xcode Build Cache
|
||||
ipapi.is 在线查询 ipapi.is online lookup
|
||||
ipinfo.io 在线查询 ipinfo.io (online)
|
||||
macOS 系统目录(系统组件) macOS system folder (system component)
|
||||
npm 缓存 npm Cache
|
||||
{} {} 英寸 {} {}-inch
|
||||
{} · {} 核{} {} · {} cores{}
|
||||
{} · {} 个 {} · {}
|
||||
{} · {} 核 {} · {} cores
|
||||
{} · 每 {} 秒 {} · every {}s
|
||||
{} · 约 {} {} · about {}
|
||||
{} · 负载 {}/{} {} · load {}/{}
|
||||
@@ -89,6 +102,7 @@ npm 缓存 npm Cache
|
||||
{} 个进程 {} processes
|
||||
{} 分钟 {} min
|
||||
{} 可用 {} · 共 {} {} {} free · {} total
|
||||
{} 在线查询 {} online lookup
|
||||
{} 天 {} d
|
||||
{} 天 {} 小时 {} d {} h
|
||||
{} 小时 {} h
|
||||
@@ -110,6 +124,7 @@ npm 缓存 npm Cache
|
||||
{}%(阈值 {}%) {}% (threshold {}%)
|
||||
{}{}只剩 {}%,记得充电。 {}{} is down to {}%. Time to charge.
|
||||
{}{}({}) {}{} ({})
|
||||
{}。系统会自动分配任务,不用你操心 {}. macOS assigns work between them automatically.
|
||||
{}中 {}
|
||||
{}前 {} ago
|
||||
{}后充满 Full in {}
|
||||
@@ -122,13 +137,18 @@ npm 缓存 npm Cache
|
||||
~/Library/Caches 中各应用的缓存,删除后会按需重建 App caches in ~/Library/Caches; rebuilt as needed
|
||||
~/Library/Logs 中的日志和诊断报告 Logs and diagnostic reports in ~/Library/Logs
|
||||
“{}”只剩 {} 可用。可以用 OpenStats 的清理功能释放缓存。 “{}” has only {} free. Use OpenStats Cleanup to clear caches.
|
||||
“{}”({})会移到废纸篓,可以从废纸篓放回。 “{1}” ({2}) will be moved to the Trash; you can put it back from there.
|
||||
、 ,
|
||||
一般 Fair
|
||||
一键安装 Install Now
|
||||
上传 Upload
|
||||
上传 {} · 下载 {} Up {} · Down {}
|
||||
上传与下载速度、IP 地址、DNS Upload and download speed, IP addresses, DNS
|
||||
上传本机设置 Upload this Mac's settings
|
||||
上次同步:{} Last synced: {}
|
||||
上次检查 {},最新 {} Last checked {}, latest {}
|
||||
上次检查发现问题 Last check found problems
|
||||
上次检查正常 Last check was fine
|
||||
上次检查:{} Last checked: {}
|
||||
下载 Download
|
||||
下载 {} 失败 Failed to download {}
|
||||
@@ -151,25 +171,40 @@ npm 缓存 npm Cache
|
||||
丢包 Loss
|
||||
严重 Critical
|
||||
中等负载 Moderate load
|
||||
中等风险 Medium risk
|
||||
中继 Relay
|
||||
主显示器 Main display
|
||||
主窗口与弹窗的配色;菜单栏始终跟随系统 Colors for the window and popovers; the menu bar always follows the system
|
||||
云端只保存邮箱、姓名与设置文档 The cloud only keeps your email, name and the settings document
|
||||
云端已有 {} 在 {} 保存的设置,和这台 Mac 上的不一样。要用哪一份? {1} saved settings to the cloud {2}, and they differ from this Mac's. Which one should be used?
|
||||
交换区 Swap
|
||||
仅系统不休眠 Keep system awake
|
||||
介质错误 Media errors
|
||||
从左侧选择应用,或把应用拖到这里 Choose an app on the left, or drop one here
|
||||
从文件导入… Import from File…
|
||||
从未 Never
|
||||
从程序坞移除图标 Remove from Dock
|
||||
代理 Proxy
|
||||
以单核满载为 100% 100% = one full core
|
||||
以后再说 Later
|
||||
以系统权限运行的后台服务,只接受本应用的请求 A background service with system privileges that only accepts requests from this app
|
||||
仪表盘 Dashboard
|
||||
企业 Business
|
||||
企业 IP Business IP
|
||||
企业网络 Business network
|
||||
会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回 Also finds its caches, preferences, containers and launch agents in your Library and moves them all to the Trash, where you can restore them
|
||||
会结束这个应用的 {} 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 This ends {} processes of the app and unsaved work may be lost. Force Quit ends them immediately without letting the app save.
|
||||
传输速率 Link rate
|
||||
位置 Location
|
||||
位置:{} Location: {}
|
||||
低负载 Light load
|
||||
低风险 Low risk
|
||||
住宅 IP Residential IP
|
||||
住宅代理 Residential proxy
|
||||
住宅宽带 Residential
|
||||
住宅概率 Residential probability
|
||||
余量 {} Headroom {}
|
||||
使用 {} 登录 Sign in with {}
|
||||
使用云端设置 Use cloud settings
|
||||
使用历史 Usage History
|
||||
使用电池 On battery
|
||||
使用电池且电量低于该值时,自动关闭合盖运行 Turn off lid-closed mode when on battery below this level
|
||||
@@ -180,6 +215,7 @@ npm 缓存 npm Cache
|
||||
修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。 Changing DNS needs administrator permission: it's applied directly when the helper is installed, otherwise the system asks each time.
|
||||
偏好设置 Preferences
|
||||
偏高 Elevated
|
||||
停止 Stop
|
||||
停用 Turn Off
|
||||
停用失败:{} Failed to turn off: {}
|
||||
健康 {} Health {}
|
||||
@@ -191,18 +227,21 @@ npm 缓存 npm Cache
|
||||
全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃) Global shortcuts (work in any app; must include ⌘, ⌥ or ⌃)
|
||||
全选 Select All
|
||||
全部 All
|
||||
全部删除 Delete all
|
||||
公网 IP Public IP
|
||||
公网 IP 查询 Public IP Lookup
|
||||
公网 IPv4 Public IPv4
|
||||
公网 IPv6 Public IPv6
|
||||
共 {} {} total
|
||||
共 {} 条记录 {} records
|
||||
共 {},{} 个条目 · {} {1} in {2} items · {3}
|
||||
关 Off
|
||||
关于 About
|
||||
关于 OpenStats About OpenStats
|
||||
关闭 Off
|
||||
其他 Other
|
||||
其他位置 Other locations
|
||||
其他磁盘 Other disks
|
||||
其他程序手动控制 Controlled by another app
|
||||
其他程序控制 Another app
|
||||
内存 Memory
|
||||
@@ -222,6 +261,13 @@ npm 缓存 npm Cache
|
||||
切换到深色 Switch to Dark
|
||||
刚刚 Just now
|
||||
刚刚被使用 Recently used
|
||||
删除 Delete
|
||||
删除… Delete…
|
||||
删除中… Deleting…
|
||||
删除云端数据 Delete cloud data
|
||||
删除云端数据? Delete cloud data?
|
||||
删除全部本地快照? Delete all local snapshots?
|
||||
删除账号、所有登录方式与云端保存的设置;这台 Mac 上的设置保留 Deletes the account, every sign-in method and the settings stored in the cloud; settings on this Mac stay
|
||||
到时后自动恢复系统默认的睡眠行为。 Normal sleep behavior resumes when time runs out.
|
||||
刷新 Refresh
|
||||
刷新 DNS 缓存 Flush DNS Cache
|
||||
@@ -231,6 +277,7 @@ npm 缓存 npm Cache
|
||||
刷新频率 Refresh Rate
|
||||
剩余 {} {} left
|
||||
剩余寿命 Life left
|
||||
剩余寿命 {}% · 已写入 {} {}% life left · {} written
|
||||
剪切 Cut
|
||||
功耗 Power
|
||||
包名是 {} Bundle ID is {}
|
||||
@@ -240,6 +287,7 @@ npm 缓存 npm Cache
|
||||
单行文字 Inline Text
|
||||
占用 Usage
|
||||
占用超过 85% 时数值与图形显示为红色 Show values and graphs in red above 85%
|
||||
占用高于 Usage above
|
||||
卷 Volume
|
||||
卸载 Uninstall
|
||||
卸载“{}”? Uninstall “{}”?
|
||||
@@ -258,19 +306,25 @@ npm 缓存 npm Cache
|
||||
压缩包里应当只有一个 .app The archive should contain exactly one .app
|
||||
压缩比 Compression ratio
|
||||
原因 Reason
|
||||
原生 / 广播 Native / broadcast
|
||||
原生 IP Native IP
|
||||
去批准 Approve
|
||||
去授权 Grant Access
|
||||
双行圆点 Dots
|
||||
双行文字 Stacked Text
|
||||
双行箭头 Arrows
|
||||
反向解析 Reverse DNS
|
||||
反馈问题 Report a Problem
|
||||
发现新版本 {} Version {} available
|
||||
发现问题 Problems found
|
||||
发现问题:{} Problem found: {}
|
||||
发生这些状况时发送系统通知,点通知打开对应页面 Send a notification when these happen; click it to open the related page
|
||||
发送 Send
|
||||
发送测试通知 Send Test Notification
|
||||
取消 Cancel
|
||||
受保护 Protected
|
||||
受保护的项目:{} Protected item: {}
|
||||
另一台 Mac Another Mac
|
||||
另有 {} 移到废纸篓 Plus {} moved to Trash
|
||||
另有 {} 项 {} more
|
||||
只含当前用户的进程 Your processes only
|
||||
@@ -278,16 +332,21 @@ npm 缓存 npm Cache
|
||||
只在菜单栏显示 GPU 或打开 GPU 相关页面时记录 Recorded only when GPU is in the menu bar or a GPU page is open
|
||||
只在菜单栏显示温度、开启过热通知或打开温度页面时记录 Recorded only when temperature is in the menu bar, overheating alerts are on, or a temperature page is open
|
||||
只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒) Sampling interval when only the menu bar is shown; 1 second with a popover or window open (2 seconds on Processes)
|
||||
只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动 Only files named after the app's bundle ID, plus folders with the app's name in Application Support and Logs, are included. Keychains and data shared with other apps are never touched.
|
||||
只有国家、省 / 州与城市;把公网 IP 发给 db-ip.com Country, state and city only; sends your public IP to db-ip.com
|
||||
只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除 Only files named after the app's bundle ID, plus folders with the app's name in Application Support and Logs, are included. Keychains and data shared with other apps are never touched. The Dock icon is removed as well
|
||||
可以恢复,但清空废纸篓前不会释放空间 Recoverable, but space isn't freed until the Trash is emptied
|
||||
可执行文件:{} Executable: {}
|
||||
可清理 · 已选 {} 项 Cleanable · {} selected
|
||||
可清除 Purgeable
|
||||
可清除是系统随时可以腾出的缓存;访达显示的“可用”把它算在内 Purgeable is cache the system can free at any time; Finder's “available” figure includes it.
|
||||
可用 Available
|
||||
可用 {} {} available
|
||||
可用 {} / 共 {} {1} free of {2}
|
||||
可用内存只剩 {},系统正在压缩和交换内存。关闭不用的应用可以缓解。 Only {} of memory is available and the system is compressing and swapping. Quitting unused apps will help.
|
||||
可用空间包含系统可以随时清除的缓存,与访达显示一致 Available space includes purgeable caches, matching Finder
|
||||
右侧风扇 Right Fan
|
||||
右耳 Right
|
||||
各核心占用 Per-Core Load
|
||||
各核心负载 Core Load
|
||||
各项指标正常 Everything looks good
|
||||
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 Your Mac stays awake with the lid closed, so downloads, renders and remote sessions continue.
|
||||
@@ -299,12 +358,15 @@ npm 缓存 npm Cache
|
||||
合盖运行时散热变差,请勿放入包中。使用电池且电量低于 {}% 时会自动关闭。 Cooling is worse with the lid closed, so don't put your Mac in a bag. Turns off automatically on battery below {}%.
|
||||
合盖运行设置 Lid Mode
|
||||
合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。 Lid mode changes system sleep settings and needs the helper (one-time admin approval).
|
||||
同时从程序坞移除图标 Also remove its Dock icon
|
||||
同步 Sync
|
||||
同步失败 Sync failed
|
||||
同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具、历史记录与归属地库留在本机。 What syncs: menu bar items and styles, refresh rate, appearance and language, popover sections, connection probe, notifications, hotkeys, fan safety temperature and lid-mode battery floor. The current page, the helper, history and the GeoIP database stay on this Mac.
|
||||
后台低频探测 Background Probing
|
||||
启动于 Booted
|
||||
启动时和之后每天检查一次,发现新版本时显示更新摘要 Checks at launch and daily, and shows what's new when an update is found
|
||||
启动磁盘 Startup Disk
|
||||
启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次 Startup disk has less than 10% or 10 GB free; checked every 10 minutes
|
||||
启动磁盘已用占比 Startup disk used
|
||||
启动项 Startup Items
|
||||
启用 Turn On
|
||||
启用 {} Enable {}
|
||||
@@ -320,6 +382,9 @@ npm 缓存 npm Cache
|
||||
图标 Icon
|
||||
圆环 Ring
|
||||
圆环表示当前占用比例 A ring shows current usage
|
||||
在 cleanip.io 查看这个 IP 的完整报告 Open this IP's full report on cleanip.io
|
||||
在主窗口打开“{}” Open “{}” in the main window
|
||||
在本机的 MaxMind 数据库里查国家、城市与 ASN,不向任何服务发送 IP;数据库未就绪时暂用 CleanIP.io Looks up country, city and ASN in the local MaxMind database and sends your IP nowhere; uses CleanIP.io until the database is ready
|
||||
在线升级 Online Updates
|
||||
在线升级:发现新版本时显示更新摘要,一键安装并自动重启 Online updates: see what's new, install in one click and relaunch automatically
|
||||
在线查询 Online lookup
|
||||
@@ -332,6 +397,7 @@ npm 缓存 npm Cache
|
||||
处理器核心 CPU cores
|
||||
备用空间 Available spare
|
||||
备用空间低于阈值,建议备份 Spare capacity below threshold; back up your data
|
||||
外置硬盘、U 盘、镜像与网络共享 External drives, USB sticks, disk images and network shares
|
||||
外观 Appearance
|
||||
存储 Storage
|
||||
它只做这几件事 It only does these things
|
||||
@@ -343,8 +409,10 @@ npm 缓存 npm Cache
|
||||
安装失败:{} Install failed: {}
|
||||
安装辅助工具 Install Helper
|
||||
安静 Quiet
|
||||
完整报告 Full report
|
||||
定时 ping 一个地址,记录网络是否通畅 Pings an address regularly to record whether the network is reachable
|
||||
定时探测网络 Connectivity Probe
|
||||
家目录 Home folder
|
||||
容量 Capacity
|
||||
导入失败:{} Import failed: {}
|
||||
导出… Export…
|
||||
@@ -362,6 +430,7 @@ npm 缓存 npm Cache
|
||||
屏幕可按设置关闭,下载、编译等后台任务继续运行 The display can turn off as usual while downloads and builds keep running
|
||||
屏幕和系统都不会因闲置而关闭或休眠 Neither the display nor the system sleeps when idle
|
||||
峰值 Peak
|
||||
峰值 {} Peak {}
|
||||
工具 Tools
|
||||
左侧风扇 Left Fan
|
||||
左耳 Left
|
||||
@@ -369,15 +438,21 @@ npm 缓存 npm Cache
|
||||
已使用 Developer ID 签名(团队 {}),辅助工具只接受同一团队签名的 OpenStats。 Signed with Developer ID (team {}); the helper only accepts OpenStats signed by the same team.
|
||||
已停用 Off
|
||||
已充满 Charged
|
||||
已删除 {} 个快照,腾出的空间稍后会反映在“可清除”里 Deleted {} snapshots; the freed space will show up under purgeable shortly
|
||||
已到设定时间,防休眠已关闭 Time's up; Keep Awake is off
|
||||
已加载 Loaded
|
||||
已取消 Cancelled
|
||||
已取消登录 Sign-in cancelled
|
||||
已启用 Enabled
|
||||
已导入 {} Imported {}
|
||||
已导出 {} Exported {}
|
||||
已将 {} 与 {} 项残留移到废纸篓,约 {}{}{}。需要时可以在废纸篓里放回。 Moved {} and {} leftovers to the Trash, about {}{}{}. You can put them back from the Trash.
|
||||
已开启同步 Sync is on
|
||||
已恢复自动获取 DNS DNS is automatic again
|
||||
已扫描 {} 个条目 · {} Scanned {} items · {}
|
||||
已把“{}”移到废纸篓 Moved “{}” to the Trash
|
||||
已拷贝 Copied
|
||||
已推出“{}” Ejected “{}”
|
||||
已是最新版本 Up to date
|
||||
已更新 {} 个数据库 Updated {} databases
|
||||
已用 Used
|
||||
@@ -393,11 +468,13 @@ npm 缓存 npm Cache
|
||||
已释放 Freed
|
||||
已释放 {} 缓存内存 Freed {} of cached memory
|
||||
布局 Layout
|
||||
干净 Clean
|
||||
平均 Average
|
||||
平均 {} · 峰值 {}{} Avg {} · Peak {}{}
|
||||
平均 {} · 最高 {} Avg {} · Max {}
|
||||
平均温度 Average temperature
|
||||
平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU Load average per core: below 1 means no waiting; above 1 means tasks are waiting for the CPU
|
||||
广播 IP Broadcast IP
|
||||
序列号 Serial Number
|
||||
应用 Apps
|
||||
应用 {} / 辅助工具 {} App {} / Helper {}
|
||||
@@ -421,6 +498,8 @@ npm 缓存 npm Cache
|
||||
开发者 Developer
|
||||
开启 On
|
||||
开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep) Turn lid-closed sleep prevention on or off (same as pmset disablesleep)
|
||||
开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置 Adds the icon to the menu bar; hold ⌘ and drag the icon to move it
|
||||
开始分析 Analyze
|
||||
开机以来 Since boot
|
||||
开机后上传 Uploaded since boot
|
||||
开机后下载 Downloaded since boot
|
||||
@@ -428,10 +507,14 @@ npm 缓存 npm Cache
|
||||
异常断电 Unsafe shutdowns
|
||||
弹窗显示 Popover Sections
|
||||
强冷 Max
|
||||
强制刷新:忽略缓存,立即重新查询公网 IP 与归属地 Force refresh: ignore the cache and look up the public IP and location again now
|
||||
强制退出 Force Quit
|
||||
归属地 Location
|
||||
归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名;把公网 IP 发给 cleanip.io Location, ASN, network type, native / broadcast, cleanliness and risk scores; sends your public IP to cleanip.io
|
||||
归属地与 ASN;把公网 IP 发给 ipapi.is,匿名额度每天 30 次,同一 IP 的结果记住一天 Location and ASN; sends your public IP to ipapi.is. Anonymous quota is 30 a day, so a result is kept for a day
|
||||
归属地与 ASN;把公网 IP 发给 ipinfo.io,匿名额度很小,容易被限流 Location and ASN; sends your public IP to ipinfo.io. The anonymous quota is tiny and easily exhausted
|
||||
归属地数据库 Location database
|
||||
当前 {} Current {}
|
||||
归属地数据源 Location data source
|
||||
当前 {} · 发布于 {} · {} · {} Current {} · Released {} · {} · {}
|
||||
当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。 This is an ad-hoc signed development build, so the helper can only check the bundle ID. Team verification turns on automatically with a Developer ID build.
|
||||
当前效果(实时数据) Preview (live data)
|
||||
@@ -444,6 +527,7 @@ npm 缓存 npm Cache
|
||||
循环次数 Cycle count
|
||||
忙 Busy
|
||||
快捷开关 Quick Toggles
|
||||
快照名称格式不认识,没有删除 Unrecognized snapshot names; nothing was deleted
|
||||
性能核 Performance
|
||||
总占用 Total
|
||||
恢复系统睡眠失败:{} Failed to restore system sleep: {}
|
||||
@@ -464,15 +548,19 @@ npm 缓存 npm Cache
|
||||
打开 SMC 失败 ({}) Failed to open SMC ({})
|
||||
打开登录项设置 Open Login Items Settings
|
||||
打开网络详情时使用 Used while network details are open
|
||||
打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求 Asks Cloudflare (1.1.1.1) or ipify for the public address once when network details open, at most every 10 minutes
|
||||
打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求 When network details open, asks Cloudflare (1.1.1.1) or ipify for your public address; location and ASN come from the local database. At most once every 10 minutes.
|
||||
打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询 Looks up your public address when network details open; until the local database is ready, location and ASN come from ipinfo.io
|
||||
打开进程页 Show Processes
|
||||
打开通知设置 Open Notification Settings
|
||||
托管 Hosting
|
||||
执行中 Working
|
||||
扫描中 Scanning
|
||||
扫描中… Scanning…
|
||||
扫描于 Scanned
|
||||
抖动 Jitter
|
||||
折线历史 Line History
|
||||
折线是最近 30 次采样(约 1 分钟)的走势 The line shows the last 30 samples (about a minute)
|
||||
拷贝 Copy
|
||||
拷贝 PID Copy PID
|
||||
拷贝 PID {} Copy PID {}
|
||||
@@ -480,8 +568,10 @@ npm 缓存 npm Cache
|
||||
按下快捷键… Press shortcut…
|
||||
按应用 By App
|
||||
按应用汇总 By App
|
||||
按文件夹 By folder
|
||||
按系统休眠 System sleep
|
||||
按进程 By Process
|
||||
换一台 Mac 登录后,菜单栏、外观、通知与快捷键等偏好会自动恢复。不登录也能使用全部功能。 Sign in on another Mac and your menu bar, appearance, notification and hotkey preferences come back automatically. Everything works without signing in.
|
||||
换入 / 换出 Swap in / out
|
||||
掌托 Palm rest
|
||||
排队程度 Run Queue
|
||||
@@ -489,14 +579,26 @@ npm 缓存 npm Cache
|
||||
探测间隔 Probe Interval
|
||||
接口 Interface
|
||||
接通电源 Plugged in
|
||||
推出 Eject
|
||||
推出中… Ejecting…
|
||||
搜索名称、PID 或用户 Search name, PID or user
|
||||
搜索应用 Search apps
|
||||
撤销 Undo
|
||||
改回开机后累计 Back to since-boot totals
|
||||
政府 Government
|
||||
教育网 Education
|
||||
散热模式 Cooling
|
||||
数据 Data
|
||||
数据来源 Source
|
||||
数据来源:cleanip.io Data source: cleanip.io
|
||||
数据来源:cleanip.io,点击打开官网 Data source: cleanip.io. Click to open the site
|
||||
整体风格 Overall style
|
||||
整体风格套用到所有项目。想让某个项目不一样,在下面“显示项目”里给它单独选一种,比如 CPU 用圆环、风扇用数字。 The overall style applies to every item. To make one item different, pick a style for it under Items below, say a ring for CPU and plain numbers for the fan.
|
||||
整机 System
|
||||
整机 {} System {}
|
||||
整机功耗 System Power
|
||||
文件系统检查 File system check
|
||||
文件系统结构完好,没有发现错误 The file system structure is intact; no errors found
|
||||
新版本未通过 Apple 公证检查,已放弃安装 The new version failed Apple notarization; installation stopped
|
||||
新版本需要 macOS {} 或更高版本 The new version requires macOS {} or later
|
||||
无 None
|
||||
@@ -504,6 +606,9 @@ npm 缓存 npm Cache
|
||||
无法创建电源断言({}) Couldn't create power assertion ({})
|
||||
无法创建签名要求 Couldn't create the signing requirement
|
||||
无法打开 Couldn't open
|
||||
无法打开登录窗口 Couldn't open the sign-in window
|
||||
无法推出“{}”:{} Couldn't eject “{1}”: {2}
|
||||
无法移到废纸篓:{} Couldn't move to the Trash: {}
|
||||
无法读取 Unreadable
|
||||
无法读取签名 Couldn't read the signature
|
||||
无法连接辅助工具 Couldn't connect to the helper
|
||||
@@ -519,14 +624,18 @@ npm 缓存 npm Cache
|
||||
显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB) Shows city names; the database is about 60 MB. When off, only the country database (about 9 MB) is downloaded.
|
||||
显示序列号 Show Serial Number
|
||||
显示项目 Items
|
||||
暂时没有拿到评分,稍后会再试 No score yet; it will try again later
|
||||
更新内容 What's New
|
||||
更新失败:{} Update failed: {}
|
||||
更省电,负责后台和轻量任务 More efficient; handles background and light work
|
||||
替换后未找到应用 App not found after replacement
|
||||
替换应用失败:{} Failed to replace the app: {}
|
||||
最低 {} · 最高 {} · 目标 {} RPM Min {} · Max {} · Target {} RPM
|
||||
最大的文件 Largest files
|
||||
最小化 Minimize
|
||||
最强 Strongest
|
||||
最忙的核心 Busiest core
|
||||
最快,重活优先交给它们 Fastest; heavy work goes here first
|
||||
最近 60 秒 Last 60 seconds
|
||||
最近没有应用在读写磁盘 No apps are reading or writing right now
|
||||
最近的内存压力走势 Recent memory pressure
|
||||
@@ -535,14 +644,17 @@ npm 缓存 npm Cache
|
||||
最高 / 平均 Max / Avg
|
||||
最高温度 Max temperature
|
||||
服务器上还没有数据库,稍后再试 The server doesn't have the database yet. Try again later.
|
||||
服务器在 getopenstats.com,不上传任何监控数据;随时可以删除。 The server is at getopenstats.com; no monitoring data is uploaded, and you can delete everything at any time.
|
||||
服务器清单里没有 {} {} is not in the server manifest
|
||||
服务器返回 {} Server returned {}
|
||||
服务器返回的数据格式不正确 The server returned malformed data
|
||||
未使用 Unused
|
||||
未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 Unsaved work may be lost. Force Quit ends it immediately without letting it save.
|
||||
未安装 Not Installed
|
||||
未完成的下载 Incomplete Downloads
|
||||
未开启,Mac 按系统设置休眠 Off; your Mac sleeps as usual
|
||||
未找到 AppleSMC 服务 AppleSMC service not found
|
||||
未检出 None detected
|
||||
未检测到可调节的风扇 No adjustable fans found
|
||||
未检测到风扇 No fans detected
|
||||
未知状态 Unknown status
|
||||
@@ -551,18 +663,24 @@ npm 缓存 npm Cache
|
||||
未连接 Not connected
|
||||
未连接网络 No network
|
||||
本产品包含 MaxMind 创建的 GeoLite2 数据,可从 https://www.maxmind.com 获取。 This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.
|
||||
本地 GeoLite2 数据库 Local GeoLite2 database
|
||||
本地 GeoLite2(MaxMind) Local GeoLite2 (MaxMind)
|
||||
本地 IPv4 Local IPv4
|
||||
本地 IPv6 Local IPv6
|
||||
本地 MaxMind GeoLite2 Local MaxMind GeoLite2
|
||||
本地快照 Local snapshots
|
||||
本机信息 This Mac
|
||||
机型 Model
|
||||
机型标识符 Model Identifier
|
||||
机房 Data center
|
||||
机房 IP Data center IP
|
||||
极好 Excellent
|
||||
极高风险 Very high risk
|
||||
构建 Build
|
||||
查看 View
|
||||
查看全部进程 All Processes
|
||||
查看包含的项目 Show items
|
||||
查看完整报告 View full report
|
||||
查看完整更新日志 Full Changelog
|
||||
查看清理日志 View Cleanup Log
|
||||
查询中… Looking up…
|
||||
@@ -572,13 +690,20 @@ npm 缓存 npm Cache
|
||||
标签与数值同一行,最易读 Label and value on one line; easiest to read
|
||||
标识 Identifier
|
||||
核心 Cores
|
||||
核心 {} Core {}
|
||||
核心 {}({})· {} Core {} ({}) · {}
|
||||
核心分工 Core Types
|
||||
核心数 Cores
|
||||
核心最高温度;余量是离 100°C 还差多少度,越接近 0 越可能因过热降频 Hottest core; headroom is how many degrees remain before 100°C — the closer to 0, the more likely thermal throttling
|
||||
核心热力图 Core Heatmap
|
||||
核心负载 Core Load
|
||||
检查 Check
|
||||
检查中… Checking…
|
||||
检查完成,但没有得到明确结论 The check finished without a clear verdict
|
||||
检查更新 Check for Updates
|
||||
检查更新… Check for Updates…
|
||||
检查更新失败 Update check failed
|
||||
检查未能完成(退出码 {}) The check didn't complete (exit code {})
|
||||
模式 Mode
|
||||
模拟器 Simulators
|
||||
模拟器缓存 Simulator Caches
|
||||
@@ -586,6 +711,8 @@ npm 缓存 npm Cache
|
||||
正在使用 In use
|
||||
正在修改 DNS… Changing DNS…
|
||||
正在刷新… Flushing…
|
||||
正在同步 Syncing
|
||||
正在同步… Syncing…
|
||||
正在安装,完成后 OpenStats 会自动重启。 Installing. OpenStats will relaunch when it's done.
|
||||
正在导出… Exporting…
|
||||
正在扫描应用… Scanning apps…
|
||||
@@ -593,6 +720,8 @@ npm 缓存 npm Cache
|
||||
正在更新… Updating…
|
||||
正在本机生成… Generating on this Mac…
|
||||
正在查找残留… Finding leftovers…
|
||||
正在查询… Looking up…
|
||||
正在核对启动盘的文件系统结构,通常几秒到几十秒 Verifying the startup disk's file system structure; this usually takes seconds to a minute
|
||||
正在核对校验值、开发者签名与 Apple 公证… Verifying checksum, developer signature and Apple notarization…
|
||||
正在检查… Checking…
|
||||
正在检查更新 Checking for updates
|
||||
@@ -608,16 +737,19 @@ npm 缓存 npm Cache
|
||||
正在读取风扇… Reading fans…
|
||||
正在释放… Freeing…
|
||||
正常 Normal
|
||||
此刻各核心的占用;超过 60% 变橙色、85% 变红色 Current load of each core; orange above 60%, red above 85%
|
||||
此设备没有可调节的风扇 This Mac has no adjustable fans
|
||||
每 3 天检查一次 Checks every 3 days
|
||||
每 3 天检查一次 · 上次检查 {} Checks every 3 days · last checked {}
|
||||
每个应用按一个核心满载为 100% 计算,与活动监视器一致,所以各应用相加可以超过顶部的总占用 Each app is measured with one full core as 100%, like Activity Monitor, so the apps can add up to more than the total above
|
||||
每个指标一个图标,点击弹出该项详情 One icon per metric; click for its details
|
||||
每分钟把主要指标的平均值与峰值写入本机数据库,保留 7 天,不上传 Writes averages and peaks of key metrics to a local database every minute. Kept for 7 days, never uploaded.
|
||||
每核 {} {} per core
|
||||
每根柱子一个核心,按核心类型着色 One bar per core, colored by core type
|
||||
每点 {} {} per point
|
||||
每秒让 CPU 从空闲中唤醒的次数,越高越耗电 Times per second the CPU is woken from idle; higher uses more power
|
||||
每秒读写磁盘的字节数 Bytes read and written per second
|
||||
每行一个核心,每列一次采样,最新的在右边 One row per core, one column per sample; newest on the right
|
||||
每行一个核心(共 {} 个),每列一次采样,颜色越深越忙;右侧粗条是此刻的占用 One row per core ({} in total), one column per sample, darker means busier; the wide bar on the right is the current load
|
||||
每项独立 Separate
|
||||
比 30 秒前低 {}% {}% lower than 30s ago
|
||||
比 30 秒前高 {}% {}% higher than 30s ago
|
||||
@@ -628,6 +760,7 @@ npm 缓存 npm Cache
|
||||
没有已连接的蓝牙设备 No connected Bluetooth devices
|
||||
没有检测到显示器 No displays detected
|
||||
没有移动任何文件{} Nothing was moved{}
|
||||
没有超过 50 MB 的文件 No files over 50 MB
|
||||
流量历史 Traffic History
|
||||
流量经过 {},系统 DNS 可能由它接管,修改后不一定生效。 Traffic goes through {}, which may take over system DNS, so changes might not apply.
|
||||
浅色 Light
|
||||
@@ -650,9 +783,9 @@ npm 缓存 npm Cache
|
||||
温度 Temperature
|
||||
温度与风扇 Thermals
|
||||
温度传感器 Temperature sensors
|
||||
温度余量 Thermal headroom
|
||||
温度单位 Temperature Unit
|
||||
满载 Maxed out
|
||||
滥用记录 Abuse reports
|
||||
点击拷贝 Click to copy
|
||||
版本 Version
|
||||
版本 {} Version {}
|
||||
@@ -663,8 +796,10 @@ npm 缓存 npm Cache
|
||||
物理地址 Hardware address
|
||||
状态 Status
|
||||
状态圆点 Status Dot
|
||||
状态正常 Healthy
|
||||
状态良好 Healthy
|
||||
现在 Now
|
||||
现在没有本地快照 No local snapshots right now
|
||||
用 Apple 智能解释 Explain with Apple Intelligence
|
||||
用 ping 测量延迟与丢包,在网络详情里以格子显示 Measures latency and loss with ping, shown as a grid in network details
|
||||
用户 User
|
||||
@@ -691,30 +826,42 @@ npm 缓存 npm Cache
|
||||
电量下限 Battery floor
|
||||
电量低于 {}%,已关闭合盖运行 Battery below {}%; lid mode turned off
|
||||
电量条 Meter
|
||||
登录以同步设置 Sign in to sync settings
|
||||
登录启动项 Launch Agents
|
||||
登录回调不正确 Invalid sign-in callback
|
||||
登录已失效,请重新登录 Your session has expired. Please sign in again.
|
||||
登录时启动 Launch at Login
|
||||
登录时运行 Runs at login
|
||||
登录项设置 Login Items
|
||||
监控 Monitor
|
||||
目标转速 Target speed
|
||||
相当于“磁盘工具”里的急救,但只检查不修改:核对启动盘的目录结构、文件分配与快照元数据是否一致。 Like First Aid in Disk Utility, but read-only: checks that the startup disk's directory structure, allocation and snapshot metadata are consistent.
|
||||
确认清理 Clean
|
||||
确认通知能正常显示 Check that notifications appear
|
||||
磁盘 Disk
|
||||
磁盘已用 {} Disk {} used
|
||||
磁盘报告了严重警告,建议尽快备份 The disk reported a critical warning; back up soon
|
||||
磁盘清理 Disk Cleanup
|
||||
磁盘空间不足 Low Disk Space
|
||||
磁盘空间偏紧 Disk space is tight
|
||||
离 100°C 还差多少度;越接近 0,越可能因过热降频 Degrees below 100°C; the closer to 0, the more likely thermal throttling
|
||||
离线查询 Offline lookup
|
||||
移到废纸篓 Move to Trash
|
||||
移到废纸篓? Move to the Trash?
|
||||
移动网络 Mobile
|
||||
移动网络 IP Mobile IP
|
||||
移动运营商 Mobile carrier
|
||||
空 empty
|
||||
空格 Space
|
||||
空闲 Idle
|
||||
空间占用 Space usage
|
||||
窗口 Window
|
||||
窗口状态 Saved State
|
||||
立即切换;显示器、应用名称等由系统提供的文字在下次启动时切换 Applies right away; names supplied by macOS, such as displays and apps, switch at next launch
|
||||
立即同步 Sync now
|
||||
立即检查更新 Check Now
|
||||
竖向电量条表示当前占用比例 A vertical meter shows current usage
|
||||
等待批准 Waiting for Approval
|
||||
等待选择 Waiting for your choice
|
||||
签名团队 Signing team
|
||||
签名方:{} Signed by: {}
|
||||
签名校验未通过:{} Signature check failed: {}
|
||||
@@ -732,6 +879,9 @@ npm 缓存 npm Cache
|
||||
累计写入 Data written
|
||||
累计读取 Data read
|
||||
繁忙 Busy
|
||||
纯净度 {} 分 Cleanliness score {}
|
||||
纯净度看信誉、来路、邻居与网络类型四项;机房 IP 常见信誉高、来路和类型偏低。数据来自 cleanip.io Cleanliness weighs reputation, origin, neighbors and network type; data-center IPs often score high on reputation but low on origin and type. Data from cleanip.io
|
||||
纯净度评分来自 CleanIP.io。在「设置 · 网络」把归属地数据源改为 CleanIP.io 后显示。 Cleanliness scores come from CleanIP.io. Choose it as the location data source in Settings › Network to show them.
|
||||
线程 Threads
|
||||
线程 {} Threads {}
|
||||
线程数(只能读取自己的进程) Thread count (your processes only)
|
||||
@@ -740,6 +890,7 @@ npm 缓存 npm Cache
|
||||
结束它会注销当前用户,已禁止 Ending it would log you out, so it's blocked
|
||||
结束进程… End Process…
|
||||
统一内存(CPU 与 GPU 共享) Unified memory (shared by CPU and GPU)
|
||||
统计家目录里每个文件夹占多少空间,并找出最大的文件;只读取大小,不改动任何文件。文件多时需要几十秒。 Measures how much space each folder in your home folder takes and finds the largest files. It only reads sizes and changes nothing; with many files it can take a minute.
|
||||
继续运行 Keep running
|
||||
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) Green is normal, orange is high (above 60%), red is very high (above 85%)
|
||||
缓存 Caches
|
||||
@@ -751,6 +902,7 @@ npm 缓存 npm Cache
|
||||
网络名称 Network name
|
||||
网络已恢复 Network Restored
|
||||
网络断开 Network Down
|
||||
网络类型 Network type
|
||||
网络运营方 Network operator
|
||||
网络连接中断超过 20 秒,恢复后再提示一次 Network has been down for over 20 seconds; notifies again when it's back
|
||||
网络连接已中断超过 20 秒。可以打开网络详情查看接口与路由器状态。 The network has been down for over 20 seconds. Open network details to check the interface and router.
|
||||
@@ -758,10 +910,12 @@ npm 缓存 npm Cache
|
||||
网络:{} Network: {}
|
||||
网页数据 Web Data
|
||||
网页缓存与脚本缓存,不涉及 Cookie、历史记录和密码 Web and script caches; cookies, history and passwords are untouched
|
||||
置信度 {}% Confidence {}%
|
||||
联动 Wired
|
||||
能效核 Efficiency
|
||||
腾讯 DNSPod Tencent
|
||||
腾讯(119.29.29.29) Tencent (119.29.29.29)
|
||||
自 {} 起 Since {}
|
||||
自动 Auto
|
||||
自动更新 Auto Update
|
||||
自动检查更新 Check Automatically
|
||||
@@ -776,8 +930,9 @@ npm 缓存 npm Cache
|
||||
菜单栏 Menu Bar
|
||||
菜单栏图标 Menu Bar Icons
|
||||
菜单栏布局 Menu Bar Layout
|
||||
菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。 You can reorder the icons in the menu bar: hold ⌘ and drag any of them, and the position sticks. macOS picks where a newly enabled item first appears, sometimes away from the others; just drag it over.
|
||||
菜单栏项目 Menu Bar Items
|
||||
菜单栏风格 Menu Bar Style
|
||||
菜单栏风格 Menu bar style
|
||||
蓝牙设备 Bluetooth Devices
|
||||
蓝牙设备电量低 Bluetooth Battery Low
|
||||
解压失败:{} Unzip failed: {}
|
||||
@@ -786,12 +941,16 @@ npm 缓存 npm Cache
|
||||
设置 Settings
|
||||
设置 · {} Settings · {}
|
||||
设置… Settings…
|
||||
设置改动后 2 秒内上传;启动、唤醒与每 15 分钟检查一次云端 Changes upload within 2 seconds; the cloud is checked at launch, on wake and every 15 minutes
|
||||
设置风扇目标转速,或恢复系统自动控制 Set fan target speeds, or return them to automatic
|
||||
评分由 cleanip.io 提供,点击查看完整报告 Score by cleanip.io. Click to open the full report
|
||||
该项目在菜单栏里的实时效果 Live preview of this item in the menu bar
|
||||
详情关闭时,菜单栏显示网络项期间每 {} 秒探测一次,打开详情就能看到最近的连接情况;关闭后只在打开详情时探测,更省电 While details are closed and the network item is in the menu bar, probes every {} seconds so recent results are ready when you open details. Turn off to probe only while details are open and save power.
|
||||
语言 Language
|
||||
请从菜单退出 OpenStats Quit OpenStats from its menu
|
||||
请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。 Turn on Apple Intelligence in System Settings → Apple Intelligence & Siri first.
|
||||
请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。 Allow OpenStats in System Settings › General › Login Items.
|
||||
请打开系统自带的“磁盘工具”,选择启动盘运行“急救”进行修复 Open the built-in Disk Utility, select the startup disk and run First Aid to repair it
|
||||
读 {} · 写 {} Read {} · Write {}
|
||||
读写最多的应用 Top Disk Activity
|
||||
读写速度 Read & Write
|
||||
@@ -802,9 +961,15 @@ npm 缓存 npm Cache
|
||||
负载在上升 Load rising
|
||||
负载在下降 Load falling
|
||||
负载平稳 Load steady
|
||||
账号 Account
|
||||
账号与云端设置会立即删除,无法恢复。 The account and cloud settings are deleted immediately and cannot be recovered.
|
||||
账号与同步 Account & Sync
|
||||
走势图显示最近多长时间 How far back the trend chart shows
|
||||
走势时长 Trend duration
|
||||
超级核 Super
|
||||
超过 1 天未更新的 .crdownload / .part / .download .crdownload / .part / .download files untouched for over a day
|
||||
跟随整体 Follow global
|
||||
跟随整体({}) Follow overall ({})
|
||||
跟随系统 System
|
||||
路径不可读 Path not readable
|
||||
路径包含 .. Path contains ..
|
||||
@@ -824,6 +989,7 @@ npm 缓存 npm Cache
|
||||
过热温度 Overheat Threshold
|
||||
运行中 · PID {} Running · PID {}
|
||||
还可用 Available
|
||||
还有 {} 个 {} more
|
||||
这个快捷键已被其他应用或系统占用,请换一个 This shortcut is used by another app or the system. Choose another.
|
||||
这台 Mac 不提供功耗读数 This Mac doesn't report power
|
||||
这台 Mac 不支持 Apple 智能。 This Mac doesn't support Apple Intelligence.
|
||||
@@ -843,6 +1009,7 @@ npm 缓存 npm Cache
|
||||
退出 OpenStats Quit OpenStats
|
||||
退出应用 Quit App
|
||||
退出应用… Quit App…
|
||||
退出登录 Sign out
|
||||
适中 Moderate
|
||||
选择 GeoLite2 City / Country / ASN 的 .mmdb 文件 Choose a GeoLite2 City / Country / ASN .mmdb file
|
||||
通用 General
|
||||
@@ -850,26 +1017,36 @@ npm 缓存 npm Cache
|
||||
通电次数 Power cycles
|
||||
通知 Notifications
|
||||
通知可以正常显示。发生你打开的状况时,会像这样提醒你。 Notifications are working. You'll be alerted like this when something you turned on happens.
|
||||
通过 {} 登录 Signed in with {}
|
||||
速度与省电介于两者之间 In between on speed and efficiency
|
||||
部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。 Some items need Full Disk Access to scan (Safari caches, Trash).
|
||||
配置方式 Configuration
|
||||
释放内存 Free Memory
|
||||
重做 Redo
|
||||
重新分析 Analyze again
|
||||
重新安装 Reinstall
|
||||
重新扫描 Rescan
|
||||
重新查询公网 IP Look Up Public IP Again
|
||||
重新生成 Regenerate
|
||||
重置上传与下载统计:从现在起重新累计。重启后自动回到开机后的累计;右键可改回 Reset the upload and download totals and count from now on. After a restart they go back to since-boot totals; right-click to switch back
|
||||
重置后上传 Uploaded since reset
|
||||
重置后下载 Downloaded since reset
|
||||
闲 Idle
|
||||
防休眠 Keep Awake
|
||||
防休眠已开启 Keep Awake is on
|
||||
阿里云 Alibaba Cloud
|
||||
阿里云(223.5.5.5) Alibaba Cloud (223.5.5.5)
|
||||
降温 Cooling
|
||||
隐私 Privacy
|
||||
隐私政策 Privacy policy
|
||||
隐藏 OpenStats Hide OpenStats
|
||||
隐藏序列号 Hide Serial Number
|
||||
需要 macOS 26 及以上,并在系统设置中开启 Apple 智能。 Requires macOS 26 or later with Apple Intelligence turned on in System Settings.
|
||||
需要完全磁盘访问权限 Needs Full Disk Access
|
||||
需要注意 Needs attention
|
||||
需要管理员授权 Needs admin approval
|
||||
需要管理员权限。已完成的 Time Machine 备份不受影响,只是本机上这些快照对应的时间点无法再从本地恢复。 Requires administrator privileges. Completed Time Machine backups are unaffected; you just won't be able to restore these points in time from this Mac.
|
||||
非常干净 Very clean
|
||||
风扇 Fans
|
||||
风扇 {} Fan {}
|
||||
风扇安全温度 Fan Safety Temperature
|
||||
@@ -877,21 +1054,19 @@ npm 缓存 npm Cache
|
||||
风扇设置 Fan Settings
|
||||
风扇转速 Fan Speed
|
||||
风格 Style
|
||||
风险标记 Risk flags
|
||||
风险评分 Risk score
|
||||
饼图 Pie
|
||||
饼图表示当前占用比例 A pie shows current usage
|
||||
首次发送时系统会询问是否允许通知 The system asks for permission the first time
|
||||
高占用进程 Top Processes
|
||||
高负载 Heavy load
|
||||
高负载时着色 Color High Load
|
||||
高风险 High risk
|
||||
({}) ({})
|
||||
, ,
|
||||
,{} 项未能移动 , {} couldn't be moved
|
||||
,已从程序坞移除 , removed from the Dock
|
||||
:{} : {}
|
||||
立即切换;显示器、应用名称等由系统提供的文字在下次启动时切换 Applies right away; names supplied by macOS, such as displays and apps, switch at next launch
|
||||
1 项 1 item
|
||||
1 个进程 1 process
|
||||
1 个应用,系统自带的不列出 1 app; built-in apps are not listed
|
||||
1 个传感器 1 sensor
|
||||
"""#
|
||||
}
|
||||
@@ -8,13 +8,15 @@ import Localization
|
||||
public enum DiskSampler {
|
||||
public static func sample(volume: URL = URL(fileURLWithPath: "/")) -> DiskUsage? {
|
||||
let keys: Set<URLResourceKey> = [.volumeLocalizedNameKey, .volumeTotalCapacityKey,
|
||||
.volumeAvailableCapacityForImportantUsageKey]
|
||||
.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey]
|
||||
guard let values = try? volume.resourceValues(forKeys: keys),
|
||||
let total = values.volumeTotalCapacity else { return nil }
|
||||
// “重要用途可用空间”包含可清除空间,与访达显示一致
|
||||
let available = values.volumeAvailableCapacityForImportantUsage ?? 0
|
||||
// “重要用途可用空间”包含可清除空间,与访达显示一致;减去普通可用空间就是可清除的部分
|
||||
let available = max(0, values.volumeAvailableCapacityForImportantUsage ?? 0)
|
||||
let strict = Int64(values.volumeAvailableCapacity ?? 0)
|
||||
return DiskUsage(volumeName: values.volumeLocalizedName ?? tr("磁盘"),
|
||||
total: UInt64(total), available: UInt64(max(0, available)))
|
||||
total: UInt64(total), available: UInt64(available),
|
||||
purgeable: UInt64(max(0, available - strict)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,10 +75,24 @@ public struct NetworkRate: Sendable, Equatable {
|
||||
public struct DiskUsage: Sendable, Equatable {
|
||||
public var volumeName: String
|
||||
public var total: UInt64
|
||||
/// 可用空间,包含系统可随时清除的缓存,与访达显示一致
|
||||
public var available: UInt64
|
||||
/// 可用空间里可清除的那部分
|
||||
public var purgeable: UInt64
|
||||
|
||||
public init(volumeName: String, total: UInt64, available: UInt64, purgeable: UInt64 = 0) {
|
||||
self.volumeName = volumeName
|
||||
self.total = total
|
||||
self.available = available
|
||||
self.purgeable = min(purgeable, available)
|
||||
}
|
||||
|
||||
public var used: UInt64 { total > available ? total - available : 0 }
|
||||
public var usedFraction: Double { total == 0 ? 0 : Double(used) / Double(total) }
|
||||
/// 不含可清除部分的真正空闲空间
|
||||
public var free: UInt64 { available - purgeable }
|
||||
public var purgeableFraction: Double { total == 0 ? 0 : Double(purgeable) / Double(total) }
|
||||
public var freeFraction: Double { total == 0 ? 0 : Double(free) / Double(total) }
|
||||
}
|
||||
|
||||
public struct BatteryStatus: Sendable, Equatable {
|
||||
|
||||
@@ -1,108 +1,433 @@
|
||||
import Foundation
|
||||
|
||||
public struct PublicAddresses: Sendable, Equatable {
|
||||
/// 在线归属地数据源。都由每台 Mac 直接查询自己的公网 IP,不经过我们的服务器
|
||||
public enum GeoProvider: String, CaseIterable, Sendable, Codable {
|
||||
/// cleanip.io:归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名
|
||||
case cleanIP
|
||||
/// ipapi.is 匿名接口:归属地与 ASN,按客户端 IP 每天 30 次
|
||||
case ipapi
|
||||
/// DB-IP 免费接口:只有国家、省 / 州与城市
|
||||
case dbip
|
||||
/// ipinfo.io 匿名接口:归属地与 ASN,匿名额度很小
|
||||
case ipinfo
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .cleanIP: "CleanIP.io"
|
||||
case .ipapi: "ipapi.is"
|
||||
case .dbip: "DB-IP"
|
||||
case .ipinfo: "ipinfo.io"
|
||||
}
|
||||
}
|
||||
|
||||
/// 同一个 IP 的结果保留多久
|
||||
var cacheLifetime: TimeInterval {
|
||||
self == .cleanIP ? 60 * 60 : 24 * 60 * 60
|
||||
}
|
||||
|
||||
func url(for ip: String) -> URL? {
|
||||
var components: URLComponents?
|
||||
switch self {
|
||||
case .cleanIP:
|
||||
components = URLComponents(string: "https://cleanip.io/cli")
|
||||
components?.queryItems = [URLQueryItem(name: "ip", value: ip), URLQueryItem(name: "json", value: "1")]
|
||||
case .ipapi:
|
||||
components = URLComponents(string: "https://api.ipapi.is/")
|
||||
components?.queryItems = [URLQueryItem(name: "q", value: ip)]
|
||||
case .dbip:
|
||||
components = URLComponents(string: "https://api.db-ip.com/v2/free/\(ip)")
|
||||
case .ipinfo:
|
||||
components = URLComponents(string: "https://ipinfo.io/\(ip)/json")
|
||||
}
|
||||
return components?.url
|
||||
}
|
||||
}
|
||||
|
||||
public struct PublicAddresses: Sendable, Equatable, Codable {
|
||||
public var ipv4: String?
|
||||
public var ipv6: String?
|
||||
/// ISO 3166 两位国家 / 地区代码(大写),例如 “CN”
|
||||
public var countryCode: String?
|
||||
public var city: String?
|
||||
/// 省 / 州,例如 “New York”
|
||||
public var region: String?
|
||||
/// 数据源给的是中文地名时,这里是英文,供英文界面使用
|
||||
public var cityEnglish: String?
|
||||
public var regionEnglish: String?
|
||||
/// 自治系统号,例如 “AS4134”
|
||||
public var asn: String?
|
||||
/// 网络所属组织(英文),例如 “CHINANET-BACKBONE”
|
||||
public var organization: String?
|
||||
public var source: Source = .online
|
||||
/// 以下只有 CleanIP.io 提供
|
||||
public var hostname: String?
|
||||
/// 网络类型:Residential / Business / Hosting / Mobile …
|
||||
public var networkType: String?
|
||||
/// 自治系统的类型:isp / hosting / business / education / government / mobile
|
||||
public var asnType: String?
|
||||
/// 接入方式:Cable/DSL / Cellular / Corporate …
|
||||
public var connectionType: String?
|
||||
/// IP 类型:Residential IP / Datacenter IP …
|
||||
public var ipType: String?
|
||||
/// 原生 IP(登记国、宣告 AS 注册国与定位国一致)还是广播 IP
|
||||
public var isNative: Bool?
|
||||
public var residentialProbability: Int?
|
||||
public var purity: Purity?
|
||||
public var risk: Risk?
|
||||
public var reportURL: URL?
|
||||
public var source: Source = .online(.cleanIP)
|
||||
|
||||
public enum Source: Sendable, Equatable {
|
||||
public struct Purity: Sendable, Equatable, Codable {
|
||||
public var score: Int
|
||||
public var grade: String
|
||||
public var confidence: Int?
|
||||
public var recommendation: String?
|
||||
public init(score: Int, grade: String, confidence: Int? = nil, recommendation: String? = nil) {
|
||||
self.score = score
|
||||
self.grade = grade
|
||||
self.confidence = confidence
|
||||
self.recommendation = recommendation
|
||||
}
|
||||
}
|
||||
|
||||
public struct Risk: Sendable, Equatable, Codable {
|
||||
public var score: Int
|
||||
public var label: String?
|
||||
/// 命中的标记:vpn、proxy、tor、datacenter、hosting、relay、abuser、residentialProxy
|
||||
public var flags: [String]
|
||||
public init(score: Int, label: String? = nil, flags: [String] = []) {
|
||||
self.score = score
|
||||
self.label = label
|
||||
self.flags = flags
|
||||
}
|
||||
}
|
||||
|
||||
public enum Source: Sendable, Equatable, Codable {
|
||||
/// 本机的 MaxMind GeoLite2 数据库
|
||||
case localDatabase
|
||||
/// ipinfo.io 在线查询
|
||||
case online
|
||||
case online(GeoProvider)
|
||||
}
|
||||
|
||||
public init(ipv4: String? = nil, ipv6: String? = nil, countryCode: String? = nil,
|
||||
city: String? = nil, asn: String? = nil, organization: String? = nil) {
|
||||
city: String? = nil, region: String? = nil, asn: String? = nil, organization: String? = nil) {
|
||||
self.ipv4 = ipv4
|
||||
self.ipv6 = ipv6
|
||||
self.countryCode = countryCode
|
||||
self.city = city
|
||||
self.region = region
|
||||
self.asn = asn
|
||||
self.organization = organization
|
||||
}
|
||||
|
||||
/// 把在线查到的归属地信息并进来;国家代码以 Cloudflare 给的为准,没有时才用数据源的
|
||||
public mutating func apply(_ geo: PublicAddressLookup.GeoInfo) {
|
||||
city = geo.city
|
||||
region = geo.region
|
||||
cityEnglish = geo.cityEnglish
|
||||
regionEnglish = geo.regionEnglish
|
||||
asn = geo.asn
|
||||
organization = geo.organization
|
||||
hostname = geo.hostname
|
||||
networkType = geo.networkType
|
||||
asnType = geo.asnType
|
||||
connectionType = geo.connectionType
|
||||
ipType = geo.ipType
|
||||
isNative = geo.isNative
|
||||
residentialProbability = geo.residentialProbability
|
||||
purity = geo.purity
|
||||
risk = geo.risk
|
||||
reportURL = geo.reportURL
|
||||
if countryCode == nil { countryCode = geo.countryCode }
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询公网 IP 与归属地。只在用户打开网络详情时请求,不携带任何本机信息。
|
||||
/// 归属地与 ASN 来自 ipinfo.io;IPv6 地址与失败时的回退使用 Cloudflare trace(直连 IP,不依赖 DNS)与 ipify。
|
||||
/// 公网地址来自 Cloudflare trace(直连 IP,不依赖 DNS,同时给出国家代码),回退 ipify;
|
||||
/// 归属地等信息按用户选择的数据源在线查询,由每台 Mac 自己直接查。这些接口按客户端 IP 限额,
|
||||
/// 所以同一个公网 IP 的结果会记住一段时间,收到 429 后到当天(UTC)结束都不再请求
|
||||
public enum PublicAddressLookup {
|
||||
private static let session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 5
|
||||
configuration.timeoutIntervalForResource = 8
|
||||
configuration.timeoutIntervalForRequest = 8
|
||||
configuration.timeoutIntervalForResource = 12
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.waitsForConnectivity = false
|
||||
configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"]
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
/// `includeGeo` 为假时只查询公网 IP(Cloudflare trace / ipify),不请求任何归属地服务
|
||||
public static func fetch(includeGeo: Bool = true) async -> PublicAddresses {
|
||||
guard includeGeo else {
|
||||
async let v4 = lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org")
|
||||
async let v6 = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org")
|
||||
let (ipv4, ipv6) = await (v4, v6)
|
||||
return PublicAddresses(ipv4: ipv4.ip, ipv6: ipv6.ip, countryCode: ipv4.country ?? ipv6.country)
|
||||
}
|
||||
async let geoResult = geo()
|
||||
async let v6Result = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org")
|
||||
var result = await geoResult ?? PublicAddresses()
|
||||
let v6 = await v6Result
|
||||
private static let geoCache = GeoCache()
|
||||
|
||||
if let ip = result.ipv6, result.ipv4 == nil {
|
||||
// 本机优先走 IPv6 时,归属地接口返回的是 IPv6 地址,IPv4 另外查询
|
||||
result.ipv6 = ip
|
||||
let v4 = await lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org")
|
||||
result.ipv4 = v4.ip
|
||||
} else if result.ipv4 == nil {
|
||||
let v4 = await lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org")
|
||||
result.ipv4 = v4.ip
|
||||
result.countryCode = result.countryCode ?? v4.country
|
||||
}
|
||||
if result.ipv6 == nil { result.ipv6 = v6.ip }
|
||||
if result.countryCode == nil { result.countryCode = v6.country }
|
||||
/// `includeGeo` 为假时只查询公网 IP,不请求任何归属地服务
|
||||
public static func fetch(includeGeo: Bool = true, provider: GeoProvider = .cleanIP) async -> PublicAddresses {
|
||||
async let v4 = lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org")
|
||||
async let v6 = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org")
|
||||
let (ipv4, ipv6) = await (v4, v6)
|
||||
var result = PublicAddresses(ipv4: ipv4.ip, ipv6: ipv6.ip, countryCode: ipv4.country ?? ipv6.country)
|
||||
result.source = .online(provider)
|
||||
guard includeGeo, let ip = result.ipv4 ?? result.ipv6, let geo = await geoCache.lookup(ip, provider: provider) else { return result }
|
||||
result.apply(geo)
|
||||
return result
|
||||
}
|
||||
|
||||
/// 只查一个已知地址的归属地,公网 IP 没变时用它补全,不必重新取地址
|
||||
public static func geo(for ip: String, provider: GeoProvider) async -> GeoInfo? {
|
||||
await geoCache.lookup(ip, provider: provider)
|
||||
}
|
||||
|
||||
// MARK: 归属地
|
||||
|
||||
/// ipinfo.io 的响应:`country` 是两位代码,`org` 形如 “AS151338 POLONETWORK LIMITED”
|
||||
public struct GeoInfo: Sendable, Equatable {
|
||||
public var countryCode: String?
|
||||
public var city: String?
|
||||
public var region: String?
|
||||
public var cityEnglish: String?
|
||||
public var regionEnglish: String?
|
||||
public var asn: String?
|
||||
public var organization: String?
|
||||
public var hostname: String?
|
||||
public var networkType: String?
|
||||
public var asnType: String?
|
||||
public var connectionType: String?
|
||||
public var ipType: String?
|
||||
public var isNative: Bool?
|
||||
public var residentialProbability: Int?
|
||||
public var purity: PublicAddresses.Purity?
|
||||
public var risk: PublicAddresses.Risk?
|
||||
public var reportURL: URL?
|
||||
}
|
||||
|
||||
static func parse(_ data: Data, provider: GeoProvider) -> GeoInfo? {
|
||||
switch provider {
|
||||
case .cleanIP: parseCleanIP(data)
|
||||
case .ipapi: parseGeo(data)
|
||||
case .dbip: parseDBIP(data)
|
||||
case .ipinfo: parseIpinfo(data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: CleanIP.io
|
||||
|
||||
struct CleanIPResponse: Decodable {
|
||||
struct Geo: Decodable {
|
||||
let countryCode: String?
|
||||
let country: String?
|
||||
let countryEn: String?
|
||||
let region: String?
|
||||
let city: String?
|
||||
}
|
||||
struct GeoSource: Decodable {
|
||||
let source: String?
|
||||
let region: String?
|
||||
let city: String?
|
||||
}
|
||||
struct Network: Decodable {
|
||||
let asn: Int?
|
||||
let asnName: String?
|
||||
let asnType: String?
|
||||
let isp: String?
|
||||
let networkType: String?
|
||||
let connectionType: String?
|
||||
let nativeOrBroadcast: String?
|
||||
}
|
||||
struct Purity: Decodable {
|
||||
let score: Int?
|
||||
let grade: String?
|
||||
let confidence: Int?
|
||||
let ipType: String?
|
||||
let isNative: Bool?
|
||||
let residentialProbability: Int?
|
||||
let recommendation: String?
|
||||
}
|
||||
struct Risk: Decodable {
|
||||
let riskScore: Int?
|
||||
let riskLabel: String?
|
||||
let isVpn: Bool?
|
||||
let isProxy: Bool?
|
||||
let isTor: Bool?
|
||||
let isDatacenter: Bool?
|
||||
let isHosting: Bool?
|
||||
let isRelay: Bool?
|
||||
let isAbuser: Bool?
|
||||
let isResidentialProxy: Bool?
|
||||
}
|
||||
let ok: Bool?
|
||||
let ip: String?
|
||||
let hostname: String?
|
||||
let geo: Geo?
|
||||
let geoSources: [GeoSource]?
|
||||
let network: Network?
|
||||
let purity: Purity?
|
||||
let risk: Risk?
|
||||
}
|
||||
|
||||
static func parseCleanIP(_ data: Data) -> GeoInfo? {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
guard let response = try? decoder.decode(CleanIPResponse.self, from: data), response.ok != false else { return nil }
|
||||
var info = GeoInfo()
|
||||
info.countryCode = response.geo?.countryCode.flatMap(validCountryCode)
|
||||
info.city = response.geo?.city.flatMap(nonEmpty)
|
||||
info.region = response.geo?.region.flatMap(nonEmpty)
|
||||
// 英文地名取 ipinfo 那一路的
|
||||
if let english = response.geoSources?.first(where: { $0.source == "ipinfo" }) {
|
||||
info.cityEnglish = english.city.flatMap(nonEmpty)
|
||||
info.regionEnglish = english.region.flatMap(nonEmpty)
|
||||
}
|
||||
if let asn = response.network?.asn, asn > 0 { info.asn = "AS\(asn)" }
|
||||
info.organization = response.network?.isp.flatMap(nonEmpty) ?? response.network?.asnName.flatMap(nonEmpty)
|
||||
if let hostname = response.hostname.flatMap(nonEmpty), hostname != response.ip { info.hostname = hostname }
|
||||
info.networkType = response.network?.networkType.flatMap(nonEmpty)
|
||||
info.asnType = response.network?.asnType.flatMap(nonEmpty)?.lowercased()
|
||||
info.connectionType = response.network?.connectionType.flatMap(nonEmpty)
|
||||
info.ipType = response.purity?.ipType.flatMap(nonEmpty)
|
||||
info.isNative = response.purity?.isNative ?? response.network?.nativeOrBroadcast.map { $0.uppercased() == "NATIVE" }
|
||||
info.residentialProbability = response.purity?.residentialProbability
|
||||
if let score = response.purity?.score, let grade = response.purity?.grade.flatMap(nonEmpty) {
|
||||
info.purity = PublicAddresses.Purity(score: min(100, max(0, score)), grade: grade,
|
||||
confidence: response.purity?.confidence,
|
||||
recommendation: response.purity?.recommendation.flatMap(nonEmpty))
|
||||
}
|
||||
if let risk = response.risk, let score = risk.riskScore {
|
||||
var flags: [String] = []
|
||||
if risk.isVpn == true { flags.append("vpn") }
|
||||
if risk.isProxy == true { flags.append("proxy") }
|
||||
if risk.isResidentialProxy == true { flags.append("residentialProxy") }
|
||||
if risk.isTor == true { flags.append("tor") }
|
||||
if risk.isRelay == true { flags.append("relay") }
|
||||
if risk.isDatacenter == true { flags.append("datacenter") }
|
||||
if risk.isHosting == true { flags.append("hosting") }
|
||||
if risk.isAbuser == true { flags.append("abuser") }
|
||||
info.risk = PublicAddresses.Risk(score: min(100, max(0, score)), label: risk.riskLabel.flatMap(nonEmpty), flags: flags)
|
||||
}
|
||||
if let ip = response.ip, isAddress(ip) { info.reportURL = URL(string: "https://cleanip.io/\(ip)") }
|
||||
return info
|
||||
}
|
||||
|
||||
// MARK: ipapi.is
|
||||
|
||||
/// ipapi.is 匿名接口的响应:`asn` 形如 “AS7018 AT&T Enterprises, LLC”,`country` 是英文全名,`company` 是地址持有方
|
||||
struct GeoResponse: Decodable {
|
||||
let ip: String?
|
||||
let company: String?
|
||||
let asn: String?
|
||||
let city: String?
|
||||
let region: String?
|
||||
let country: String?
|
||||
}
|
||||
|
||||
static func parseGeo(_ data: Data) -> GeoInfo? {
|
||||
guard let response = try? JSONDecoder().decode(GeoResponse.self, from: data) else { return nil }
|
||||
var info = GeoInfo()
|
||||
info.countryCode = response.country.flatMap(nonEmpty).flatMap(countryCode(fromName:))
|
||||
info.city = response.city.flatMap(nonEmpty)
|
||||
info.region = response.region.flatMap(nonEmpty)
|
||||
let (asn, asnOrganization) = splitASN(response.asn)
|
||||
info.asn = asn
|
||||
// 地址持有方通常比自治系统的注册名更能说明“是谁的网络”
|
||||
info.organization = response.company.flatMap(nonEmpty) ?? asnOrganization
|
||||
return info
|
||||
}
|
||||
|
||||
// MARK: DB-IP
|
||||
|
||||
struct DBIPResponse: Decodable {
|
||||
let countryCode: String?
|
||||
let stateProv: String?
|
||||
let city: String?
|
||||
}
|
||||
|
||||
static func parseDBIP(_ data: Data) -> GeoInfo? {
|
||||
guard let response = try? JSONDecoder().decode(DBIPResponse.self, from: data) else { return nil }
|
||||
var info = GeoInfo()
|
||||
info.countryCode = response.countryCode.flatMap(validCountryCode)
|
||||
info.region = response.stateProv.flatMap(nonEmpty)
|
||||
info.city = response.city.flatMap(nonEmpty)
|
||||
return info
|
||||
}
|
||||
|
||||
// MARK: ipinfo.io
|
||||
|
||||
struct IpinfoResponse: Decodable {
|
||||
let country: String?
|
||||
let region: String?
|
||||
let city: String?
|
||||
let org: String?
|
||||
}
|
||||
|
||||
private static func geo() async -> PublicAddresses? {
|
||||
guard let data = await get("https://ipinfo.io/json") else { return nil }
|
||||
return parseGeo(data)
|
||||
static func parseIpinfo(_ data: Data) -> GeoInfo? {
|
||||
guard let response = try? JSONDecoder().decode(IpinfoResponse.self, from: data) else { return nil }
|
||||
var info = GeoInfo()
|
||||
info.countryCode = response.country.flatMap(validCountryCode)
|
||||
info.region = response.region.flatMap(nonEmpty)
|
||||
info.city = response.city.flatMap(nonEmpty)
|
||||
let (asn, organization) = splitASN(response.org)
|
||||
info.asn = asn
|
||||
info.organization = organization
|
||||
return info
|
||||
}
|
||||
|
||||
static func parseGeo(_ data: Data) -> PublicAddresses? {
|
||||
guard let response = try? JSONDecoder().decode(GeoResponse.self, from: data),
|
||||
let ip = response.ip, isAddress(ip) else { return nil }
|
||||
var result = PublicAddresses()
|
||||
if ip.contains(":") { result.ipv6 = ip } else { result.ipv4 = ip }
|
||||
result.countryCode = response.country.flatMap(validCountryCode)
|
||||
result.city = response.city.flatMap(nonEmpty)
|
||||
if let org = response.org.flatMap(nonEmpty) {
|
||||
let parts = org.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
if let first = parts.first, first.hasPrefix("AS"), Int(first.dropFirst(2)) != nil {
|
||||
result.asn = first
|
||||
result.organization = parts.count > 1 ? parts[1] : nil
|
||||
} else {
|
||||
result.organization = org
|
||||
/// “AS7018 AT&T Enterprises, LLC” → (“AS7018”, “AT&T Enterprises, LLC”);没有 AS 前缀时整段当组织名
|
||||
static func splitASN(_ text: String?) -> (String?, String?) {
|
||||
guard let text = text.flatMap(nonEmpty) else { return (nil, nil) }
|
||||
let parts = text.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
if let first = parts.first, first.hasPrefix("AS"), Int(first.dropFirst(2)) != nil {
|
||||
return (first, parts.count > 1 ? parts[1] : nil)
|
||||
}
|
||||
return (nil, text)
|
||||
}
|
||||
|
||||
/// 英文国家名转两位代码:先查几个系统名称对不上的别名,再按系统的英文区域名反查
|
||||
static func countryCode(fromName name: String) -> String? {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let alias = countryAliases[trimmed.lowercased()] { return alias }
|
||||
let english = Locale(identifier: "en_US")
|
||||
for region in Locale.Region.isoRegions where region.identifier.count == 2 {
|
||||
if english.localizedString(forRegionCode: region.identifier)?.caseInsensitiveCompare(trimmed) == .orderedSame {
|
||||
return validCountryCode(region.identifier)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return nil
|
||||
}
|
||||
|
||||
private static let countryAliases: [String: String] = [
|
||||
"china": "CN", "china mainland": "CN", "united states": "US", "usa": "US", "united kingdom": "GB", "uk": "GB",
|
||||
"russia": "RU", "south korea": "KR", "north korea": "KP", "hong kong": "HK", "macau": "MO", "macao": "MO",
|
||||
"taiwan": "TW", "vietnam": "VN", "viet nam": "VN", "turkey": "TR", "türkiye": "TR", "czech republic": "CZ",
|
||||
"czechia": "CZ", "iran": "IR", "syria": "SY", "laos": "LA", "brunei": "BN", "bolivia": "BO", "venezuela": "VE",
|
||||
"tanzania": "TZ", "moldova": "MD", "palestine": "PS", "the netherlands": "NL", "netherlands": "NL",
|
||||
"ivory coast": "CI", "cape verde": "CV", "swaziland": "SZ", "myanmar": "MM", "burma": "MM", "congo": "CG",
|
||||
"dr congo": "CD", "democratic republic of the congo": "CD", "micronesia": "FM", "vatican": "VA", "reunion": "RE",
|
||||
"curacao": "CW", "saint martin": "MF", "sint maarten": "SX",
|
||||
]
|
||||
|
||||
/// 按数据源与 IP 缓存;收到 429 后该数据源到当天(UTC)结束都不再请求
|
||||
actor GeoCache {
|
||||
private var entries: [String: (info: GeoInfo?, date: Date)] = [:]
|
||||
private var blockedUntil: [GeoProvider: Date] = [:]
|
||||
|
||||
func lookup(_ ip: String, provider: GeoProvider) async -> GeoInfo? {
|
||||
let key = "\(provider.rawValue)|\(ip)"
|
||||
if let entry = entries[key], Date().timeIntervalSince(entry.date) < provider.cacheLifetime { return entry.info }
|
||||
if let until = blockedUntil[provider], Date() < until { return nil }
|
||||
guard let url = provider.url(for: ip) else { return nil }
|
||||
let (data, status) = await PublicAddressLookup.getWithStatus(url)
|
||||
if status == 429 {
|
||||
blockedUntil[provider] = Self.nextUTCMidnight()
|
||||
return nil
|
||||
}
|
||||
guard status == 200, let data else { return nil }
|
||||
let info = PublicAddressLookup.parse(data, provider: provider)
|
||||
entries[key] = (info, Date())
|
||||
return info
|
||||
}
|
||||
|
||||
private static func nextUTCMidnight() -> Date {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
let start = calendar.startOfDay(for: Date())
|
||||
return calendar.date(byAdding: .day, value: 1, to: start) ?? Date().addingTimeInterval(12 * 3600)
|
||||
}
|
||||
}
|
||||
|
||||
/// 只接受两位字母,之后会用作国旗文件名
|
||||
@@ -132,10 +457,14 @@ public enum PublicAddressLookup {
|
||||
}
|
||||
|
||||
private static func get(_ string: String) async -> Data? {
|
||||
guard let url = URL(string: string),
|
||||
let (data, response) = try? await session.data(from: url),
|
||||
(response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
||||
return data
|
||||
guard let url = URL(string: string) else { return nil }
|
||||
let (data, status) = await getWithStatus(url)
|
||||
return status == 200 ? data : nil
|
||||
}
|
||||
|
||||
static func getWithStatus(_ url: URL) async -> (Data?, Int) {
|
||||
guard let (data, response) = try? await session.data(from: url) else { return (nil, 0) }
|
||||
return (data, (response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
|
||||
static func parseTrace(_ body: String) -> [String: String] {
|
||||
|
||||
@@ -63,6 +63,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
model.alerts.start()
|
||||
hotKeys.onAction = { [weak self] action in self?.perform(action) }
|
||||
applyHotKeys()
|
||||
model.sync.presentationAnchor = { [weak self] in self?.mainWindow.nsWindow }
|
||||
model.sync.start()
|
||||
|
||||
// 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口
|
||||
let arguments = CommandLine.arguments
|
||||
@@ -208,13 +210,13 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
_ = model.settings.probeTarget
|
||||
_ = model.settings.probeSeconds
|
||||
_ = model.settings.publicIPLookup
|
||||
_ = model.settings.geoSource
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.model.network.restartProbing()
|
||||
if !self.model.settings.publicIPLookup {
|
||||
self.model.network.clearPublicAddresses()
|
||||
} else if self.model.isNetworkDetailVisible {
|
||||
self.model.network.clearPublicAddresses()
|
||||
if self.model.settings.publicIPLookup, self.model.isNetworkDetailVisible {
|
||||
self.model.network.lookUpPublicAddresses()
|
||||
}
|
||||
self.observeProbeSettings()
|
||||
@@ -276,6 +278,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
self.model.network.setPaused(false)
|
||||
self.model.sync.pullSoon()
|
||||
Task {
|
||||
await self.model.hub.setPaused(false)
|
||||
await self.model.fans.reapply()
|
||||
@@ -289,6 +292,9 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
public enum OpenStatsApplication {
|
||||
@MainActor
|
||||
public static func main() {
|
||||
// 滚动条一律用浮层样式:接了鼠标时系统默认是常驻滚动条,SwiftUI 的 ScrollView 会给它预留一条宽度,
|
||||
// 弹窗里的内容就会整体偏左。这是本应用自己的偏好域,只影响 OpenStats
|
||||
UserDefaults.standard.set("WhenScrolling", forKey: "AppleShowScrollBars")
|
||||
let app = NSApplication.shared
|
||||
// 界面语言在创建任何界面之前确定,切换后重启生效
|
||||
let stored = UserDefaults.standard.string(forKey: AppSettings.languageKey).flatMap(AppLanguage.init(rawValue:)) ?? .system
|
||||
|
||||
@@ -14,6 +14,8 @@ final class MainWindowController: NSObject, NSWindowDelegate {
|
||||
}
|
||||
|
||||
var isVisible: Bool { window?.isVisible == true }
|
||||
/// 登录窗口等系统面板挂靠用
|
||||
var nsWindow: NSWindow? { window }
|
||||
|
||||
func close() {
|
||||
window?.performClose(nil)
|
||||
@@ -94,4 +96,5 @@ enum WindowChrome {
|
||||
window.toolbar = toolbar
|
||||
window.toolbarStyle = .unifiedCompact
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,6 +10,9 @@ struct MenuBarReading {
|
||||
var gpuHistory: [Double] = []
|
||||
var memory: Double?
|
||||
var memoryHistory: [Double] = []
|
||||
/// 启动磁盘已用占比;用量几乎不变,柱状 / 折线风格画成一条平线
|
||||
var disk: Double?
|
||||
var diskHistory: [Double] = []
|
||||
var upload: Double?
|
||||
var download: Double?
|
||||
var temperature: Double?
|
||||
@@ -25,6 +28,8 @@ struct MenuBarReading {
|
||||
gpuHistory = store.gpuHistory.elements
|
||||
memory = store.memory?.usedFraction
|
||||
memoryHistory = store.memoryHistory.elements
|
||||
disk = store.disk?.usedFraction
|
||||
diskHistory = disk.map { Array(repeating: $0, count: 30) } ?? []
|
||||
upload = store.network?.uploadBytesPerSecond
|
||||
download = store.network?.downloadBytesPerSecond
|
||||
temperature = store.sensors?.temperature(.cpu)?.maximum
|
||||
@@ -34,15 +39,21 @@ struct MenuBarReading {
|
||||
|
||||
init() {}
|
||||
|
||||
/// 设置页预览用的示例读数
|
||||
/// 设置页预览用的示例读数:历史给 30 个点,柱状风格取最后 10 个,折线风格用全部
|
||||
static let sample: MenuBarReading = {
|
||||
var reading = MenuBarReading()
|
||||
reading.cpu = 0.34
|
||||
reading.cpuHistory = [0.18, 0.22, 0.41, 0.36, 0.28, 0.52, 0.47, 0.31, 0.29, 0.34]
|
||||
reading.cpuHistory = [0.12, 0.15, 0.11, 0.20, 0.33, 0.27, 0.19, 0.24, 0.45, 0.38,
|
||||
0.30, 0.26, 0.22, 0.35, 0.58, 0.49, 0.41, 0.37, 0.30, 0.25,
|
||||
0.18, 0.22, 0.41, 0.36, 0.28, 0.52, 0.47, 0.31, 0.29, 0.34]
|
||||
reading.gpu = 0.22
|
||||
reading.gpuHistory = [0.05, 0.12, 0.30, 0.18, 0.10, 0.26, 0.40, 0.21, 0.15, 0.22]
|
||||
reading.gpuHistory = [0.02, 0.04, 0.03, 0.08, 0.15, 0.12, 0.09, 0.11, 0.24, 0.20,
|
||||
0.14, 0.10, 0.08, 0.17, 0.35, 0.28, 0.22, 0.19, 0.14, 0.10,
|
||||
0.05, 0.12, 0.30, 0.18, 0.10, 0.26, 0.40, 0.21, 0.15, 0.22]
|
||||
reading.memory = 0.68
|
||||
reading.memoryHistory = Array(repeating: 0.68, count: 10)
|
||||
reading.memoryHistory = Array(repeating: 0.68, count: 30)
|
||||
reading.disk = 0.58
|
||||
reading.diskHistory = Array(repeating: 0.58, count: 30)
|
||||
reading.upload = 12 * 1024
|
||||
reading.download = 1.4 * 1024 * 1024
|
||||
reading.temperature = 52
|
||||
@@ -55,6 +66,7 @@ struct MenuBarReading {
|
||||
case .cpu: (cpu, cpuHistory)
|
||||
case .gpu: (gpu, gpuHistory)
|
||||
case .memory: (memory, memoryHistory)
|
||||
case .disk: (disk, diskHistory)
|
||||
default: (nil, [])
|
||||
}
|
||||
}
|
||||
@@ -66,6 +78,7 @@ struct MenuBarReading {
|
||||
case .cpu: cpu.map { "CPU \(Format.percent($0))" }
|
||||
case .gpu: gpu.map { "GPU \(Format.percent($0))" }
|
||||
case .memory: memory.map { tr("内存 \(Format.percent($0))") }
|
||||
case .disk: disk.map { tr("磁盘已用 \(Format.percent($0))") }
|
||||
case .network:
|
||||
upload.flatMap { up in download.map { tr("上传 \(Format.menuBarRate(up)) · 下载 \(Format.menuBarRate($0))") } }
|
||||
case .temperature: temperature.map { tr("CPU 温度 \(Format.temperature($0, fahrenheit: fahrenheit))") }
|
||||
@@ -96,6 +109,8 @@ enum MenuBarRenderer {
|
||||
static let historyCount = 10
|
||||
static let historyBarWidth: CGFloat = 2
|
||||
static let historyBarGap: CGFloat = 1
|
||||
static let lineCount = 30
|
||||
static let lineWidth: CGFloat = 30
|
||||
static let chartHeight: CGFloat = 13
|
||||
static let gaugeDiameter: CGFloat = 13
|
||||
static let ringWidth: CGFloat = 2
|
||||
@@ -172,7 +187,7 @@ enum MenuBarRenderer {
|
||||
private static func segment(for item: MenuBarItem, reading: MenuBarReading, style: MenuBarStyle,
|
||||
networkStyle: NetworkMenuStyle, colorizeHighLoad: Bool, fahrenheit: Bool) -> Segment {
|
||||
switch item {
|
||||
case .cpu, .gpu, .memory:
|
||||
case .cpu, .gpu, .memory, .disk:
|
||||
let (value, history) = reading.percent(item)
|
||||
return percentSegment(item: item, value: value, history: history, style: style, colorizeHighLoad: colorizeHighLoad)
|
||||
case .network:
|
||||
@@ -208,6 +223,8 @@ enum MenuBarRenderer {
|
||||
return combine([pie(fraction: fraction, alert: alert), stacked])
|
||||
case .history:
|
||||
return combine([historyBars(history, alert: alert), stacked])
|
||||
case .line:
|
||||
return combine([historyLine(history, alert: alert), stacked])
|
||||
case .meter:
|
||||
return combine([meter(fraction: fraction, alert: alert), stacked])
|
||||
case .dot:
|
||||
@@ -335,6 +352,37 @@ enum MenuBarRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近 30 次采样的折线,最新的点在右边,线下填淡色;数据不足时从右往左只画已有的部分
|
||||
private static func historyLine(_ history: [Double], alert: Bool) -> Segment {
|
||||
let recent = Array(history.suffix(Metrics.lineCount))
|
||||
return Segment(width: Metrics.lineWidth) { rect in
|
||||
let bottom = rect.midY - Metrics.chartHeight / 2
|
||||
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setFill()
|
||||
NSRect(x: rect.minX, y: bottom, width: Metrics.lineWidth, height: 1).fill()
|
||||
guard recent.count > 1 else { return }
|
||||
|
||||
let step = Metrics.lineWidth / CGFloat(Metrics.lineCount - 1)
|
||||
let startX = rect.maxX - CGFloat(recent.count - 1) * step
|
||||
let line = NSBezierPath()
|
||||
for (index, value) in recent.enumerated() {
|
||||
let point = NSPoint(x: startX + CGFloat(index) * step,
|
||||
y: bottom + 1 + (Metrics.chartHeight - 2) * CGFloat(min(1, max(0, value))))
|
||||
index == 0 ? line.move(to: point) : line.line(to: point)
|
||||
}
|
||||
let area = line.copy() as! NSBezierPath
|
||||
area.line(to: NSPoint(x: rect.maxX, y: bottom))
|
||||
area.line(to: NSPoint(x: startX, y: bottom))
|
||||
area.close()
|
||||
foreground(alert).withAlphaComponent(Metrics.trackAlpha).setFill()
|
||||
area.fill()
|
||||
|
||||
line.lineWidth = 1
|
||||
line.lineJoinStyle = .round
|
||||
foreground(alert).setStroke()
|
||||
line.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
/// 竖向电量条
|
||||
private static func meter(fraction: Double, alert: Bool) -> Segment {
|
||||
Segment(width: Metrics.meterSize.width) { rect in
|
||||
|
||||
@@ -49,10 +49,20 @@ struct LineHistoryChart: View {
|
||||
var maxValue: Double = 1
|
||||
var color: Color = DS.Palette.primary
|
||||
var filled = true
|
||||
/// 在 25% / 50% / 75% 画三条淡淡的参考线,图表较高时(主窗口)用来读数
|
||||
var grid = false
|
||||
var height: CGFloat = DS.Size.chartHeight
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
if grid {
|
||||
let usable = size.height - DS.Size.chartLine * 2
|
||||
for fraction in [0.25, 0.5, 0.75] {
|
||||
let y = (DS.Size.chartLine + usable * CGFloat(1 - fraction)).rounded()
|
||||
context.fill(Path(CGRect(x: 0, y: y, width: size.width, height: DS.Size.stroke)),
|
||||
with: .color(DS.Palette.border))
|
||||
}
|
||||
}
|
||||
let line = LineHistoryChart.path(values: values, capacity: capacity, maxValue: maxValue, in: size)
|
||||
guard let line else { return }
|
||||
if filled {
|
||||
@@ -87,6 +97,67 @@ struct LineHistoryChart: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按时间定位的短期走势线:最新的采样固定在右边缘,往左按真实时间间隔铺开,
|
||||
/// 采样间隔变化(后台 2 秒、打开详情后 1 秒)也不会把曲线拉伸;相邻采样间隔超过 10 秒(睡眠)线条断开
|
||||
struct TimedLineChart: View {
|
||||
let points: [TimedValue]
|
||||
let duration: TimeInterval
|
||||
let end: Date
|
||||
var color: Color = DS.Palette.primary
|
||||
/// 在 25% / 50% / 75% 画三条淡淡的参考线
|
||||
var grid = false
|
||||
var height: CGFloat = DS.Size.chartHeight
|
||||
|
||||
static let gapLimit: TimeInterval = 10
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
if grid {
|
||||
let usable = size.height - DS.Size.chartLine * 2
|
||||
for fraction in [0.25, 0.5, 0.75] {
|
||||
let y = (DS.Size.chartLine + usable * CGFloat(1 - fraction)).rounded()
|
||||
context.fill(Path(CGRect(x: 0, y: y, width: size.width, height: DS.Size.stroke)),
|
||||
with: .color(DS.Palette.border))
|
||||
}
|
||||
}
|
||||
for segment in Self.segments(points, duration: duration, end: end, in: size) {
|
||||
guard let first = segment.first, let last = segment.last else { continue }
|
||||
var line = Path()
|
||||
line.addLines(segment.count == 1 ? [first, CGPoint(x: first.x + DS.Size.stroke, y: first.y)] : segment)
|
||||
var area = line
|
||||
area.addLine(to: CGPoint(x: last.x, y: size.height))
|
||||
area.addLine(to: CGPoint(x: first.x, y: size.height))
|
||||
area.closeSubpath()
|
||||
context.fill(area, with: .color(color.opacity(0.14)))
|
||||
context.stroke(line, with: .color(color),
|
||||
style: StrokeStyle(lineWidth: DS.Size.chartLine, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// 落在 [end − duration, end] 内的采样换算成坐标(0 在下、1 在上),按间隔断开成若干段
|
||||
static func segments(_ points: [TimedValue], duration: TimeInterval, end: Date, in size: CGSize) -> [[CGPoint]] {
|
||||
let start = end.addingTimeInterval(-duration)
|
||||
let inset = DS.Size.chartLine
|
||||
let usable = size.height - inset * 2
|
||||
var segments: [[CGPoint]] = []
|
||||
var previous: Date?
|
||||
for point in points where point.date >= start && point.date <= end {
|
||||
let location = CGPoint(x: size.width * CGFloat(point.date.timeIntervalSince(start) / max(duration, 1)),
|
||||
y: inset + usable * CGFloat(1 - min(1, max(0, point.value))))
|
||||
if let previous, point.date.timeIntervalSince(previous) <= gapLimit, !segments.isEmpty {
|
||||
segments[segments.count - 1].append(location)
|
||||
} else {
|
||||
segments.append([location])
|
||||
}
|
||||
previous = point.date
|
||||
}
|
||||
return segments
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传 / 下载双折线,按两者峰值归一化
|
||||
struct DualLineChart: View {
|
||||
let upload: [Double]
|
||||
@@ -214,7 +285,8 @@ struct CoreClusterBars: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 核心热力图:每行一个核心(按类型分组,高性能档在上),每列一次采样,最新在右。
|
||||
/// 核心热力图:每行一个核心(按类型分组,高性能档在上),每列一次采样,最新在右;
|
||||
/// 最右侧单独一根粗条画此刻的占用,一眼能看出现在哪些核心在忙。
|
||||
/// 颜色越深越忙,超过 85% 用警告色
|
||||
struct CoreHeatmap: View {
|
||||
let topology: CPUTopology
|
||||
@@ -224,6 +296,8 @@ struct CoreHeatmap: View {
|
||||
|
||||
private static let gap = DS.Size.stroke
|
||||
private static let groupGap = DS.Space.s1
|
||||
private static let nowWidth = DS.Space.s2
|
||||
private static let nowGap = DS.Space.s1
|
||||
|
||||
var body: some View {
|
||||
let clusters = topology.clusters.sorted { $0.id < $1.id }
|
||||
@@ -233,7 +307,8 @@ struct CoreHeatmap: View {
|
||||
HStack(alignment: .top, spacing: DS.Space.s2) {
|
||||
VStack(alignment: .leading, spacing: Self.groupGap) {
|
||||
ForEach(clusters) { cluster in
|
||||
Text(verbatim: cluster.name)
|
||||
// 类型名后面带上核心数,与“各核心占用”里的分组标题一致
|
||||
Text(verbatim: "\(cluster.name) · \(cluster.coreIndices.count)")
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
@@ -244,8 +319,10 @@ struct CoreHeatmap: View {
|
||||
|
||||
Canvas { context, size in
|
||||
let recent = Array(history.suffix(columns))
|
||||
let cellWidth = (size.width - Self.gap * CGFloat(columns - 1)) / CGFloat(columns)
|
||||
let gridWidth = size.width - Self.nowWidth - Self.nowGap
|
||||
let cellWidth = (gridWidth - Self.gap * CGFloat(columns - 1)) / CGFloat(columns)
|
||||
let offset = columns - recent.count
|
||||
let latest = recent.last
|
||||
var y: CGFloat = 0
|
||||
for cluster in clusters {
|
||||
for core in cluster.coreIndices.reversed() {
|
||||
@@ -254,6 +331,8 @@ struct CoreHeatmap: View {
|
||||
let value = column >= offset ? recent[column - offset][safe: core] : nil
|
||||
context.fill(Path(rect), with: .color(Self.color(value)))
|
||||
}
|
||||
let now = CGRect(x: size.width - Self.nowWidth, y: y, width: Self.nowWidth, height: rowHeight)
|
||||
context.fill(Path(roundedRect: now, cornerRadius: DS.Radius.sm / 2), with: .color(Self.color(latest?[safe: core])))
|
||||
y += rowHeight + Self.gap
|
||||
}
|
||||
y += Self.groupGap
|
||||
@@ -423,3 +502,87 @@ struct ProbeGrid: View {
|
||||
.accessibilityLabel(tr("连接探测历史"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 评分色带
|
||||
|
||||
/// 0–100 分的六段色带:F、D、C、B、A、A+ 各占自己的分数区间,段内写等级,下方标刻度,
|
||||
/// 得分处有一个带当前等级的小标记
|
||||
struct ScoreBand: View {
|
||||
let score: Int
|
||||
var height: CGFloat = DS.Size.segmentHeight
|
||||
|
||||
private static let markerHeight: CGFloat = DS.Size.iconStandalone + DS.Space.s1
|
||||
private static let tickHeight: CGFloat = DS.Space.s3
|
||||
|
||||
var body: some View {
|
||||
let bands = DS.Grade.bands
|
||||
let clamped = min(100, max(0, score))
|
||||
GeometryReader { proxy in
|
||||
let width = proxy.size.width
|
||||
ZStack(alignment: .topLeading) {
|
||||
VStack(spacing: DS.Space.s1 / 2) {
|
||||
HStack(spacing: 1) {
|
||||
ForEach(Array(bands.enumerated()), id: \.offset) { index, band in
|
||||
let lower = index == 0 ? 0 : bands[index - 1].upper
|
||||
let segmentWidth = width * CGFloat(band.upper - lower) / 100
|
||||
ZStack {
|
||||
Rectangle().fill(band.color)
|
||||
if segmentWidth >= DS.Space.s6 {
|
||||
Text(verbatim: band.grade)
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.onPrimary)
|
||||
}
|
||||
}
|
||||
.frame(width: max(0, segmentWidth - (index == bands.count - 1 ? 0 : 1)))
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||||
ZStack(alignment: .topLeading) {
|
||||
// A 与 A+ 的分界太密,刻度上不标 95;弹窗那么窄时 85 也挤不下
|
||||
ForEach([0] + bands.map(\.upper).filter { $0 != 95 && ($0 != 85 || width >= DS.Size.panelWidth / 2) }, id: \.self) { tick in
|
||||
Text(verbatim: "\(tick)")
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
.fixedSize()
|
||||
.alignmentGuide(.leading) { dimensions in
|
||||
// 0 靠左、100 靠右,其余居中对齐刻度
|
||||
tick == 0 ? 0 : tick == 100 ? dimensions.width - width : dimensions.width / 2 - width * CGFloat(tick) / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: Self.tickHeight, alignment: .topLeading)
|
||||
}
|
||||
.padding(.top, Self.markerHeight)
|
||||
|
||||
// 得分标记:气泡里写等级,尖角指向色带
|
||||
let color = DS.Grade.color(for: clamped)
|
||||
VStack(spacing: 0) {
|
||||
Text(verbatim: DS.Grade.bands.first { clamped < $0.upper }?.grade ?? "A+")
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.onPrimary)
|
||||
.padding(.horizontal, DS.Space.s1)
|
||||
.frame(height: DS.Size.iconInline)
|
||||
.background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||||
Triangle().fill(color).frame(width: DS.Space.s2, height: DS.Space.s1)
|
||||
}
|
||||
.fixedSize()
|
||||
.alignmentGuide(.leading) { dimensions in dimensions.width / 2 - width * CGFloat(clamped) / 100 }
|
||||
}
|
||||
}
|
||||
.frame(height: height + Self.markerHeight + Self.tickHeight + DS.Space.s1 / 2)
|
||||
.accessibilityLabel(tr("纯净度 \(clamped) 分"))
|
||||
}
|
||||
}
|
||||
|
||||
private struct Triangle: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -84,11 +84,15 @@ struct ProgressTrack: View {
|
||||
let fraction: Double
|
||||
var color: Color = DS.Palette.primary
|
||||
var height: CGFloat = DS.Size.barHeight
|
||||
/// 占比类(容量、电量)画灰色底槽;排行类(按应用汇总)只画彩色条,底槽拉满一整行反而显得杂乱
|
||||
var showsTrack = true
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.sm).fill(DS.Palette.track)
|
||||
if showsTrack {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.sm).fill(DS.Palette.track)
|
||||
}
|
||||
RoundedRectangle(cornerRadius: DS.Radius.sm)
|
||||
.fill(color)
|
||||
.frame(width: proxy.size.width * min(1, max(0, fraction)))
|
||||
@@ -98,6 +102,183 @@ struct ProgressTrack: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分段条:把一个整体按比例切成几段,每段填自己的颜色,段内写“名称 百分比”,
|
||||
/// 第一段靠左、最后一段靠右、中间居中;段太窄放不下文字就只画色块,数值由旁边的图例补充
|
||||
struct SegmentedBar: View {
|
||||
struct Segment: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let fraction: Double
|
||||
let color: Color
|
||||
var labelColor: Color = DS.Palette.onPrimary
|
||||
}
|
||||
|
||||
let segments: [Segment]
|
||||
var height: CGFloat = DS.Size.controlHeight
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let total = max(segments.reduce(0) { $0 + max(0, $1.fraction) }, 0.000_001)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(segments.enumerated()), id: \.element.id) { index, segment in
|
||||
let width = proxy.size.width * max(0, segment.fraction) / total
|
||||
let percent = Format.percent(segment.fraction / total)
|
||||
ZStack(alignment: index == 0 ? .leading : index == segments.count - 1 ? .trailing : .center) {
|
||||
Rectangle().fill(segment.color)
|
||||
// 放得下就写“名称 百分比”,只放得下数字就只写百分比,再窄就只画色块
|
||||
ViewThatFits(in: .horizontal) {
|
||||
Text(verbatim: "\(segment.label) \(percent)")
|
||||
Text(verbatim: percent)
|
||||
Color.clear.frame(width: 0, height: 0)
|
||||
}
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(segment.labelColor)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, DS.Space.s2)
|
||||
}
|
||||
.frame(width: width)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||||
}
|
||||
}
|
||||
|
||||
/// 带图标的徽章(cleanip.io 样式):浅色底、细边框、同色系文字,用于“原生 IP”“住宅 IP”“ISP”这类结论性标签
|
||||
struct TagBadge: View {
|
||||
enum Tone { case success, warning, neutral, primary }
|
||||
|
||||
var icon: String?
|
||||
let text: String
|
||||
var tone: Tone = .success
|
||||
/// 放在区块标题行里的小号:更窄的内边距
|
||||
var compact = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.s1) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
|
||||
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
|
||||
}
|
||||
Text(text).dsFont(.xs, weight: .semibold)
|
||||
}
|
||||
.foregroundStyle(foreground)
|
||||
.padding(.horizontal, compact ? DS.Space.s1 : DS.Space.s3)
|
||||
.padding(.vertical, compact ? DS.Space.s1 / 2 : DS.Space.s1)
|
||||
.background(fill, in: RoundedRectangle(cornerRadius: compact ? DS.Radius.sm : DS.Radius.md))
|
||||
.overlay(RoundedRectangle(cornerRadius: compact ? DS.Radius.sm : DS.Radius.md).strokeBorder(border, lineWidth: DS.Size.stroke))
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
private var foreground: Color {
|
||||
switch tone {
|
||||
case .success: DS.Badge.successText
|
||||
case .warning: DS.Badge.warningText
|
||||
case .neutral: DS.Badge.neutralText
|
||||
case .primary: DS.Palette.primary
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: Color {
|
||||
switch tone {
|
||||
case .success: DS.Badge.successFill
|
||||
case .warning: DS.Badge.warningFill
|
||||
case .neutral: DS.Badge.neutralFill
|
||||
case .primary: DS.Palette.primary.opacity(0.10)
|
||||
}
|
||||
}
|
||||
|
||||
private var border: Color {
|
||||
switch tone {
|
||||
case .success: DS.Badge.successBorder
|
||||
case .warning: DS.Badge.warningBorder
|
||||
case .neutral: DS.Badge.neutralBorder
|
||||
case .primary: DS.Palette.primary.opacity(0.30)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// cleanip.io 的字标:黑色部分按文字色着色(随深浅色变化),圆环与 “.io” 保持品牌绿。
|
||||
/// 画布已裁到字形边缘,指定的高度就是字形实际高度,不留上下空白
|
||||
struct CleanIPLogo: View {
|
||||
var height: CGFloat = DS.Size.iconInline
|
||||
|
||||
var body: some View {
|
||||
// 字标画布已裁到字形边缘:600 × 98
|
||||
let width = height * 600 / 98
|
||||
ZStack {
|
||||
if let ink = LogoCache.shared.image(named: "cleanip-ink", template: true) {
|
||||
Image(nsImage: ink).resizable().foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
if let green = LogoCache.shared.image(named: "cleanip-green", template: false) {
|
||||
Image(nsImage: green).resizable()
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
.accessibilityLabel("cleanip.io")
|
||||
}
|
||||
}
|
||||
|
||||
/// 可点击的 cleanip.io 字标:点了打开链接,悬停时略微变淡
|
||||
struct CleanIPLink: View {
|
||||
static let site = URL(string: "https://cleanip.io")!
|
||||
var height: CGFloat = DS.Size.iconInline
|
||||
var url = CleanIPLink.site
|
||||
var help = "cleanip.io"
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
Button { NSWorkspace.shared.open(url) } label: {
|
||||
CleanIPLogo(height: height)
|
||||
.opacity(hovering ? 0.7 : 1)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovering = $0 }
|
||||
.animation(DS.Motion.quick, value: hovering)
|
||||
.help(help)
|
||||
}
|
||||
}
|
||||
|
||||
/// 胶囊切换:底槽是一条灰色胶囊,选中项是一颗蓝色胶囊滑过去,文字随之变白
|
||||
struct PillSwitch<Value: Hashable>: View {
|
||||
@Binding var selection: Value
|
||||
let options: [(value: Value, title: String)]
|
||||
@Namespace private var namespace
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(options, id: \.value) { option in
|
||||
let selected = option.value == selection
|
||||
Button { selection = option.value } label: {
|
||||
Text(option.title)
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(selected ? DS.Palette.onPrimary : DS.Palette.textSecondary)
|
||||
.padding(.horizontal, DS.Space.s2)
|
||||
.frame(height: DS.Size.segmentHeight - DS.Space.s1)
|
||||
.background {
|
||||
if selected {
|
||||
Capsule().fill(DS.Palette.primary)
|
||||
.matchedGeometryEffect(id: "pill", in: namespace)
|
||||
}
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.s1 / 2)
|
||||
.background(DS.Palette.track, in: Capsule())
|
||||
.animation(DS.Motion.quick, value: selection)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 徽标
|
||||
|
||||
enum Tone {
|
||||
|
||||
@@ -59,8 +59,12 @@ struct OverlayScrollers: NSViewRepresentable {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
MainActor.assumeIsolated {
|
||||
guard let scrollView = self?.enclosingScrollView else { return }
|
||||
guard scrollView.scrollerStyle != .overlay || !scrollView.autohidesScrollers else { return }
|
||||
scrollView.scrollerStyle = .overlay
|
||||
scrollView.autohidesScrollers = true
|
||||
// 样式改了之后重新排版,让内容占满原来留给滚动条的那一条
|
||||
scrollView.tile()
|
||||
scrollView.documentView?.needsLayout = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ extension DS {
|
||||
static let border = Color.dynamic(light: 0x000000, lightAlpha: 0.07, dark: 0xFFFFFF, darkAlpha: 0.09)
|
||||
static let track = Color.dynamic(light: 0x000000, lightAlpha: 0.06, dark: 0xFFFFFF, darkAlpha: 0.10)
|
||||
static let neutral300 = Color.dynamic(light: 0xD1D5DB, dark: 0x3F434A)
|
||||
/// 分段条里表示“剩余 / 空闲”的中性段,深到能衬白字
|
||||
static let neutral500 = Color.dynamic(light: 0x6B7280, dark: 0x4B5563)
|
||||
/// 浅一档的品牌蓝,用来区分同一图表里的第二类数据(如性能核)
|
||||
static let primarySoft = Color.dynamic(light: 0x93C5FD, dark: 0x60A5FA)
|
||||
|
||||
@@ -90,6 +92,38 @@ extension DS {
|
||||
case xs = 12, sm = 14, base = 16, lg = 20, xl = 24, xxl = 32
|
||||
}
|
||||
|
||||
/// 徽章配色:浅底、细边、深字,与 cleanip.io 的徽章一致
|
||||
enum Badge {
|
||||
static let successText = Color.dynamic(light: 0x15803D, dark: 0x75D99B)
|
||||
static let successFill = Color.dynamic(light: 0xF0FDF4, dark: 0x142F24)
|
||||
static let successBorder = Color.dynamic(light: 0xBBF7D0, dark: 0x29543C)
|
||||
static let warningText = Color.dynamic(light: 0xB45309, dark: 0xF3C66C)
|
||||
static let warningFill = Color.dynamic(light: 0xFFFBEB, dark: 0x342B1B)
|
||||
static let warningBorder = Color.dynamic(light: 0xFDE68A, dark: 0x625030)
|
||||
static let neutralText = Palette.textSecondary
|
||||
static let neutralFill = Palette.elevated
|
||||
static let neutralBorder = Palette.border
|
||||
}
|
||||
|
||||
/// 0–100 分的六档色阶(F / D / C / B / A / A+),只用于评分类图表,不是界面配色
|
||||
enum Grade {
|
||||
static let f = Palette.error
|
||||
static let d = Palette.warning
|
||||
static let c = Color.dynamic(light: 0xEAB308, dark: 0xFACC15)
|
||||
static let b = Color.dynamic(light: 0x84CC16, dark: 0xA3E635)
|
||||
static let a = Palette.success
|
||||
static let aPlus = Color.dynamic(light: 0x15803D, dark: 0x16A34A)
|
||||
|
||||
/// 分段上限与颜色,从低到高
|
||||
static let bands: [(grade: String, upper: Int, color: Color)] = [
|
||||
("F", 25, f), ("D", 50, d), ("C", 70, c), ("B", 85, b), ("A", 95, a), ("A+", 100, aPlus),
|
||||
]
|
||||
|
||||
static func color(for score: Int) -> Color {
|
||||
bands.first { score < $0.upper }?.color ?? aPlus
|
||||
}
|
||||
}
|
||||
|
||||
enum Space {
|
||||
static let s1: CGFloat = 4
|
||||
static let s2: CGFloat = 8
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Cleaner
|
||||
import Localization
|
||||
import Metrics
|
||||
import SwiftUI
|
||||
|
||||
/// 磁盘:容量、实时读写、SSD 健康(SMART)与读写最多的应用
|
||||
/// 磁盘:容量、实时读写、空间分析、文件系统检查、本地快照、其他磁盘、SSD 健康(SMART)与读写最多的应用
|
||||
struct DiskPage: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
@@ -13,9 +14,256 @@ struct DiskPage: View {
|
||||
ActivityCard()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
SpaceCard()
|
||||
WeightedRow(weights: [1, 1]) {
|
||||
VerifyCard()
|
||||
SnapshotsCard()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VolumesCard()
|
||||
HealthCard()
|
||||
DiskProcessesCard()
|
||||
}
|
||||
.onAppear { model.diskTools.pageOpened() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 空间占用
|
||||
|
||||
private struct SpaceCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var confirming: SpaceScanResult.File?
|
||||
|
||||
var body: some View {
|
||||
let tools = model.diskTools
|
||||
Card {
|
||||
CardHeader(icon: "chart.pie", title: tr("空间占用")) {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
Text(tr("家目录")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).lineLimit(1)
|
||||
Group {
|
||||
switch tools.scanPhase {
|
||||
case .scanning:
|
||||
Button(tr("停止")) { tools.cancelScan() }.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
case .finished:
|
||||
Button(tr("重新分析")) { tools.startScan() }.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
default:
|
||||
Button(tr("开始分析")) { tools.startScan() }.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
switch tools.scanPhase {
|
||||
case .idle:
|
||||
Text(tr("统计家目录里每个文件夹占多少空间,并找出最大的文件;只读取大小,不改动任何文件。文件多时需要几十秒。"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case .scanning(let progress):
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(verbatim: tr("已扫描 \(progress.items) 个条目 · \(Format.bytes(progress.bytes, base: .decimal))") + (progress.current.isEmpty ? "" : " · \(progress.current)"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).monospacedDigit().lineLimit(1)
|
||||
}
|
||||
case .failed(let message):
|
||||
InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error)
|
||||
case .finished(let result):
|
||||
results(result, tools: tools)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(tr("移到废纸篓?"), isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }),
|
||||
titleVisibility: .visible, presenting: confirming) { file in
|
||||
Button(tr("移到废纸篓"), role: .destructive) { tools.trash(file) }
|
||||
Button(tr("取消"), role: .cancel) {}
|
||||
} message: { file in
|
||||
Text(verbatim: tr("“\(file.name)”(\(Format.bytes(file.bytes, base: .decimal)))会移到废纸篓,可以从废纸篓放回。"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func results(_ result: SpaceScanResult, tools: DiskToolsController) -> some View {
|
||||
let top = result.folders.prefix(8)
|
||||
let peak = top.first?.bytes ?? 1
|
||||
Text(verbatim: tr("共 \(Format.bytes(result.totalBytes, base: .decimal)),\(result.scannedItems) 个条目 · \(result.date.formatted(.relative(presentation: .named).locale(L10n.locale)))"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
WeightedRow(weights: [1, 1], spacing: DS.Space.s6) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||||
Text(tr("按文件夹")).dsFont(.xs, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
|
||||
ForEach(Array(top)) { folder in
|
||||
VStack(spacing: DS.Space.s1) {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
Text(verbatim: folder.name).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
Text(verbatim: Format.bytes(folder.bytes, base: .decimal))
|
||||
.dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textPrimary).monospacedDigit()
|
||||
}
|
||||
ProgressTrack(fraction: peak > 0 ? Double(folder.bytes) / Double(peak) : 0, height: DS.Space.s1, showsTrack: false)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tools.reveal(folder.url) }
|
||||
.help(tr("在访达中显示"))
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||||
Text(tr("最大的文件")).dsFont(.xs, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
|
||||
if result.largestFiles.isEmpty {
|
||||
Text(tr("没有超过 50 MB 的文件")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
ForEach(result.largestFiles.prefix(8)) { file in
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(verbatim: file.name).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
|
||||
Text(verbatim: file.url.deletingLastPathComponent().path.replacingOccurrences(of: result.root.path, with: "~"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1).truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
Text(verbatim: Format.bytes(file.bytes, base: .decimal))
|
||||
.dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textPrimary).monospacedDigit()
|
||||
IconButton(systemName: "magnifyingglass", help: tr("在访达中显示")) { tools.reveal(file.url) }
|
||||
if SpaceScanner.canTrash(file.url) {
|
||||
IconButton(systemName: "trash", help: tr("移到废纸篓")) { confirming = file }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let outcome = tools.trashOutcome {
|
||||
InfoBanner(icon: outcome.isError ? "exclamationmark.triangle" : "checkmark.circle.fill", text: outcome.text,
|
||||
tone: outcome.isError ? .error : .success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 文件系统检查
|
||||
|
||||
private struct VerifyCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let tools = model.diskTools
|
||||
Card {
|
||||
CardHeader(icon: "stethoscope", title: tr("文件系统检查")) {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
Text(tools.lastVerified.map { tr("上次检查:\($0.formatted(.relative(presentation: .named).locale(L10n.locale)))") } ?? tr("尚未检查"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).lineLimit(1)
|
||||
Button(tools.verifyPhase == .running ? tr("检查中…") : tr("检查")) { tools.verify() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(tools.verifyPhase == .running)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
switch tools.verifyPhase {
|
||||
case .running:
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(tr("正在核对启动盘的文件系统结构,通常几秒到几十秒")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
case .finished(let result):
|
||||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||||
StatusBadge(text: result.ok ? tr("状态正常") : tr("发现问题"), tone: result.ok ? .success : .error)
|
||||
Text(verbatim: result.summary).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !result.ok {
|
||||
Text(tr("请打开系统自带的“磁盘工具”,选择启动盘运行“急救”进行修复"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
case .failed(let message):
|
||||
InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error)
|
||||
case .idle:
|
||||
Text(tr("相当于“磁盘工具”里的急救,但只检查不修改:核对启动盘的目录结构、文件分配与快照元数据是否一致。"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize(horizontal: false, vertical: true)
|
||||
if tools.lastVerified != nil {
|
||||
StatusBadge(text: tools.lastVerifiedOK ? tr("上次检查正常") : tr("上次检查发现问题"), tone: tools.lastVerifiedOK ? .success : .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 本地快照
|
||||
|
||||
private struct SnapshotsCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var confirming = false
|
||||
|
||||
var body: some View {
|
||||
let tools = model.diskTools
|
||||
Card {
|
||||
CardHeader(icon: "clock.arrow.circlepath", title: tr("本地快照")) {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
Text(tools.snapshotsLoaded ? tr("\(tools.snapshots.count) 个") : tr("读取中"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).lineLimit(1)
|
||||
Button(tools.isDeletingSnapshots ? tr("删除中…") : tr("全部删除")) { confirming = true }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(tools.snapshots.isEmpty || tools.isDeletingSnapshots)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
Text(tr("Time Machine 在备份之间会先在本机留下快照,它们占的空间计入“可清除”,系统缺空间时会自动删。手动删除不影响已完成的备份。"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize(horizontal: false, vertical: true)
|
||||
if tools.snapshotsLoaded, tools.snapshots.isEmpty {
|
||||
Text(tr("现在没有本地快照")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
ForEach(tools.snapshots.prefix(6)) { snapshot in
|
||||
InfoRow(label: snapshot.date.map { $0.formatted(date: .abbreviated, time: .shortened) } ?? snapshot.identifier) {
|
||||
Text(verbatim: snapshot.date.map { $0.formatted(.relative(presentation: .named).locale(L10n.locale)) } ?? "")
|
||||
}
|
||||
}
|
||||
if tools.snapshots.count > 6 {
|
||||
Text(verbatim: tr("还有 \(tools.snapshots.count - 6) 个")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
if let outcome = tools.snapshotOutcome {
|
||||
InfoBanner(icon: outcome.isError ? "exclamationmark.triangle" : "checkmark.circle.fill", text: outcome.text,
|
||||
tone: outcome.isError ? .error : .success)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(tr("删除全部本地快照?"), isPresented: $confirming, titleVisibility: .visible) {
|
||||
Button(tr("删除"), role: .destructive) { tools.deleteAllSnapshots() }
|
||||
Button(tr("取消"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(tr("需要管理员权限。已完成的 Time Machine 备份不受影响,只是本机上这些快照对应的时间点无法再从本地恢复。"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 其他磁盘
|
||||
|
||||
private struct VolumesCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let tools = model.diskTools
|
||||
if !tools.volumes.isEmpty || tools.volumeOutcome != nil {
|
||||
Card {
|
||||
CardHeader(icon: "externaldrive", title: tr("其他磁盘"), detail: tr("外置硬盘、U 盘、镜像与网络共享"))
|
||||
ForEach(tools.volumes) { volume in
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
Image(systemName: volume.isLocal ? (volume.isInternal ? "internaldrive" : "externaldrive") : "network")
|
||||
.font(.system(size: DS.TextSize.base.rawValue))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.frame(width: DS.Size.iconStandalone)
|
||||
Text(verbatim: volume.name).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
Text(verbatim: tr("可用 \(Format.bytes(volume.available, base: .decimal)) / 共 \(Format.bytes(volume.total, base: .decimal))"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).monospacedDigit()
|
||||
IconButton(systemName: "magnifyingglass", help: tr("在访达中显示")) { tools.reveal(volume.url) }
|
||||
if volume.isEjectable || volume.isRemovable || !volume.isLocal {
|
||||
Button(tools.ejecting.contains(volume.id) ? tr("推出中…") : tr("推出")) { tools.eject(volume) }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(tools.ejecting.contains(volume.id))
|
||||
}
|
||||
}
|
||||
ProgressTrack(fraction: volume.usedFraction,
|
||||
color: volume.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary, height: DS.Space.s1)
|
||||
}
|
||||
}
|
||||
if let outcome = tools.volumeOutcome {
|
||||
InfoBanner(icon: outcome.isError ? "exclamationmark.triangle" : "checkmark.circle.fill", text: outcome.text,
|
||||
tone: outcome.isError ? .error : .success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +283,10 @@ private struct CapacityCard: View {
|
||||
Text(verbatim: "%").dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
ProgressTrack(fraction: disk?.usedFraction ?? 0,
|
||||
color: (disk?.usedFraction ?? 0) > 0.9 ? DS.Palette.warning : DS.Palette.primary)
|
||||
SegmentedBar(segments: disk.map(DiskSegments.segments) ?? DiskSegments.placeholder)
|
||||
if let disk {
|
||||
InfoRow(label: tr("已用"), text: Format.bytes(disk.used, base: .decimal))
|
||||
InfoRow(label: tr("可用"), text: Format.bytes(disk.available, base: .decimal))
|
||||
Text(tr("可用空间包含系统可以随时清除的缓存,与访达显示一致"))
|
||||
DiskSegments.legend(disk)
|
||||
Text(tr("可清除是系统随时可以腾出的缓存;访达显示的“可用”把它算在内"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -49,6 +295,41 @@ private struct CapacityCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 容量分段:已用(超过 90% 变橙)、可清除、可用,主窗口卡片与菜单栏弹窗共用
|
||||
enum DiskSegments {
|
||||
static func segments(_ disk: DiskUsage) -> [SegmentedBar.Segment] {
|
||||
var segments = [SegmentedBar.Segment(id: "used", label: tr("已用"), fraction: disk.usedFraction,
|
||||
color: disk.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary)]
|
||||
if disk.purgeable > 0 {
|
||||
segments.append(SegmentedBar.Segment(id: "purgeable", label: tr("可清除"), fraction: disk.purgeableFraction,
|
||||
color: DS.Palette.secondary))
|
||||
}
|
||||
segments.append(SegmentedBar.Segment(id: "free", label: tr("可用"), fraction: disk.freeFraction, color: DS.Palette.neutral500))
|
||||
return segments
|
||||
}
|
||||
|
||||
static let placeholder = [SegmentedBar.Segment(id: "loading", label: "", fraction: 1, color: DS.Palette.track)]
|
||||
|
||||
@ViewBuilder
|
||||
static func legend(_ disk: DiskUsage) -> some View {
|
||||
// 放不下就换行,不压缩每一项
|
||||
FlowLayout(spacing: DS.Space.s4) {
|
||||
ForEach(segments(disk)) { segment in
|
||||
LegendItem(color: segment.color, label: segment.label, value: Format.bytes(bytes(for: segment.id, in: disk), base: .decimal))
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func bytes(for id: String, in disk: DiskUsage) -> UInt64 {
|
||||
switch id {
|
||||
case "used": disk.used
|
||||
case "purgeable": disk.purgeable
|
||||
default: disk.free
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
@@ -58,10 +339,12 @@ private struct ActivityCard: View {
|
||||
let writes = store.diskWriteHistory.elements
|
||||
Card {
|
||||
CardHeader(icon: "arrow.up.arrow.down", title: tr("读写速度"), detail: tr("所有磁盘合计"))
|
||||
// 两组数值各占一半宽度:数字长短变化时位置不动
|
||||
HStack(spacing: DS.Space.s4) {
|
||||
rate(tr("读取"), store.diskActivity?.readRate, color: DS.NetworkPalette.download)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
rate(tr("写入"), store.diskActivity?.writeRate, color: DS.NetworkPalette.upload)
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
// 上半写入、下半读取,与网络流量图同一套配色
|
||||
MirroredRateChart(upload: writes, download: reads, height: DS.Size.chartHeight * 2)
|
||||
@@ -137,54 +420,184 @@ private struct HealthCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func tone(_ health: DiskHealth) -> Tone {
|
||||
if health.criticalWarning != 0 || health.mediaErrors > 0 || health.availableSpare < health.availableSpareThreshold { return .error }
|
||||
return health.remainingLife < 20 ? .warning : .success
|
||||
private func tone(_ health: DiskHealth) -> Tone { health.tone }
|
||||
private func summary(_ health: DiskHealth) -> String { health.summary }
|
||||
}
|
||||
|
||||
extension DiskHealth {
|
||||
/// 有严重警告、介质错误或备用空间不足为红色,寿命偏低为橙色
|
||||
var tone: Tone {
|
||||
if criticalWarning != 0 || mediaErrors > 0 || availableSpare < availableSpareThreshold { return .error }
|
||||
return remainingLife < 20 ? .warning : .success
|
||||
}
|
||||
|
||||
private func summary(_ health: DiskHealth) -> String {
|
||||
if health.criticalWarning != 0 { return tr("磁盘报告了严重警告,建议尽快备份") }
|
||||
if health.mediaErrors > 0 { return tr("出现介质错误,建议备份") }
|
||||
if health.availableSpare < health.availableSpareThreshold { return tr("备用空间低于阈值,建议备份") }
|
||||
return health.remainingLife < 20 ? tr("寿命偏低,注意备份") : tr("状态良好")
|
||||
var summary: String {
|
||||
if criticalWarning != 0 { return tr("磁盘报告了严重警告,建议尽快备份") }
|
||||
if mediaErrors > 0 { return tr("出现介质错误,建议备份") }
|
||||
if availableSpare < availableSpareThreshold { return tr("备用空间低于阈值,建议备份") }
|
||||
return remainingLife < 20 ? tr("寿命偏低,注意备份") : tr("状态良好")
|
||||
}
|
||||
}
|
||||
|
||||
private struct DiskProcessesCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let rows = ProcessRowModel.grouped(model.store.processes)
|
||||
.compactMap { row in row.disk.map { (row, $0.read + $0.write) } }
|
||||
.filter { $0.1 > 0 }
|
||||
.sorted { $0.1 > $1.1 }
|
||||
.prefix(8)
|
||||
let peak = rows.first?.1 ?? 1
|
||||
|
||||
Card {
|
||||
CardHeader(icon: "list.bullet.rectangle", title: tr("读写最多的应用"), detail: tr("只含当前用户的进程"))
|
||||
if rows.isEmpty {
|
||||
Text(tr("最近没有应用在读写磁盘")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
ForEach(Array(rows), id: \.0.id) { row, total in
|
||||
VStack(spacing: DS.Space.s1) {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
AppIconCache.shared.image(bundlePath: row.bundlePath)
|
||||
.resizable()
|
||||
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
|
||||
Text(verbatim: row.title)
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
Text(verbatim: tr("读 \(Format.menuBarRate(row.disk?.read ?? 0)) · 写 \(Format.menuBarRate(row.disk?.write ?? 0))"))
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
ProgressTrack(fraction: total / peak, height: DS.Space.s1)
|
||||
DiskProcessRows(rowCount: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读写磁盘最多的应用,主窗口磁盘页与菜单栏弹窗共用
|
||||
private struct DiskProcessRows: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let rowCount: Int
|
||||
|
||||
var body: some View {
|
||||
// 顺序和条形长度按最近十几秒的平均速率,数字显示当前速率:列表不会随每秒的波动跳动
|
||||
let grouped = Dictionary(uniqueKeysWithValues: ProcessRowModel.grouped(model.store.processes).map { ($0.id, $0) })
|
||||
let rows = model.store.diskRanking.ranked(limit: rowCount)
|
||||
.compactMap { entry in grouped[entry.id].map { ($0, entry.score) } }
|
||||
let peak = rows.first?.1 ?? 1
|
||||
|
||||
if rows.isEmpty {
|
||||
Text(tr("最近没有应用在读写磁盘")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
ForEach(Array(rows), id: \.0.id) { row, total in
|
||||
VStack(spacing: DS.Space.s1) {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
AppIconCache.shared.image(bundlePath: row.bundlePath)
|
||||
.resizable()
|
||||
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
|
||||
Text(verbatim: row.title)
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
Text(verbatim: tr("读 \(Format.menuBarRate(row.disk?.read ?? 0)) · 写 \(Format.menuBarRate(row.disk?.write ?? 0))"))
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
ProgressTrack(fraction: total / peak, height: DS.Space.s1, showsTrack: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 菜单栏弹窗
|
||||
|
||||
/// 菜单栏“磁盘”项的窄详情:容量、读写速度、SSD 健康、读写最多的应用,区块可在设置里逐个隐藏
|
||||
struct DiskPopover: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let settings = model.settings
|
||||
let store = model.store
|
||||
|
||||
DiskHero()
|
||||
|
||||
ForEach(MenuBarItem.disk.popoverSections.filter { settings.isVisible($0) }) { section in
|
||||
switch section {
|
||||
case .diskActivity:
|
||||
let reads = store.diskReadHistory.elements
|
||||
let writes = store.diskWriteHistory.elements
|
||||
SectionCard(title: section.title, trailing: { Text(tr("所有磁盘合计")) }) {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
LegendItem(color: Color(nsColor: DS.NetworkPalette.download), label: tr("读取"),
|
||||
value: store.diskActivity.map { Format.menuBarRate($0.readRate) } ?? "—")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
LegendItem(color: Color(nsColor: DS.NetworkPalette.upload), label: tr("写入"),
|
||||
value: store.diskActivity.map { Format.menuBarRate($0.writeRate) } ?? "—")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
MirroredRateChart(upload: writes, download: reads, height: DS.Size.chartHeight)
|
||||
InfoRow(label: tr("60 秒峰值")) {
|
||||
Text(verbatim: tr("读 \(Format.menuBarRate(reads.max() ?? 0)) · 写 \(Format.menuBarRate(writes.max() ?? 0))"))
|
||||
}
|
||||
}
|
||||
case .diskHealth:
|
||||
let health = store.diskHealth
|
||||
SectionCard(title: section.title,
|
||||
hint: tr("寿命按厂商估算的已用比例计算;写入量越大消耗越快,日常使用通常可用很多年"),
|
||||
trailing: { Text(verbatim: health?.model ?? "") }) {
|
||||
if let health {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
RingGauge(fraction: Double(health.remainingLife) / 100, color: health.tone.color, size: DS.Space.s12 + DS.Space.s2) {
|
||||
Text(verbatim: "\(health.remainingLife)%")
|
||||
.dsFont(.sm, weight: .semibold)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
StatusBadge(text: health.summary, tone: health.tone)
|
||||
Text(verbatim: tr("剩余寿命 \(health.remainingLife)% · 已写入 \(Format.bytes(health.bytesWritten, base: .decimal))"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
if let temperature = health.temperature {
|
||||
InfoRow(label: tr("温度"), text: Format.temperature(temperature, fahrenheit: settings.useFahrenheit))
|
||||
}
|
||||
InfoRow(label: tr("通电时间"), text: tr("\(health.powerOnHours.formatted()) 小时"))
|
||||
InfoRow(label: tr("异常断电"), text: health.unsafeShutdowns.formatted())
|
||||
} else {
|
||||
Text(tr("正在读取 SMART 信息;外置磁盘或不支持 NVMe SMART 的磁盘不显示"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
case .diskProcesses:
|
||||
SectionCard(title: section.title, trailing: { Text(tr("只含当前用户的进程")) }) {
|
||||
DiskProcessRows(rowCount: 5)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 磁盘弹窗顶部:已用百分比、卷名与总容量、可用空间,下面是用量条
|
||||
private struct DiskHero: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let disk = model.store.disk
|
||||
let fraction = disk?.usedFraction ?? 0
|
||||
let color = fraction > 0.9 ? DS.Palette.warning : DS.Palette.primary
|
||||
|
||||
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
|
||||
HStack(alignment: .center, spacing: DS.Space.s3) {
|
||||
HeroValue(value: disk.map { "\(Int(($0.usedFraction * 100).rounded()))" } ?? "—", unit: "%", size: .xxl)
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
Text(verbatim: disk?.volumeName ?? tr("启动磁盘"))
|
||||
.dsFont(.sm, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(verbatim: disk.map { tr("共 \(Format.bytes($0.total, base: .decimal))") } ?? tr("读取中"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
if let disk {
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
Text(tr("可用")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
Text(verbatim: Format.bytes(disk.available, base: .decimal))
|
||||
.dsFont(.base, weight: .semibold)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
SegmentedBar(segments: disk.map(DiskSegments.segments) ?? DiskSegments.placeholder, height: DS.Space.s6)
|
||||
if let disk {
|
||||
DiskSegments.legend(disk)
|
||||
}
|
||||
}
|
||||
.help(tr("可清除是系统随时可以腾出的缓存;访达显示的“可用”把它算在内"))
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ public struct MainWindowView: View {
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 页面的滚动视图会自动向上延伸到标题栏下面,与顶栏重叠;顶栏必须在它之上,否则开关收不到点击
|
||||
PageHeader()
|
||||
.zIndex(1)
|
||||
Group {
|
||||
switch model.settings.panelTab {
|
||||
case .overview: OverviewPage()
|
||||
@@ -45,6 +47,7 @@ public struct MainWindowView: View {
|
||||
case .settingsGeneral: SettingsTabPage { GeneralSettings() }
|
||||
case .settingsMenuBar: SettingsTabPage { MenuBarSettings() }
|
||||
case .settingsNotifications: SettingsTabPage { NotificationSettings() }
|
||||
case .settingsAccount: SettingsTabPage { AccountSettings() }
|
||||
case .settingsHelper: SettingsTabPage { HelperSettings() }
|
||||
case .settingsAbout: SettingsTabPage { AboutSettings() }
|
||||
}
|
||||
@@ -121,6 +124,8 @@ private struct MainSidebar: View {
|
||||
switch tab {
|
||||
case .settingsAbout: model.updates.release != nil ? .primary : nil
|
||||
case .settingsHelper: model.helper.isReady && model.helper.isOutdated ? .warning : nil
|
||||
case .settingsAccount:
|
||||
if model.sync.conflict != nil { .primary } else if case .failed = model.sync.phase { .warning } else { nil }
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
@@ -162,10 +167,18 @@ private struct PageHeader: View {
|
||||
Chip(text: tr("防休眠已开启"), icon: "cup.and.saucer.fill", tone: .primary)
|
||||
}
|
||||
if tab == .memory { PurgeMemoryButton() }
|
||||
if let item = tab.menuBarItem {
|
||||
// 每个监控页都能在这里开关自己的菜单栏项目;温度与风扇页有两项,各带一个小标签
|
||||
let items = tab.menuBarItems
|
||||
if !items.isEmpty {
|
||||
Text(tr("在菜单栏显示")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }),
|
||||
label: tr("在菜单栏显示\(item.title)"))
|
||||
ForEach(items) { item in
|
||||
if items.count > 1 {
|
||||
Text(item.popoverTitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }),
|
||||
label: tr("在菜单栏显示\(item.title)"))
|
||||
.help(tr("开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DS.Space.s3 + DS.Space.s1)
|
||||
|
||||
@@ -85,8 +85,9 @@ private struct AppListColumn: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 探针必须放在内容里才找得到这个列表自己的 NSScrollView;挂在外面时接了鼠标会显示一条粗的传统滚动条
|
||||
.overlayScrollers()
|
||||
}
|
||||
.overlayScrollers()
|
||||
}
|
||||
.padding(.vertical, DS.Space.s3)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
@@ -199,14 +200,13 @@ private struct AppDetailCard: View {
|
||||
|
||||
HairlineDivider()
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
DSToggle(isOn: $uninstaller.removeFromDock, label: tr("从程序坞移除图标"))
|
||||
Text(tr("同时从程序坞移除图标")).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary)
|
||||
Spacer()
|
||||
Button(uninstaller.isRemoving ? tr("正在移除…") : tr("卸载")) { confirm() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(running || uninstaller.isScanning || uninstaller.isRemoving)
|
||||
}
|
||||
Text(tr("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动"))
|
||||
// 程序坞里的图标始终一并移除,不单独做开关:留着一个空图标没有意义
|
||||
Text(tr("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -25,65 +25,85 @@ struct CPUPopover: View {
|
||||
@Environment(\.isDetailPage) private var isDetailPage
|
||||
|
||||
var body: some View {
|
||||
let settings = model.settings
|
||||
let store = model.store
|
||||
let cpu = store.cpu
|
||||
let cores = Double(max(1, store.topology.logicalCores))
|
||||
|
||||
CPUHero()
|
||||
|
||||
ForEach(MenuBarItem.cpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
|
||||
switch section {
|
||||
case .cpuHeatmap:
|
||||
SectionCard(title: section.title, trailing: { HeatLegend() }) {
|
||||
CoreHeatmap(topology: store.topology, history: store.coreHistory.elements,
|
||||
columns: isDetailPage ? 60 : 36,
|
||||
rowHeight: isDetailPage ? DS.Space.s2 : DS.Space.s1 - DS.Size.stroke)
|
||||
Text(tr("每行一个核心,每列一次采样,最新的在右边"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
case .cpuClusters:
|
||||
SectionCard(title: section.title) {
|
||||
ForEach(store.topology.clusters) { cluster in
|
||||
let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0
|
||||
let frequency = store.power?.clusterFrequency[cluster.id].map { " · \(Format.frequency(megahertz: $0))" } ?? ""
|
||||
ShareRow(label: tr("\(cluster.name) · \(cluster.coreIndices.count) 核\(frequency)"), value: Format.percent(average),
|
||||
fraction: average, color: DS.Palette.cluster(cluster.id))
|
||||
}
|
||||
if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) {
|
||||
InfoRow(label: tr("最忙的核心"), text: busiest)
|
||||
}
|
||||
}
|
||||
case .cpuLoadAverage:
|
||||
SectionCard(title: section.title, trailing: { Text(loadTrend(cpu?.loadAverage ?? [])) }) {
|
||||
let averages = cpu?.loadAverage ?? []
|
||||
ForEach(Array([tr("1 分钟"), tr("5 分钟"), tr("15 分钟")].enumerated()), id: \.offset) { index, label in
|
||||
let value = averages[safe: index] ?? 0
|
||||
ShareRow(label: label,
|
||||
value: tr("每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))"),
|
||||
fraction: value / cores, color: loadTone(value / cores).color)
|
||||
}
|
||||
Text(tr("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
case .cpuApps:
|
||||
SectionCard(title: section.title, trailing: { Text("CPU") }) {
|
||||
AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.cpu > $1.cpu },
|
||||
rowCount: isDetailPage ? 10 : 6, metric: .cpu)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
if isDetailPage {
|
||||
// 主窗口更宽:先是最直观的各核心圆环,再是热力图;核心分工与排队程度并排,按应用汇总占满一行
|
||||
card(.cpuCores)
|
||||
card(.cpuHeatmap)
|
||||
HStack(alignment: .top, spacing: DS.Space.s3) {
|
||||
card(.cpuClusters)
|
||||
card(.cpuLoadAverage)
|
||||
}
|
||||
card(.cpuApps)
|
||||
} else {
|
||||
ForEach(MenuBarItem.cpu.popoverSections.filter { model.settings.isVisible($0) }) { section in
|
||||
card(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func busiestCore(_ topology: CPUTopology, _ perCore: [Double]) -> String? {
|
||||
guard let (index, value) = perCore.enumerated().max(by: { $0.element < $1.element }).map({ ($0.offset, $0.element) }),
|
||||
let cluster = topology.clusters.first(where: { $0.coreIndices.contains(index) }) else { return nil }
|
||||
return "\(cluster.name) #\(index + 1) · \(Format.percent(value))"
|
||||
@ViewBuilder
|
||||
private func card(_ section: PopoverSection) -> some View {
|
||||
let store = model.store
|
||||
let cpu = store.cpu
|
||||
let cores = Double(max(1, store.topology.logicalCores))
|
||||
|
||||
switch section {
|
||||
// 各区块的解释都放在悬停提示里(标题旁的 ⓘ 或卡片内容),界面上只留数据
|
||||
case .cpuHeatmap:
|
||||
SectionCard(title: section.title,
|
||||
hint: tr("每行一个核心(共 \(store.topology.logicalCores) 个),每列一次采样,颜色越深越忙;右侧粗条是此刻的占用"),
|
||||
trailing: { HeatLegend() }) {
|
||||
CoreHeatmap(topology: store.topology, history: store.coreHistory.elements,
|
||||
columns: isDetailPage ? 60 : 36,
|
||||
rowHeight: isDetailPage ? DS.Space.s2 : DS.Space.s1 - DS.Size.stroke)
|
||||
}
|
||||
case .cpuCores:
|
||||
// 主窗口:每个核心一个圆环,中间写百分比,最直观;弹窗窄,用每核一根柱子的紧凑版(默认关闭,在设置里打开)
|
||||
if isDetailPage {
|
||||
SectionCard(title: section.title, hint: tr("此刻各核心的占用;超过 60% 变橙色、85% 变红色")) {
|
||||
CoreRingGrid(topology: store.topology, perCore: cpu?.perCore ?? [])
|
||||
}
|
||||
} else {
|
||||
SectionCard(title: section.title, hint: tr("每根柱子一个核心,按核心类型着色")) {
|
||||
CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? [], byCluster: true)
|
||||
}
|
||||
}
|
||||
case .cpuClusters:
|
||||
SectionCard(title: section.title, hint: clusterExplanation(store.topology),
|
||||
trailing: { Text(verbatim: store.topology.brand) }) {
|
||||
ForEach(store.topology.clusters) { cluster in
|
||||
let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0
|
||||
ShareRow(label: tr("\(cluster.name) · \(cluster.coreIndices.count) 核"), value: Format.percent(average),
|
||||
detail: store.power?.clusterFrequency[cluster.id].map { Format.frequency(megahertz: $0) },
|
||||
fraction: average, color: DS.Palette.cluster(cluster.id))
|
||||
}
|
||||
if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) {
|
||||
InfoRow(label: tr("最忙的核心"), text: busiest)
|
||||
}
|
||||
}
|
||||
case .cpuLoadAverage:
|
||||
SectionCard(title: section.title,
|
||||
hint: tr("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU"),
|
||||
trailing: { Text(loadTrend(cpu?.loadAverage ?? [])) }) {
|
||||
let averages = cpu?.loadAverage ?? []
|
||||
ForEach(Array([tr("1 分钟"), tr("5 分钟"), tr("15 分钟")].enumerated()), id: \.offset) { index, label in
|
||||
let value = averages[safe: index] ?? 0
|
||||
ShareRow(label: label,
|
||||
value: tr("每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))"),
|
||||
fraction: value / cores, color: loadTone(value / cores).color)
|
||||
}
|
||||
}
|
||||
case .cpuApps:
|
||||
SectionCard(title: section.title,
|
||||
hint: tr("每个应用按一个核心满载为 100% 计算,与活动监视器一致,所以各应用相加可以超过顶部的总占用")) {
|
||||
AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.cpu > $1.cpu },
|
||||
rowCount: isDetailPage ? 10 : 6, metric: .cpu)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTrend(_ averages: [Double]) -> String {
|
||||
@@ -91,11 +111,91 @@ struct CPUPopover: View {
|
||||
let change = averages[0] / averages[2]
|
||||
return change > 1.15 ? tr("负载在上升") : change < 0.85 ? tr("负载在下降") : tr("负载平稳")
|
||||
}
|
||||
|
||||
/// 核心按 1…N 统一编号,与“各核心占用”里的圆环标签一致,括号里注明它属于哪一类
|
||||
private func busiestCore(_ topology: CPUTopology, _ perCore: [Double]) -> String? {
|
||||
guard let (index, value) = perCore.enumerated().max(by: { $0.element < $1.element }).map({ ($0.offset, $0.element) }),
|
||||
let cluster = topology.clusters.first(where: { $0.coreIndices.contains(index) }) else { return nil }
|
||||
return tr("核心 \(index + 1)(\(cluster.name))· \(Format.percent(value))")
|
||||
}
|
||||
|
||||
/// 用人话说明每类核心是干什么的,只有一类核心(Intel)时不显示
|
||||
private func clusterExplanation(_ topology: CPUTopology) -> String? {
|
||||
let roles = topology.clusters.sorted { $0.id < $1.id }.compactMap { cluster in
|
||||
cluster.role(in: topology).map { tr("\(cluster.name):\($0)") }
|
||||
}
|
||||
guard !roles.isEmpty else { return nil }
|
||||
return tr("\(roles.joined(separator: " · "))。系统会自动分配任务,不用你操心")
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU 顶部:大号占用、状态、与 30 秒前相比的变化、温度余量,下面是走势线和用户 / 系统 / 空闲构成
|
||||
extension CPUCluster {
|
||||
/// 这类核心的分工:最高档最快、最低档最省电、中间档介于两者之间;只有一类核心时没有分工可言
|
||||
func role(in topology: CPUTopology) -> String? {
|
||||
let levels = topology.clusters.count
|
||||
guard levels > 1 else { return nil }
|
||||
if id == 0 { return tr("最快,重活优先交给它们") }
|
||||
if id == levels - 1 { return tr("更省电,负责后台和轻量任务") }
|
||||
return tr("速度与省电介于两者之间")
|
||||
}
|
||||
}
|
||||
|
||||
/// 主窗口里的各核心占用:每个核心一个小圆环,中间写百分比,按核心类型分组,
|
||||
/// 组名后面用一句话说明这类核心是干什么的;圆环颜色随占用变化,忙的核心一眼能看到
|
||||
private struct CoreRingGrid: View {
|
||||
let topology: CPUTopology
|
||||
let perCore: [Double]
|
||||
|
||||
private static let ring = DS.Space.s12 + DS.Space.s2
|
||||
private static let columns = [GridItem(.adaptive(minimum: DS.Space.s16 + DS.Space.s2), spacing: DS.Space.s2)]
|
||||
|
||||
var body: some View {
|
||||
// 最快的一类在前
|
||||
let clusters = topology.clusters.sorted { $0.id < $1.id }
|
||||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||||
ForEach(clusters) { cluster in
|
||||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||||
if clusters.count > 1 {
|
||||
// 这类核心是干什么的,悬停组名时再说
|
||||
HStack(spacing: DS.Space.s1) {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(DS.Palette.cluster(cluster.id))
|
||||
.frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
|
||||
Text(verbatim: tr("\(cluster.name) · \(cluster.coreIndices.count) 个"))
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
.help(optional: cluster.role(in: topology))
|
||||
}
|
||||
LazyVGrid(columns: Self.columns, alignment: .leading, spacing: DS.Space.s2) {
|
||||
ForEach(cluster.coreIndices, id: \.self) { index in
|
||||
let value = perCore[safe: index] ?? 0
|
||||
VStack(spacing: DS.Space.s1) {
|
||||
RingGauge(fraction: value, color: loadTone(value).color, size: Self.ring) {
|
||||
Text(verbatim: Format.percent(value))
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
Text(verbatim: tr("核心 \(index + 1)"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(tr("各核心占用"))
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU 顶部:大号占用、状态、与 30 秒前相比的变化、CPU 温度(附离 100°C 的余量),
|
||||
/// 下面是最近 1 / 3 / 5 分钟的走势线(主窗口里更高,带百分比刻度、参考线与时间轴)、峰值和用户 / 系统 / 空闲构成
|
||||
private struct CPUHero: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.isDetailPage) private var isDetailPage
|
||||
|
||||
var body: some View {
|
||||
let store = model.store
|
||||
@@ -103,6 +203,11 @@ private struct CPUHero: View {
|
||||
let total = cpu?.total ?? 0
|
||||
let history = store.cpuTotal.elements
|
||||
let temperature = store.sensors?.temperature(.cpu)
|
||||
let fahrenheit = model.settings.useFahrenheit
|
||||
// 走势按真实时间定位:最新一次采样在右边缘,往左铺满所选时长
|
||||
let duration = TimeInterval(model.settings.cpuChartSeconds)
|
||||
let end = store.cpuTimeline.elements.last?.date ?? Date()
|
||||
let timeline = store.cpuTimeline.elements.filter { $0.date >= end.addingTimeInterval(-duration) }
|
||||
|
||||
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
|
||||
HStack(alignment: .center, spacing: DS.Space.s3) {
|
||||
@@ -116,20 +221,47 @@ private struct CPUHero: View {
|
||||
}
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
if let temperature {
|
||||
let hottest = temperature.maximum
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
Text(tr("温度余量")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
Text(verbatim: "\(Int(max(0, 100 - temperature.maximum).rounded()))°C")
|
||||
Text(tr("CPU 温度")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
Text(verbatim: Format.temperature(hottest, fahrenheit: fahrenheit))
|
||||
.dsFont(.base, weight: .semibold)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Tone.forTemperature(temperature.maximum).color)
|
||||
Text(verbatim: tr("当前 \(Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit))"))
|
||||
.foregroundStyle(Tone.forTemperature(hottest).color)
|
||||
Text(verbatim: tr("余量 \(headroom(hottest, fahrenheit: fahrenheit))"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
.help(tr("离 100°C 还差多少度;越接近 0,越可能因过热降频"))
|
||||
.help(tr("核心最高温度;余量是离 100°C 还差多少度,越接近 0 越可能因过热降频"))
|
||||
}
|
||||
}
|
||||
LineHistoryChart(values: history, height: DS.Space.s8)
|
||||
if isDetailPage {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
PercentAxis(height: DS.Size.chartHeight * 2)
|
||||
TimedLineChart(points: timeline, duration: duration, end: end, grid: true, height: DS.Size.chartHeight * 2)
|
||||
}
|
||||
HStack {
|
||||
Text(verbatim: Self.clock(end.addingTimeInterval(-duration)))
|
||||
Spacer()
|
||||
Text(verbatim: Self.clock(end.addingTimeInterval(-duration / 2)))
|
||||
Spacer()
|
||||
Text(tr("现在"))
|
||||
}
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
.padding(.leading, PercentAxis.width + DS.Space.s2)
|
||||
} else {
|
||||
TimedLineChart(points: timeline, duration: duration, end: end, height: DS.Space.s8)
|
||||
}
|
||||
HStack {
|
||||
ChartDurationPicker()
|
||||
Spacer()
|
||||
Text(verbatim: tr("峰值 \(Format.percent(timeline.map(\.value).max() ?? 0))"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
if let cpu {
|
||||
WaterlineBar(segments: [(cpu.user, DS.Palette.primary), (cpu.system, DS.Palette.secondary)],
|
||||
total: 1, height: DS.Space.s2)
|
||||
@@ -146,6 +278,12 @@ private struct CPUHero: View {
|
||||
total >= 0.85 ? tr("满载") : total >= 0.6 ? tr("繁忙") : total >= 0.25 ? tr("适中") : tr("空闲")
|
||||
}
|
||||
|
||||
/// 离 100°C 还差多少度;华氏制下按温差换算(差 45°C 即差 81°F)
|
||||
private func headroom(_ celsius: Double, fahrenheit: Bool) -> String {
|
||||
let degrees = max(0, 100 - celsius)
|
||||
return fahrenheit ? "\(Int((degrees * 9 / 5).rounded()))°F" : "\(Int(degrees.rounded()))°C"
|
||||
}
|
||||
|
||||
/// 与大约 30 秒前(第 15 个采样之前)相比的变化
|
||||
private func trend(_ history: [Double]) -> String {
|
||||
guard history.count > 15, let now = history.last else { return tr("正在收集走势…") }
|
||||
@@ -154,6 +292,63 @@ private struct CPUHero: View {
|
||||
if abs(delta) < 3 { return tr("与 30 秒前持平") }
|
||||
return delta > 0 ? tr("比 30 秒前高 \(delta)%") : tr("比 30 秒前低 \(-delta)%")
|
||||
}
|
||||
|
||||
/// 时间轴刻度:走势只有几分钟,带秒才分得开;小时固定两位,零点前后宽度不变
|
||||
private static func clock(_ date: Date) -> String {
|
||||
date.formatted(Date.FormatStyle(locale: L10n.locale).hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits))
|
||||
}
|
||||
}
|
||||
|
||||
/// 走势时长 1 / 3 / 5 分钟:一行小字标签,选中的用品牌蓝加粗,不另占高度
|
||||
private struct ChartDurationPicker: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let settings = model.settings
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
ForEach(AppSettings.cpuChartOptions, id: \.self) { seconds in
|
||||
let selected = settings.cpuChartSeconds == seconds
|
||||
Button {
|
||||
settings.cpuChartSeconds = seconds
|
||||
} label: {
|
||||
Text(verbatim: tr("\(seconds / 60) 分钟"))
|
||||
.dsFont(.xs, weight: selected ? .semibold : .regular)
|
||||
.foregroundStyle(selected ? DS.Palette.primary : DS.Palette.textTertiary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.help(tr("走势图显示最近多长时间"))
|
||||
.accessibilityLabel(tr("走势时长"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 走势图左侧的 0–100% 刻度,位置与参考线对齐;最上、最下两个标签收进图表范围内,不压到相邻的行
|
||||
private struct PercentAxis: View {
|
||||
static let width = DS.Space.s8 + DS.Space.s2
|
||||
let height: CGFloat
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let inset = DS.Size.chartLine
|
||||
let usable = proxy.size.height - inset * 2
|
||||
let half = DS.TextSize.xs.rawValue / 2 + DS.Size.stroke
|
||||
ForEach([1.0, 0.75, 0.5, 0.25, 0.0], id: \.self) { fraction in
|
||||
Text(verbatim: "\(Int(fraction * 100))%")
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
.frame(width: Self.width, alignment: .trailing)
|
||||
.position(x: Self.width / 2, y: min(max(inset + usable * CGFloat(1 - fraction), half), proxy.size.height - half))
|
||||
}
|
||||
}
|
||||
.frame(width: Self.width, height: height)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 内存
|
||||
@@ -342,7 +537,7 @@ private struct AppUsageList: View {
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
ProgressTrack(fraction: peak > 0 ? value / peak : 0, color: DS.Palette.primary, height: DS.Space.s1)
|
||||
ProgressTrack(fraction: peak > 0 ? value / peak : 0, color: DS.Palette.primary, height: DS.Space.s1, showsTrack: false)
|
||||
.padding(.leading, DS.Size.iconInline + DS.Space.s2)
|
||||
}
|
||||
.explainable(.init(app.representative))
|
||||
@@ -364,12 +559,21 @@ private struct AppUsageList: View {
|
||||
private struct ShareRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
/// 跟在数值后面的补充读数(如频率),用次要颜色,不抢主数值
|
||||
var detail: String?
|
||||
let fraction: Double
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: DS.Space.s1) {
|
||||
InfoRow(label: label, text: value)
|
||||
InfoRow(label: label) {
|
||||
if let detail {
|
||||
Text(verbatim: value)
|
||||
+ Text(verbatim: " · \(detail)").foregroundStyle(DS.Palette.textSecondary).fontWeight(.regular)
|
||||
} else {
|
||||
Text(verbatim: value)
|
||||
}
|
||||
}
|
||||
ProgressTrack(fraction: fraction, color: color, height: DS.Space.s1 + DS.Space.s1 / 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ struct NetworkPopover: View {
|
||||
case .networkProbe: ProbeSection()
|
||||
case .networkInterface: InterfaceSection()
|
||||
case .networkAddresses: AddressSection()
|
||||
case .networkPurity: PuritySection()
|
||||
case .networkDNS: DNSSection()
|
||||
case .networkProcesses: NetworkProcessesSection()
|
||||
default: EmptyView()
|
||||
@@ -143,11 +144,28 @@ private struct InterfaceSection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let details = model.network.details
|
||||
let network = model.network
|
||||
let details = network.details
|
||||
let physical = details?.physical
|
||||
let totals = model.store.network
|
||||
|
||||
SectionCard(title: PopoverSection.networkInterface.title) {
|
||||
SectionCard(title: PopoverSection.networkInterface.title, trailing: {
|
||||
// 右上角重置:把上传 / 下载从现在起重新累计,标出起算时间
|
||||
if let totals {
|
||||
if let since = network.trafficBaseline?.date {
|
||||
Text(verbatim: tr("自 \(since.formatted(Date.FormatStyle(locale: L10n.locale).month().day().hour().minute())) 起"))
|
||||
}
|
||||
MiniIconButton(systemName: "arrow.counterclockwise",
|
||||
help: tr("重置上传与下载统计:从现在起重新累计。重启后自动回到开机后的累计;右键可改回")) {
|
||||
network.resetTraffic(totals)
|
||||
}
|
||||
.contextMenu {
|
||||
if network.trafficBaseline != nil {
|
||||
Button(tr("改回开机后累计")) { network.clearTrafficBaseline() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {
|
||||
if let physical {
|
||||
InfoRow(label: tr("接口"), text: tr("\(physical.displayName)(\(physical.bsdName))"))
|
||||
InfoRow(label: tr("状态")) {
|
||||
@@ -166,8 +184,10 @@ private struct InterfaceSection: View {
|
||||
InfoRow(label: tr("VPN / 代理"), text: tr("\(tunnel.name)(\(tunnel.bsdName))"))
|
||||
}
|
||||
if let totals {
|
||||
InfoRow(label: tr("开机后下载"), text: Format.bytes(totals.totalDownloaded, base: .decimal))
|
||||
InfoRow(label: tr("开机后上传"), text: Format.bytes(totals.totalUploaded, base: .decimal))
|
||||
let counted = network.trafficTotals(totals)
|
||||
let sinceReset = network.trafficBaseline != nil
|
||||
InfoRow(label: sinceReset ? tr("重置后下载") : tr("开机后下载"), text: Format.bytes(counted.download, base: .decimal))
|
||||
InfoRow(label: sinceReset ? tr("重置后上传") : tr("开机后上传"), text: Format.bytes(counted.upload, base: .decimal))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,14 +208,13 @@ private struct AddressSection: View {
|
||||
let lookup = model.settings.publicIPLookup
|
||||
let publicAddresses = network.publicAddresses
|
||||
|
||||
SectionCard(title: PopoverSection.networkAddresses.title, trailing: {
|
||||
if lookup {
|
||||
MiniIconButton(systemName: "arrow.clockwise", help: tr("重新查询公网 IP")) {
|
||||
network.lookUpPublicAddresses()
|
||||
}
|
||||
.disabled(network.isLookingUpPublic)
|
||||
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
|
||||
// 标题、数据来源字标、IPv4 / IPv6 切换、刷新排在一行;320 宽放不下时字标依次缩小,卡片不会被撑宽
|
||||
ViewThatFits(in: .horizontal) {
|
||||
header(publicAddresses, logoHeight: DS.TextSize.sm.rawValue)
|
||||
header(publicAddresses, logoHeight: DS.TextSize.xs.rawValue)
|
||||
header(publicAddresses, logoHeight: DS.TextSize.xs.rawValue - DS.Space.s1 / 2)
|
||||
}
|
||||
}) {
|
||||
InfoRow(label: tr("本地 IPv4")) { CopyableText(text: physical?.ipv4.first ?? "—") }
|
||||
InfoRow(label: tr("本地 IPv6")) { CopyableText(text: physical?.ipv6.first ?? "—") }
|
||||
InfoRow(label: tr("路由器")) { CopyableText(text: physical?.router ?? "—") }
|
||||
@@ -218,8 +237,14 @@ private struct AddressSection: View {
|
||||
if let organization = publicAddresses?.organization {
|
||||
InfoRow(label: tr("网络运营方")) { Text(verbatim: organization).lineLimit(1).truncationMode(.middle) }
|
||||
}
|
||||
if let publicAddresses, publicAddresses.countryCode != nil || publicAddresses.asn != nil {
|
||||
InfoRow(label: tr("数据来源"), text: publicAddresses.source == .localDatabase ? tr("本地 GeoLite2(MaxMind)") : tr("ipinfo.io 在线查询"))
|
||||
// 原生 / 广播、IP 类型、网络类型做成徽章,一眼看结论;反向解析、住宅概率等细节不在弹窗里占行
|
||||
if let publicAddresses, !Self.badges(publicAddresses).isEmpty {
|
||||
FlowLayout(spacing: DS.Space.s2) {
|
||||
ForEach(Self.badges(publicAddresses), id: \.text) { badge in
|
||||
TagBadge(icon: badge.icon, text: badge.text, tone: badge.tone)
|
||||
}
|
||||
}
|
||||
.padding(.top, DS.Space.s1)
|
||||
}
|
||||
} else {
|
||||
InfoRow(label: tr("公网 IP"), text: tr("查询已关闭"))
|
||||
@@ -236,9 +261,249 @@ private struct AddressSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 国家 · 省 / 州 · 城市;英文界面优先用英文地名,重复的相邻项只留一个
|
||||
private func location(_ addresses: PublicAddresses, code: String) -> String {
|
||||
let country = Locale(identifier: L10n.isEnglish ? "en" : "zh-Hans").localizedString(forRegionCode: code) ?? code
|
||||
return [country, addresses.city].compactMap { $0 }.joined(separator: " · ")
|
||||
let region = L10n.isEnglish ? (addresses.regionEnglish ?? addresses.region) : addresses.region
|
||||
let city = L10n.isEnglish ? (addresses.cityEnglish ?? addresses.city) : addresses.city
|
||||
var parts: [String] = [country]
|
||||
for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 标题行:IP 地址 · 数据来源 ……… [IPv4 | IPv6] ⟳
|
||||
private func header(_ addresses: PublicAddresses?, logoHeight: CGFloat) -> some View {
|
||||
let network = model.network
|
||||
return HStack(spacing: DS.Space.s2) {
|
||||
Text(PopoverSection.networkAddresses.title)
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
if let source = sourceAccessory(addresses, logoHeight: logoHeight) {
|
||||
source
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if model.settings.publicIPLookup {
|
||||
HStack(spacing: DS.Space.s1) {
|
||||
if network.hasDualStack {
|
||||
PillSwitch(selection: Binding(get: { network.publicFamily }, set: { network.publicFamily = $0 }),
|
||||
options: IPFamily.allCases.map { ($0, $0.title) })
|
||||
}
|
||||
RefreshButton(loading: network.isLookingUpPublic, help: tr("强制刷新:忽略缓存,立即重新查询公网 IP 与归属地")) {
|
||||
network.lookUpPublicAddresses(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 标题后的数据来源:CleanIP.io 用它的字标(默认与标题文字同高,点了打开官网),其他来源写名字
|
||||
private func sourceAccessory(_ addresses: PublicAddresses?, logoHeight: CGFloat) -> AnyView? {
|
||||
guard let addresses, addresses.countryCode != nil || addresses.asn != nil else { return nil }
|
||||
switch addresses.source {
|
||||
case .online(.cleanIP):
|
||||
return AnyView(CleanIPLink(height: logoHeight, help: tr("数据来源:cleanip.io,点击打开官网")))
|
||||
case .online(let provider):
|
||||
return AnyView(Text(verbatim: provider.displayName).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).fixedSize())
|
||||
case .localDatabase:
|
||||
return AnyView(Text("GeoLite2").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).fixedSize())
|
||||
}
|
||||
}
|
||||
|
||||
struct Badge: Equatable {
|
||||
let icon: String
|
||||
let text: String
|
||||
let tone: TagBadge.Tone
|
||||
}
|
||||
|
||||
/// 结论性徽章:原生 / 广播、住宅 / 机房、ASN 的类型
|
||||
static func badges(_ addresses: PublicAddresses) -> [Badge] {
|
||||
var badges: [Badge] = []
|
||||
if let isNative = addresses.isNative {
|
||||
badges.append(isNative ? Badge(icon: "checkmark.shield.fill", text: tr("原生 IP"), tone: .success)
|
||||
: Badge(icon: "antenna.radiowaves.left.and.right", text: tr("广播 IP"), tone: .warning))
|
||||
}
|
||||
if let ipType = addresses.ipType?.lowercased() {
|
||||
switch ipType {
|
||||
case "residential ip": badges.append(Badge(icon: "house.fill", text: tr("住宅 IP"), tone: .success))
|
||||
case "datacenter ip", "hosting ip": badges.append(Badge(icon: "server.rack", text: tr("机房 IP"), tone: .warning))
|
||||
case "mobile ip": badges.append(Badge(icon: "iphone.radiowaves.left.and.right", text: tr("移动网络 IP"), tone: .success))
|
||||
case "business ip": badges.append(Badge(icon: "building.2.fill", text: tr("企业 IP"), tone: .success))
|
||||
default: badges.append(Badge(icon: "questionmark.circle", text: addresses.ipType ?? "", tone: .neutral))
|
||||
}
|
||||
}
|
||||
if let asnType = addresses.asnType {
|
||||
switch asnType {
|
||||
case "isp": badges.append(Badge(icon: "wifi.router.fill", text: "ISP", tone: .success))
|
||||
case "mobile": badges.append(Badge(icon: "antenna.radiowaves.left.and.right", text: tr("移动运营商"), tone: .success))
|
||||
case "business": badges.append(Badge(icon: "building.2.fill", text: tr("企业网络"), tone: .success))
|
||||
case "education": badges.append(Badge(icon: "graduationcap.fill", text: tr("教育网"), tone: .success))
|
||||
case "government": badges.append(Badge(icon: "building.columns.fill", text: tr("政府"), tone: .success))
|
||||
case "hosting": badges.append(Badge(icon: "server.rack", text: tr("机房"), tone: .warning))
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return badges
|
||||
}
|
||||
|
||||
static func sourceLabel(_ source: PublicAddresses.Source) -> String {
|
||||
switch source {
|
||||
case .localDatabase: tr("本地 GeoLite2(MaxMind)")
|
||||
case .online(let provider): tr("\(provider.displayName) 在线查询")
|
||||
}
|
||||
}
|
||||
|
||||
static func networkTypeLabel(_ raw: String) -> String {
|
||||
switch raw.lowercased() {
|
||||
case "residential", "isp": tr("住宅宽带")
|
||||
case "business": tr("企业")
|
||||
case "hosting", "datacenter", "data center": tr("机房")
|
||||
case "mobile", "cellular": tr("移动网络")
|
||||
case "education": tr("教育网")
|
||||
case "government": tr("政府")
|
||||
default: raw
|
||||
}
|
||||
}
|
||||
|
||||
static func ipTypeLabel(_ raw: String) -> String {
|
||||
switch raw.lowercased() {
|
||||
case "residential ip": tr("住宅 IP")
|
||||
case "datacenter ip", "hosting ip": tr("机房 IP")
|
||||
case "mobile ip": tr("移动网络 IP")
|
||||
case "business ip": tr("企业 IP")
|
||||
default: raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IP 纯净度
|
||||
|
||||
/// CleanIP.io 给出的纯净度评分与风险标记;其他数据源没有这一项
|
||||
private struct PuritySection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
/// 标题行里“查看完整报告”链接的三种长度
|
||||
enum ReportStyle { case full, short, icon }
|
||||
|
||||
var body: some View {
|
||||
let settings = model.settings
|
||||
let network = model.network
|
||||
let addresses = network.publicAddresses
|
||||
let hint = tr("纯净度看信誉、来路、邻居与网络类型四项;机房 IP 常见信誉高、来路和类型偏低。数据来自 cleanip.io")
|
||||
|
||||
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
|
||||
// 标题、地址族徽章、置信度、报告链接、刷新按钮排成一行;宽度不够时报告链接依次缩成“完整报告”、只剩图标
|
||||
ViewThatFits(in: .horizontal) {
|
||||
header(addresses, report: .full)
|
||||
header(addresses, report: .short)
|
||||
header(addresses, report: .icon)
|
||||
}
|
||||
if let purity = addresses?.purity {
|
||||
// 分数在左、cleanip.io 字标在右,色带在下占满整行
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) {
|
||||
Text(verbatim: "\(purity.score)")
|
||||
.dsFont(.xxl, weight: .semibold)
|
||||
.foregroundStyle(DS.Grade.color(for: purity.score))
|
||||
.monospacedDigit()
|
||||
Text(verbatim: purity.grade)
|
||||
.dsFont(.base, weight: .semibold)
|
||||
.foregroundStyle(DS.Grade.color(for: purity.score))
|
||||
Text(verbatim: "/ 100").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
.help(hint)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
CleanIPLink(height: DS.Size.iconInline,
|
||||
url: addresses?.reportURL ?? CleanIPLink.site,
|
||||
help: tr("评分由 cleanip.io 提供,点击查看完整报告"))
|
||||
}
|
||||
ScoreBand(score: purity.score).help(hint)
|
||||
if let risk = addresses?.risk {
|
||||
InfoRow(label: tr("风险评分")) {
|
||||
Text(verbatim: "\(risk.score)/100" + (risk.label.map { " · \(Self.riskLabel($0))" } ?? ""))
|
||||
}
|
||||
InfoRow(label: tr("风险标记")) {
|
||||
Text(verbatim: risk.flags.isEmpty ? tr("未检出") : risk.flags.map(Self.flagLabel).joined(separator: "、"))
|
||||
.foregroundStyle(risk.flags.isEmpty ? DS.Palette.success : DS.Palette.error)
|
||||
}
|
||||
}
|
||||
if let recommendation = purity.recommendation, !L10n.isEnglish {
|
||||
Text(verbatim: recommendation)
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else if !settings.publicIPLookup {
|
||||
Text(tr("查询已关闭")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
} else if settings.geoSource != .cleanIP {
|
||||
Text(tr("纯净度评分来自 CleanIP.io。在「设置 · 网络」把归属地数据源改为 CleanIP.io 后显示。"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize(horizontal: false, vertical: true)
|
||||
} else if network.isLookingUpPublic {
|
||||
Text(tr("正在查询…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
} else {
|
||||
Text(tr("暂时没有拿到评分,稍后会再试")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 标题行:IP 纯净度 · [IPv4] · [置信度 92%] · 查看完整报告 ↗ ……… ⟳
|
||||
private func header(_ addresses: PublicAddresses?, report: ReportStyle) -> some View {
|
||||
let network = model.network
|
||||
// 320 宽的弹窗里这一行很挤:标题与徽章之间只留最小间距,右侧刷新按钮自带留白
|
||||
return HStack(spacing: DS.Space.s1) {
|
||||
Text(PopoverSection.networkPurity.title)
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
HStack(spacing: DS.Space.s1) {
|
||||
if let family = network.shownFamily {
|
||||
TagBadge(text: family.title, tone: .primary, compact: true)
|
||||
}
|
||||
if let confidence = addresses?.purity?.confidence {
|
||||
TagBadge(text: tr("置信度 \(confidence)%"), tone: Self.confidenceTone(confidence), compact: true)
|
||||
}
|
||||
}
|
||||
if let url = addresses?.reportURL {
|
||||
ReportLink(url: url, style: report).padding(.leading, DS.Space.s1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
RefreshButton(loading: network.isLookingUpPublic, help: tr("强制刷新:忽略缓存,立即重新查询公网 IP 与归属地")) {
|
||||
network.lookUpPublicAddresses(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 置信度高的绿、中等的灰、低的黄
|
||||
static func confidenceTone(_ confidence: Int) -> TagBadge.Tone {
|
||||
confidence >= 80 ? .success : confidence >= 50 ? .neutral : .warning
|
||||
}
|
||||
|
||||
static func riskLabel(_ raw: String) -> String {
|
||||
switch raw.lowercased() {
|
||||
case "very clean": tr("非常干净")
|
||||
case "clean": tr("干净")
|
||||
case "low risk", "low": tr("低风险")
|
||||
case "medium risk", "medium", "moderate": tr("中等风险")
|
||||
case "high risk", "high": tr("高风险")
|
||||
case "very high risk", "critical": tr("极高风险")
|
||||
default: raw
|
||||
}
|
||||
}
|
||||
|
||||
static func flagLabel(_ flag: String) -> String {
|
||||
switch flag {
|
||||
case "vpn": "VPN"
|
||||
case "proxy": tr("代理")
|
||||
case "residentialProxy": tr("住宅代理")
|
||||
case "tor": "Tor"
|
||||
case "relay": tr("中继")
|
||||
case "datacenter": tr("机房")
|
||||
case "hosting": tr("托管")
|
||||
case "abuser": tr("滥用记录")
|
||||
default: flag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +613,7 @@ private struct NetworkProcessesSection: View {
|
||||
Text(tr("上传")).frame(width: DS.Size.valueColumn, alignment: .trailing)
|
||||
}
|
||||
}) {
|
||||
ProcessList(count: processes.count, rowCount: 8, emptyText: tr("正在统计各进程流量…")) { index in
|
||||
ProcessList(count: processes.count, rowCount: 5, emptyText: tr("正在统计各进程流量…")) { index in
|
||||
let process = processes[index]
|
||||
HStack(spacing: 0) {
|
||||
ProcessNameLabel(icon: AppIconCache.shared.image(bundlePath: process.appBundlePath), name: process.localizedName)
|
||||
@@ -365,3 +630,33 @@ private struct NetworkProcessesSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 纯净度标题行里的“查看完整报告”链接;`icon` 只剩一个外链图标
|
||||
private struct ReportLink: View {
|
||||
let url: URL
|
||||
let style: PuritySection.ReportStyle
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
let help = tr("在 cleanip.io 查看这个 IP 的完整报告")
|
||||
if style == .icon {
|
||||
MiniIconButton(systemName: "arrow.up.forward.square", help: help) { NSWorkspace.shared.open(url) }
|
||||
} else {
|
||||
Button { NSWorkspace.shared.open(url) } label: {
|
||||
HStack(spacing: DS.Space.s1 / 2) {
|
||||
Text(style == .full ? tr("查看完整报告") : tr("完整报告"))
|
||||
.dsFont(.xs, weight: .medium)
|
||||
Image(systemName: "arrow.up.forward.square")
|
||||
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(hovering ? DS.Palette.primaryHover : DS.Palette.primary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovering = $0 }
|
||||
.help(help)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ struct PopoverRootView: View {
|
||||
case .memory: MemoryPopover()
|
||||
case .network: NetworkPopover()
|
||||
case .gpu: GPUPopover()
|
||||
case .disk: DiskPopover()
|
||||
case .temperature, .fan: ThermalPopover(item: item)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +52,9 @@ private struct PopoverHeader: View {
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
if item == .memory { PurgeMemoryButton() }
|
||||
IconButton(systemName: "macwindow", help: tr("打开 OpenStats 主窗口")) { model.openMainWindow(nil) }
|
||||
// 左边的按钮用该指标自己的图标,点进主窗口里它的页面;右边的齿轮进总设置
|
||||
let page = PanelTab(item: item)
|
||||
IconButton(systemName: page.symbol, help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) }
|
||||
IconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() }
|
||||
}
|
||||
}
|
||||
@@ -59,9 +62,13 @@ private struct PopoverHeader: View {
|
||||
|
||||
// MARK: - 共用组件
|
||||
|
||||
/// 弹窗里的一个区块:小标题 + 内容
|
||||
/// 弹窗里的一个区块:小标题 + 内容。
|
||||
/// 解释性文字不常驻界面:给 `hint` 后标题旁出现一个很淡的 ⓘ,鼠标悬停在 ⓘ 或卡片内容上才显示说明,看一遍就够的人不用一直看着它
|
||||
struct SectionCard<Trailing: View, Content: View>: View {
|
||||
let title: String
|
||||
var hint: String?
|
||||
/// 紧跟标题之后的小附件,例如数据来源的 logo
|
||||
var titleAccessory: AnyView?
|
||||
@ViewBuilder var trailing: Trailing
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@@ -71,6 +78,18 @@ struct SectionCard<Trailing: View, Content: View>: View {
|
||||
Text(title)
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
if let titleAccessory {
|
||||
titleAccessory
|
||||
}
|
||||
if let hint {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.system(size: DS.TextSize.xs.rawValue, weight: .medium))
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.help(hint)
|
||||
.accessibilityLabel(hint)
|
||||
}
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
trailing
|
||||
.dsFont(.xs)
|
||||
@@ -78,13 +97,22 @@ struct SectionCard<Trailing: View, Content: View>: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
content
|
||||
.help(optional: hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SectionCard where Trailing == EmptyView {
|
||||
init(title: String, @ViewBuilder content: () -> Content) {
|
||||
self.init(title: title, trailing: { EmptyView() }, content: content)
|
||||
init(title: String, hint: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.init(title: title, hint: hint, trailing: { EmptyView() }, content: content)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// 有说明时才挂悬停提示
|
||||
@ViewBuilder
|
||||
func help(optional text: String?) -> some View {
|
||||
if let text { help(text) } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +196,27 @@ struct MiniIconButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 区块标题栏里的刷新按钮:查询进行中换成系统的转圈,查完自动变回图标
|
||||
struct RefreshButton: View {
|
||||
var loading: Bool
|
||||
let help: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if loading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel(tr("正在查询…"))
|
||||
} else {
|
||||
MiniIconButton(systemName: "arrow.clockwise", help: help, action: action)
|
||||
}
|
||||
}
|
||||
.frame(width: DS.Size.segmentHeight, height: DS.Size.segmentHeight)
|
||||
.animation(DS.Motion.quick, value: loading)
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹窗顶部的大号数值
|
||||
struct HeroValue: View {
|
||||
let value: String
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 22 600 98" width="600" height="98">
|
||||
<svg x="7" y="23" width="82" height="82" viewBox="0 0 24 24"><path fill="#35A952" d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0m0 5.1953A6.8047 6.8047 0 0 1 18.8047 12 6.8047 6.8047 0 0 1 12 18.8047 6.8047 6.8047 0 0 1 5.1953 12 6.8047 6.8047 0 0 1 12 5.1953"/></svg>
|
||||
<g transform="translate(112 0)"><path fill="#35A952" d="M376.1 100V80.2H395.7V100Z M406.9 100V44.8H425.1V100ZM399.9 58V44.8H425.1V58ZM414.35 40.4Q409.4 40.4 407.05 37.85Q404.7 35.3 404.7 31.3Q404.7 27.3 407.062 24.75Q409.424 22.2 414.3 22.2Q419.3 22.2 421.6 24.75Q423.9 27.3 423.9 31.3Q423.9 35.3 421.6 37.85Q419.3 40.4 414.35 40.4Z M464 102Q456.7 102 451 99.8Q445.3 97.6 441.35 93.7Q437.4 89.8 435.35 84.7Q433.3 79.6 433.3 73.8V71.4Q433.3 65.4 435.45 60.2Q437.6 55 441.6 51.1Q445.6 47.2 451.3 45Q457 42.8 464 42.8Q471.1 42.8 476.75 45Q482.4 47.2 486.4 51.1Q490.4 55 492.55 60.2Q494.7 65.4 494.7 71.4V73.8Q494.7 79.6 492.65 84.7Q490.6 89.8 486.65 93.7Q482.7 97.6 477 99.8Q471.3 102 464 102ZM464 86.8Q468.2 86.8 470.95 85Q473.7 83.2 475.1 79.95Q476.5 76.7 476.5 72.6Q476.5 68.3 475.05 65.05Q473.6 61.8 470.8 59.9Q468 58 464 58Q460 58 457.2 59.9Q454.4 61.8 452.95 65.05Q451.5 68.3 451.5 72.6Q451.5 76.7 452.9 79.95Q454.3 83.2 457.1 85Q459.9 86.8 464 86.8Z"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 22 600 98" width="600" height="98">
|
||||
<g transform="translate(112 0)"><path fill="#000000" d="M31.8 102Q24.6 102 19.3 99.65Q14 97.3 10.45 93.3Q6.9 89.3 5.15 84.2Q3.4 79.1 3.4 73.6V71.6Q3.4 65.8 5.25 60.6Q7.1 55.4 10.7 51.4Q14.3 47.4 19.6 45.1Q24.9 42.8 31.8 42.8Q39.4 42.8 45.2 45.8Q51 48.8 54.45 54.05Q57.9 59.3 58.3 66.1H40.5Q40.2 62.8 38.05 60.5Q35.9 58.2 31.8 58.2Q28.3 58.2 26.05 60Q23.8 61.8 22.7 65Q21.6 68.2 21.6 72.6Q21.6 76.7 22.55 79.9Q23.5 83.1 25.75 84.85Q28 86.6 31.8 86.6Q34.6 86.6 36.5 85.6Q38.4 84.6 39.5 82.75Q40.6 80.9 40.9 78.4H58.7Q58.3 85.4 54.75 90.7Q51.2 96 45.3 99Q39.4 102 31.8 102Z M66.7 100V27H84.9V100ZM60.5 40.2V27H84.9V40.2Z M122.2 102Q115.2 102 109.8 99.6Q104.4 97.2 100.7 93.1Q97 89 95.05 83.9Q93.1 78.8 93.1 73.4V71.4Q93.1 65.8 95.05 60.65Q97 55.5 100.65 51.5Q104.3 47.5 109.6 45.15Q114.9 42.8 121.6 42.8Q130.5 42.8 136.7 46.8Q142.9 50.8 146.2 57.35Q149.5 63.9 149.5 71.6V77.4H100.7V67.6H139L132.7 72Q132.7 67.3 131.45 64.05Q130.2 60.8 127.75 59.1Q125.3 57.4 121.6 57.4Q118 57.4 115.35 59.05Q112.7 60.7 111.3 64.05Q109.9 67.4 109.9 72.5Q109.9 77.1 111.2 80.45Q112.5 83.8 115.2 85.6Q117.9 87.4 122.2 87.4Q126 87.4 128.45 86.1Q130.9 84.8 131.9 82.6H148.5Q147.3 88.2 143.75 92.6Q140.2 97 134.75 99.5Q129.3 102 122.2 102Z M190.5 100V83.8H187.5V66.5Q187.5 62.7 185.75 60.85Q184 59 180 59Q178.1 59 174.4 59.1Q170.7 59.2 166.6 59.4Q162.5 59.6 159.1 59.8V44.6Q161.5 44.4 164.8 44.2Q168.1 44 171.65 43.9Q175.2 43.8 178.2 43.8Q186.7 43.8 192.65 46.3Q198.6 48.8 201.75 53.8Q204.9 58.8 204.9 66.5V100ZM173 101.4Q167.2 101.4 162.7 99.3Q158.2 97.2 155.65 93.25Q153.1 89.3 153.1 83.8Q153.1 77.6 156.4 73.75Q159.7 69.9 165.5 68.1Q171.3 66.3 178.8 66.3H189.5V75.7H178.8Q175.1 75.7 173.1 77.55Q171.1 79.4 171.1 82.5Q171.1 85.5 173.1 87.35Q175.1 89.2 178.8 89.2Q181.2 89.2 183.05 88.35Q184.9 87.5 186.1 85.4Q187.3 83.3 187.5 79.6L191.7 83.6Q191.2 89.3 188.95 93.25Q186.7 97.2 182.75 99.3Q178.8 101.4 173 101.4Z M215.1 100V44.8H229.5V68.5H228.7Q228.7 59.9 230.9 54.25Q233.1 48.6 237.45 45.8Q241.8 43 248 43H248.8Q258.2 43 263.35 49.25Q268.5 55.5 268.5 68.4V100H250.3V67.9Q250.3 64.1 248.05 61.65Q245.8 59.2 241.9 59.2Q238 59.2 235.65 61.65Q233.3 64.1 233.3 68.1V100Z M281.3 100V44.8H299.5V100ZM274.3 58V44.8H299.5V58ZM288.75 40.4Q283.8 40.4 281.45 37.85Q279.1 35.3 279.1 31.3Q279.1 27.3 281.462 24.75Q283.824 22.2 288.7 22.2Q293.7 22.2 296 24.75Q298.3 27.3 298.3 31.3Q298.3 35.3 296 37.85Q293.7 40.4 288.75 40.4Z M310.9 120V44.8H325.3V68.6H323.7Q324 60.5 326.95 54.8Q329.9 49.1 335 46.15Q340.1 43.2 346.7 43.2Q352.4 43.2 357 45.25Q361.6 47.3 364.9 51.05Q368.2 54.8 369.95 59.9Q371.7 65 371.7 71.1V73.5Q371.7 79.6 370.1 84.75Q368.5 89.9 365.3 93.75Q362.1 97.6 357.45 99.7Q352.8 101.8 346.8 101.8Q340.4 101.8 335.5 99.25Q330.6 96.7 327.75 91.55Q324.9 86.4 324.7 78.6L329.1 84V120ZM341.3 86.8Q344.9 86.8 347.65 84.95Q350.4 83.1 351.95 79.8Q353.5 76.5 353.5 72.3Q353.5 68 351.95 64.85Q350.4 61.7 347.7 59.95Q345 58.2 341.3 58.2Q338 58.2 335.15 59.7Q332.3 61.2 330.5 64.1Q328.7 67 328.7 71.1V74.5Q328.7 78.4 330.6 81.15Q332.5 83.9 335.4 85.35Q338.3 86.8 341.3 86.8Z"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<path fill="#000000" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 734 B |
@@ -0,0 +1,236 @@
|
||||
import AccountSync
|
||||
import AppKit
|
||||
import Localization
|
||||
import SwiftUI
|
||||
|
||||
/// 设置 · 账号与同步:登录、同步状态、首次登录的二选一、退出与删除
|
||||
struct AccountSettings: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var confirmingDelete = false
|
||||
|
||||
var body: some View {
|
||||
let sync = model.sync
|
||||
Group {
|
||||
if let user = sync.user {
|
||||
signedIn(user: user, sync: sync)
|
||||
} else {
|
||||
signedOut(sync: sync)
|
||||
}
|
||||
}
|
||||
.onAppear { sync.pageOpened() }
|
||||
}
|
||||
|
||||
// MARK: 未登录
|
||||
|
||||
@ViewBuilder
|
||||
private func signedOut(sync: SyncController) -> some View {
|
||||
SettingsGroup(caption: tr("账号")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||||
SettingRow(title: tr("登录以同步设置"),
|
||||
subtitle: tr("换一台 Mac 登录后,菜单栏、外观、通知与快捷键等偏好会自动恢复。不登录也能使用全部功能。")) {
|
||||
EmptyView()
|
||||
}
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
ForEach(sync.providers) { provider in
|
||||
Button {
|
||||
sync.signIn(with: provider)
|
||||
} label: {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
ProviderLogo(provider: provider)
|
||||
Text(tr("使用 \(provider.title) 登录"))
|
||||
}
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(sync.phase == .signingIn)
|
||||
}
|
||||
if sync.phase == .signingIn {
|
||||
ProgressView().controlSize(.small)
|
||||
Button(tr("取消")) { sync.cancelSignIn() }.buttonStyle(DSButtonStyle(kind: .ghost))
|
||||
}
|
||||
}
|
||||
if case .failed(let message) = sync.phase {
|
||||
InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
privacyGroup
|
||||
}
|
||||
|
||||
// MARK: 已登录
|
||||
|
||||
@ViewBuilder
|
||||
private func signedIn(user: SyncUser, sync: SyncController) -> some View {
|
||||
SettingsGroup(caption: tr("账号")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
Avatar(url: user.avatar)
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
|
||||
Text(user.displayName).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(accountSubtitle(user)).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: DS.Space.s4)
|
||||
Button(tr("退出登录")) { sync.signOut() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(sync.phase == .syncing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsGroup(caption: tr("同步")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: statusTitle(sync), subtitle: statusSubtitle(sync)) {
|
||||
Button(sync.phase == .syncing ? tr("正在同步…") : tr("立即同步")) { sync.syncNow() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(sync.phase == .syncing || sync.conflict != nil)
|
||||
}
|
||||
}
|
||||
if let conflict = sync.conflict {
|
||||
GroupRow {
|
||||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||||
InfoBanner(icon: "arrow.triangle.2.circlepath",
|
||||
text: tr("云端已有 \(conflict.remoteDevice ?? tr("另一台 Mac")) 在 \(Self.relative(conflict.remoteUpdatedAt)) 保存的设置,和这台 Mac 上的不一样。要用哪一份?"),
|
||||
tone: .warning)
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
Spacer()
|
||||
Button(tr("上传本机设置")) { sync.resolveConflict(useRemote: false) }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
Button(tr("使用云端设置")) { sync.resolveConflict(useRemote: true) }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if case .failed(let message) = sync.phase {
|
||||
GroupRow { InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) }
|
||||
}
|
||||
GroupRow {
|
||||
Text(tr("同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具、历史记录与归属地库留在本机。"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsGroup(caption: tr("数据")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: tr("删除云端数据"), subtitle: tr("删除账号、所有登录方式与云端保存的设置;这台 Mac 上的设置保留")) {
|
||||
Button(tr("删除…")) { confirmingDelete = true }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(sync.phase == .syncing)
|
||||
.confirmationDialog(tr("删除云端数据?"), isPresented: $confirmingDelete, titleVisibility: .visible) {
|
||||
Button(tr("删除"), role: .destructive) { sync.deleteAccount() }
|
||||
Button(tr("取消"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(tr("账号与云端设置会立即删除,无法恢复。"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
privacyGroup
|
||||
}
|
||||
|
||||
private var privacyGroup: some View {
|
||||
SettingsGroup(caption: tr("隐私")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: tr("云端只保存邮箱、姓名与设置文档"),
|
||||
subtitle: tr("服务器在 getopenstats.com,不上传任何监控数据;随时可以删除。")) {
|
||||
Button(tr("隐私政策")) {
|
||||
if let url = URL(string: "https://getopenstats.com/privacy.html") { NSWorkspace.shared.open(url) }
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .ghost))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 文案
|
||||
|
||||
private func accountSubtitle(_ user: SyncUser) -> String {
|
||||
let providers = user.providers.compactMap(SyncProvider.init(rawValue:)).map(\.title).joined(separator: " / ")
|
||||
let via = providers.isEmpty ? "" : tr("通过 \(providers) 登录")
|
||||
guard let email = user.email, !email.isEmpty, email != user.displayName else { return via }
|
||||
return via.isEmpty ? email : "\(email) · \(via)"
|
||||
}
|
||||
|
||||
private func statusTitle(_ sync: SyncController) -> String {
|
||||
switch sync.phase {
|
||||
case .syncing: tr("正在同步")
|
||||
case .failed: tr("同步失败")
|
||||
default: sync.conflict != nil ? tr("等待选择") : tr("已开启同步")
|
||||
}
|
||||
}
|
||||
|
||||
private func statusSubtitle(_ sync: SyncController) -> String {
|
||||
guard let date = sync.lastSyncedAt else { return tr("设置改动后 2 秒内上传;启动、唤醒与每 15 分钟检查一次云端") }
|
||||
return tr("上次同步:\(Self.relative(date))")
|
||||
}
|
||||
|
||||
private static func relative(_ date: Date) -> String {
|
||||
date.formatted(.relative(presentation: .named).locale(L10n.locale))
|
||||
}
|
||||
}
|
||||
|
||||
/// 头像:圆形,取不到时用系统人像图标
|
||||
private struct Avatar: View {
|
||||
let url: URL?
|
||||
|
||||
var body: some View {
|
||||
AsyncImage(url: url) { phase in
|
||||
if let image = phase.image {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.font(.system(size: DS.Space.s6))
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.frame(width: DS.Space.s8, height: DS.Space.s8)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录按钮上的品牌标志,16 pt 行内尺寸。Google 用四色 G;Apple 与 GitHub 的标志本身只有单色版本,
|
||||
/// 按品牌规范用文字色绘制,浅色模式下为黑、深色模式下为白
|
||||
struct ProviderLogo: View {
|
||||
let provider: SyncProvider
|
||||
var size: CGFloat = DS.Size.iconInline
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch provider {
|
||||
case .apple:
|
||||
Image(systemName: "apple.logo")
|
||||
.font(.system(size: size - DS.Space.s1 / 2, weight: .medium))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
case .github:
|
||||
if let image = LogoCache.shared.image(named: "github", template: true) {
|
||||
Image(nsImage: image).resizable().scaledToFit().foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
case .google:
|
||||
if let image = LogoCache.shared.image(named: "google", template: false) {
|
||||
Image(nsImage: image).resizable().scaledToFit()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 包内 Resources/Logos 下的 SVG:GitHub 标志来自 Octicons(MIT),Google 标志为其品牌资源
|
||||
@MainActor
|
||||
final class LogoCache {
|
||||
static let shared = LogoCache()
|
||||
private var images: [String: NSImage] = [:]
|
||||
|
||||
func image(named name: String, template: Bool) -> NSImage? {
|
||||
if let image = images[name] { return image }
|
||||
guard let url = Bundle.module.url(forResource: name, withExtension: "svg", subdirectory: "Logos"),
|
||||
let image = NSImage(contentsOf: url) else { return nil }
|
||||
image.isTemplate = template
|
||||
images[name] = image
|
||||
return image
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,15 @@ struct NotificationSettings: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == .cpuLoad, settings.enabledAlerts.contains(.cpuLoad) {
|
||||
GroupRow {
|
||||
SettingRow(title: tr("占用高于")) {
|
||||
SegmentedControl(selection: $settings.alertCPULoad,
|
||||
options: AppSettings.alertLoadOptions.map { ($0, "\($0)%") })
|
||||
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +290,7 @@ struct MenuBarSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsGroup(caption: tr("风格")) {
|
||||
SettingsGroup(caption: tr("整体风格")) {
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible(), spacing: DS.Space.s3)],
|
||||
spacing: DS.Space.s3) {
|
||||
ForEach(MenuBarStyle.allCases) { style in
|
||||
@@ -291,24 +300,33 @@ struct MenuBarSettings: View {
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.s3)
|
||||
HairlineDivider()
|
||||
Text(tr("整体风格套用到所有项目。想让某个项目不一样,在下面“显示项目”里给它单独选一种,比如 CPU 用圆环、风扇用数字。"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, DS.Space.s4)
|
||||
.padding(.vertical, DS.Space.s3)
|
||||
}
|
||||
|
||||
SettingsGroup(caption: tr("显示项目")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
InfoBanner(icon: "hand.draw",
|
||||
text: tr("菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。"))
|
||||
}
|
||||
ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in
|
||||
GroupRow(showsDivider: index > 0) {
|
||||
GroupRow {
|
||||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||||
SettingRow(title: item.title, subtitle: item.subtitle, icon: item.symbol) {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
if settings.isEnabled(item) {
|
||||
itemStylePicker(item)
|
||||
}
|
||||
DSToggle(isOn: Binding(get: { settings.isEnabled(item) },
|
||||
set: { settings.setEnabled(item, $0) }),
|
||||
label: item.title)
|
||||
}
|
||||
DSToggle(isOn: Binding(get: { settings.isEnabled(item) },
|
||||
set: { settings.setEnabled(item, $0) }),
|
||||
label: item.title)
|
||||
}
|
||||
if settings.isEnabled(item), settings.menuBarLayout == .separate {
|
||||
PopoverSectionPicker(item: item)
|
||||
if settings.isEnabled(item) {
|
||||
ItemStyleRow(item: item)
|
||||
if settings.menuBarLayout == .separate {
|
||||
PopoverSectionPicker(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,27 +342,56 @@ struct MenuBarSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 网速有自己的三种样式;其他指标可以单独指定风格,默认跟随整体
|
||||
@ViewBuilder
|
||||
private func itemStylePicker(_ item: MenuBarItem) -> some View {
|
||||
}
|
||||
|
||||
/// 单个项目自己的菜单栏风格:网速有自己的三种样式;其他项目默认跟随整体,也可以单独选一种。
|
||||
/// 旁边用实时数据画出这一项现在在菜单栏里的样子,改了立刻能看到
|
||||
private struct ItemStyleRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let item: MenuBarItem
|
||||
|
||||
var body: some View {
|
||||
@Bindable var settings = model.settings
|
||||
if item == .network {
|
||||
SegmentedControl(selection: $settings.networkStyle,
|
||||
options: NetworkMenuStyle.allCases.map { ($0, $0.title) })
|
||||
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
|
||||
} else {
|
||||
Picker(tr("风格"), selection: Binding(get: { settings.styleOverrides[item] },
|
||||
set: { settings.setStyleOverride($0, for: item) })) {
|
||||
Text(tr("跟随整体")).tag(MenuBarStyle?.none)
|
||||
Divider()
|
||||
ForEach(MenuBarStyle.allCases) { style in
|
||||
Text(style.title).tag(MenuBarStyle?.some(style))
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
Text(tr("菜单栏风格"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize()
|
||||
if item == .network {
|
||||
SegmentedControl(selection: $settings.networkStyle,
|
||||
options: NetworkMenuStyle.allCases.map { ($0, $0.title) })
|
||||
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
|
||||
} else {
|
||||
Picker(tr("菜单栏风格"), selection: Binding(get: { settings.styleOverrides[item] },
|
||||
set: { settings.setStyleOverride($0, for: item) })) {
|
||||
Text(tr("跟随整体(\(settings.menuBarStyle.title))")).tag(MenuBarStyle?.none)
|
||||
Divider()
|
||||
ForEach(MenuBarStyle.options(for: item)) { style in
|
||||
Text(style.title).tag(MenuBarStyle?.some(style))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
preview
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.leading, DS.Size.iconStandalone + DS.Space.s3)
|
||||
}
|
||||
|
||||
/// 这一项按当前风格、实时读数画出来的样子,底色跟随浅色 / 深色外观
|
||||
private var preview: some View {
|
||||
let settings = model.settings
|
||||
let image = MenuBarRenderer.image(reading: MenuBarReading(model: model), items: [item],
|
||||
style: { settings.style(for: $0) }, networkStyle: settings.networkStyle,
|
||||
colorizeHighLoad: settings.colorizeHighLoad, fahrenheit: settings.useFahrenheit)
|
||||
return Image(nsImage: MenuBarRenderer.preview(image, dark: colorScheme == .dark))
|
||||
.padding(.horizontal, DS.Space.s3)
|
||||
.frame(height: DS.Size.segmentHeight + DS.Space.s1)
|
||||
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||||
.accessibilityLabel(tr("该项目在菜单栏里的实时效果"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,12 +576,23 @@ struct NetworkSettings: View {
|
||||
SettingsGroup(caption: tr("公网 IP")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: tr("查询公网 IP"),
|
||||
subtitle: model.geo.isAvailable
|
||||
? tr("打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求")
|
||||
: tr("打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询")) {
|
||||
subtitle: tr("打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求")) {
|
||||
DSToggle(isOn: $settings.publicIPLookup, label: tr("查询公网 IP"))
|
||||
}
|
||||
}
|
||||
GroupRow {
|
||||
SettingRow(title: tr("归属地数据源"), subtitle: settings.geoSource.detail) {
|
||||
Picker(tr("归属地数据源"), selection: $settings.geoSource) {
|
||||
ForEach(GeoSource.allCases) { source in
|
||||
Text(source.title).tag(source)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
.disabled(!settings.publicIPLookup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GeoDatabaseSettings()
|
||||
|
||||
@@ -58,7 +58,7 @@ struct ReleaseSummary: View {
|
||||
if let notesHeight {
|
||||
ViewThatFits(in: .vertical) {
|
||||
list.padding(DS.Space.s3)
|
||||
ScrollView { list.padding(DS.Space.s3) }.overlayScrollers()
|
||||
ScrollView { list.padding(DS.Space.s3).overlayScrollers() }
|
||||
}
|
||||
.frame(maxHeight: notesHeight)
|
||||
.background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
||||
|
||||
@@ -13,7 +13,7 @@ enum SnapshotRenderer {
|
||||
|
||||
let defaults = UserDefaults(suiteName: "OpenStats.snapshot") ?? .standard
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
settings.menuBarItems = [.cpu, .gpu, .memory, .network, .temperature]
|
||||
settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature]
|
||||
settings.probeSeconds = 1
|
||||
// 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档
|
||||
let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("openstats-snapshot-history.sqlite")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// 排行榜的平滑打分:给每个条目按指数移动平均记一个分数,列表按分数排序。
|
||||
/// 瞬时速率每秒都在变,直接按它排会让条目忽隐忽现、上下乱跳;按最近十几秒的平均排,
|
||||
/// 一个应用读写完之后还会在榜上停留一会儿慢慢退下去,列表看起来是稳的
|
||||
struct ActivityRanking: Equatable {
|
||||
struct Entry: Equatable {
|
||||
let id: String
|
||||
let score: Double
|
||||
}
|
||||
|
||||
private(set) var scores: [String: Double] = [:]
|
||||
/// 每次采样保留的旧分数比例;0.75 时约 3 次采样后旧值影响减半
|
||||
var decay = 0.75
|
||||
/// 分数低于这个值且当前没有活动的条目移出榜单(字节 / 秒)
|
||||
var floor: Double = 512
|
||||
|
||||
/// 用本次采样的瞬时值更新分数;没出现在本次采样里的条目按 0 衰减
|
||||
mutating func update(_ current: [String: Double]) {
|
||||
var next: [String: Double] = [:]
|
||||
for id in Set(scores.keys).union(current.keys) {
|
||||
let value = current[id] ?? 0
|
||||
let score = (scores[id] ?? 0) * decay + value * (1 - decay)
|
||||
if score >= floor || value > 0 { next[id] = score }
|
||||
}
|
||||
scores = next
|
||||
}
|
||||
|
||||
func ranked(limit: Int) -> [Entry] {
|
||||
Array(scores.map { Entry(id: $0.key, score: $0.value) }
|
||||
.sorted { $0.score == $1.score ? $0.id < $1.id : $0.score > $1.score }
|
||||
.prefix(limit))
|
||||
}
|
||||
|
||||
var isEmpty: Bool { scores.isEmpty }
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import UserNotifications
|
||||
|
||||
/// 可以发系统通知的状况
|
||||
public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
case cpuTemperature, memoryPressure, diskSpace, networkDown, batteryHealth, bluetoothBattery
|
||||
case cpuTemperature, cpuLoad, memoryPressure, diskSpace, networkDown, batteryHealth, bluetoothBattery
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .cpuTemperature: tr("CPU 过热")
|
||||
case .cpuLoad: tr("CPU 持续高负载")
|
||||
case .memoryPressure: tr("内存压力严重")
|
||||
case .diskSpace: tr("磁盘空间不足")
|
||||
case .networkDown: tr("网络断开")
|
||||
@@ -26,6 +27,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .cpuTemperature: tr("CPU 温度持续 1 分钟高于设定值")
|
||||
case .cpuLoad: tr("CPU 总占用持续 1 分钟高于设定值,通常是某个应用卡住或在后台狂跑")
|
||||
case .memoryPressure: tr("内存压力持续 30 秒处于严重,系统开始大量换页")
|
||||
case .diskSpace: tr("启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次")
|
||||
case .networkDown: tr("网络连接中断超过 20 秒,恢复后再提示一次")
|
||||
@@ -37,6 +39,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .cpuTemperature: "thermometer.high"
|
||||
case .cpuLoad: "cpu"
|
||||
case .memoryPressure: "memorychip"
|
||||
case .diskSpace: "internaldrive"
|
||||
case .networkDown: "wifi.slash"
|
||||
@@ -49,6 +52,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
var tab: PanelTab {
|
||||
switch self {
|
||||
case .cpuTemperature: .thermal
|
||||
case .cpuLoad: .cpu
|
||||
case .memoryPressure: .memory
|
||||
case .diskSpace: .disk
|
||||
case .networkDown: .network
|
||||
@@ -59,7 +63,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
/// 状况持续多久才提醒
|
||||
var sustain: TimeInterval {
|
||||
switch self {
|
||||
case .cpuTemperature: 60
|
||||
case .cpuTemperature, .cpuLoad: 60
|
||||
case .memoryPressure: 30
|
||||
case .networkDown: 20
|
||||
case .diskSpace, .batteryHealth, .bluetoothBattery: 0
|
||||
@@ -69,7 +73,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
/// 同一状况两次提醒的最短间隔
|
||||
var cooldown: TimeInterval {
|
||||
switch self {
|
||||
case .cpuTemperature, .memoryPressure, .networkDown: 30 * 60
|
||||
case .cpuTemperature, .cpuLoad, .memoryPressure, .networkDown: 30 * 60
|
||||
case .diskSpace, .bluetoothBattery: 6 * 60 * 60
|
||||
case .batteryHealth: 30 * 24 * 60 * 60
|
||||
}
|
||||
@@ -179,6 +183,12 @@ public final class AlertController: NSObject {
|
||||
fireIfNeeded(.cpuTemperature, isActive: cpu >= limit, now: now,
|
||||
body: tr("CPU 温度 \(Format.temperature(cpu, fahrenheit: settings.useFahrenheit)),已持续超过 1 分钟。可以检查高占用进程,或在“温度与风扇”里提高风扇转速。"))
|
||||
}
|
||||
if enabled.contains(.cpuLoad), let cpu = store.cpu {
|
||||
// 只在菜单栏显示时进程列表可能是很久以前采的,不在通知里点名应用,让用户打开详情看实时的
|
||||
let limit = Double(settings.alertCPULoad) / 100
|
||||
fireIfNeeded(.cpuLoad, isActive: cpu.total >= limit, now: now,
|
||||
body: tr("CPU 占用 \(Format.percent(cpu.total)),已持续超过 1 分钟。可以打开 CPU 详情,在“按应用汇总”里看看是哪个应用在占用。"))
|
||||
}
|
||||
if enabled.contains(.memoryPressure), let memory = store.memory {
|
||||
fireIfNeeded(.memoryPressure, isActive: memory.pressure == .critical, now: now,
|
||||
body: tr("可用内存只剩 \(Format.bytes(memory.available)),系统正在压缩和交换内存。关闭不用的应用可以缓解。"))
|
||||
|
||||
@@ -25,6 +25,8 @@ public final class AppModel {
|
||||
public let uninstaller = UninstallerController()
|
||||
let startupItems = StartupItemsController()
|
||||
public let history: HistoryRecorder
|
||||
public let sync: SyncController
|
||||
let diskTools: DiskToolsController
|
||||
@ObservationIgnored public let hub = MetricsHub()
|
||||
|
||||
public var isMainWindowVisible = false
|
||||
@@ -54,6 +56,8 @@ public final class AppModel {
|
||||
updates = UpdateController(settings: settings)
|
||||
alerts = AlertController(settings: settings)
|
||||
history = HistoryRecorder(settings: settings, databaseURL: historyURL)
|
||||
sync = SyncController(settings: settings)
|
||||
diskTools = DiskToolsController(helper: helper)
|
||||
}
|
||||
|
||||
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
|
||||
@@ -72,10 +76,11 @@ public final class AppModel {
|
||||
demand.network = true
|
||||
let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item }
|
||||
demand.gpu = (window && [.overview, .system].contains(tab)) || menu.contains(.gpu) || showing(.gpu, .gpu)
|
||||
demand.disk = window && [.overview, .system, .cleaner, .disk].contains(tab)
|
||||
demand.diskDetail = window && tab == .disk
|
||||
demand.disk = (window && [.overview, .system, .cleaner, .disk].contains(tab)) || menu.contains(.disk) || popover == .disk
|
||||
demand.diskDetail = showing(.disk, .disk)
|
||||
demand.battery = (window && [.overview, .system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive
|
||||
demand.processes = (window && [.processes, .overview, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory)
|
||||
|| popover == .disk
|
||||
demand.systemProcesses = window && tab == .processes
|
||||
|
||||
var groups = Set<TemperatureGroup>()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Foundation
|
||||
import Localization
|
||||
import Metrics
|
||||
import Observation
|
||||
|
||||
public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
case cpu, memory, network, gpu, temperature, fan
|
||||
case cpu, memory, network, gpu, disk, temperature, fan
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
@@ -13,6 +14,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
case .memory: tr("内存")
|
||||
case .network: tr("网络")
|
||||
case .gpu: "GPU"
|
||||
case .disk: tr("磁盘")
|
||||
case .temperature: tr("CPU 温度")
|
||||
case .fan: tr("风扇转速")
|
||||
}
|
||||
@@ -24,6 +26,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
case .memory: tr("已用内存占比")
|
||||
case .network: tr("上传与下载速度、IP 地址、DNS")
|
||||
case .gpu: tr("GPU 占用")
|
||||
case .disk: tr("启动磁盘已用占比")
|
||||
case .temperature: tr("CPU 核心最高温度")
|
||||
case .fan: tr("转速最高的风扇")
|
||||
}
|
||||
@@ -36,6 +39,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
case .memory: "RAM"
|
||||
case .network: "NET"
|
||||
case .gpu: "GPU"
|
||||
case .disk: "DISK"
|
||||
case .temperature: "TEMP"
|
||||
case .fan: "FAN"
|
||||
}
|
||||
@@ -48,6 +52,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
case .memory: tr("内存")
|
||||
case .network: tr("网络")
|
||||
case .gpu: "GPU"
|
||||
case .disk: tr("磁盘")
|
||||
case .temperature: tr("温度")
|
||||
case .fan: tr("风扇")
|
||||
}
|
||||
@@ -56,10 +61,11 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
/// 点击该项弹出的详情里可以显示的内容,按显示顺序排列
|
||||
var popoverSections: [PopoverSection] {
|
||||
switch self {
|
||||
case .cpu: [.cpuHeatmap, .cpuClusters, .cpuLoadAverage, .cpuApps]
|
||||
case .cpu: [.cpuCores, .cpuHeatmap, .cpuClusters, .cpuLoadAverage, .cpuApps]
|
||||
case .memory: [.memoryWaterline, .memoryCompression, .memoryApps]
|
||||
case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses]
|
||||
case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkPurity, .networkDNS, .networkProcesses]
|
||||
case .gpu: [.gpuHistory, .gpuDetails]
|
||||
case .disk: [.diskActivity, .diskHealth, .diskProcesses]
|
||||
case .temperature: [.thermalSensors, .thermalFans, .thermalPower]
|
||||
case .fan: [.thermalFans, .thermalSensors, .thermalPower]
|
||||
}
|
||||
@@ -74,6 +80,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
case .memory: "memorychip"
|
||||
case .network: "arrow.up.arrow.down"
|
||||
case .gpu: "square.3.layers.3d"
|
||||
case .disk: "internaldrive"
|
||||
case .temperature: "thermometer.medium"
|
||||
case .fan: "fan"
|
||||
}
|
||||
@@ -82,17 +89,23 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
|
||||
/// 详情弹窗里可以单独隐藏的区块
|
||||
public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
|
||||
case cpuHeatmap, cpuClusters, cpuLoadAverage, cpuApps
|
||||
case cpuHeatmap, cpuCores, cpuClusters, cpuLoadAverage, cpuApps
|
||||
case memoryWaterline, memoryCompression, memoryApps
|
||||
case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses
|
||||
case networkHistory, networkProbe, networkInterface, networkAddresses, networkPurity, networkDNS, networkProcesses
|
||||
case gpuHistory, gpuDetails
|
||||
case diskActivity, diskHealth, diskProcesses
|
||||
case thermalSensors, thermalFans, thermalPower
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
/// 弹窗默认不显示的区块(主窗口详情页始终显示):热力图已经能看出各核心此刻的占用,
|
||||
/// 每核柱状图给想要一眼看清单个核心的人在设置里打开,避免弹窗默认太长
|
||||
static let hiddenByDefault: Set<PopoverSection> = [.cpuCores]
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .cpuHeatmap: tr("核心热力图")
|
||||
case .cpuCores: tr("各核心占用")
|
||||
case .cpuClusters: tr("核心分工")
|
||||
case .cpuLoadAverage: tr("排队程度")
|
||||
case .cpuApps, .memoryApps: tr("按应用汇总")
|
||||
@@ -103,9 +116,13 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
|
||||
case .networkProbe: tr("连接探测")
|
||||
case .networkInterface: tr("接口")
|
||||
case .networkAddresses: tr("IP 地址")
|
||||
case .networkPurity: tr("IP 纯净度")
|
||||
case .networkDNS: "DNS"
|
||||
case .gpuHistory: tr("使用历史")
|
||||
case .gpuDetails: tr("显卡信息")
|
||||
case .diskActivity: tr("读写速度")
|
||||
case .diskHealth: tr("SSD 健康")
|
||||
case .diskProcesses: tr("读写最多的应用")
|
||||
case .thermalSensors: tr("温度")
|
||||
case .thermalFans: tr("风扇")
|
||||
case .thermalPower: tr("功耗")
|
||||
@@ -158,12 +175,58 @@ public enum ProbeTarget: String, CaseIterable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 菜单栏风格:一种风格统一套用到所有指标,风格内部的排版保持一致
|
||||
public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
|
||||
case stacked, inline, icon, ring, pie, history, meter, dot
|
||||
/// 公网 IP 归属地的数据源
|
||||
public enum GeoSource: String, CaseIterable, Identifiable, Sendable {
|
||||
case cleanIP, ipapi, dbip, ipinfo, localDatabase
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .cleanIP: "CleanIP.io"
|
||||
case .ipapi: "ipapi.is"
|
||||
case .dbip: "DB-IP"
|
||||
case .ipinfo: "ipinfo.io"
|
||||
case .localDatabase: tr("本地 GeoLite2 数据库")
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .cleanIP: tr("归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名;把公网 IP 发给 cleanip.io")
|
||||
case .ipapi: tr("归属地与 ASN;把公网 IP 发给 ipapi.is,匿名额度每天 30 次,同一 IP 的结果记住一天")
|
||||
case .dbip: tr("只有国家、省 / 州与城市;把公网 IP 发给 db-ip.com")
|
||||
case .ipinfo: tr("归属地与 ASN;把公网 IP 发给 ipinfo.io,匿名额度很小,容易被限流")
|
||||
case .localDatabase: tr("在本机的 MaxMind 数据库里查国家、城市与 ASN,不向任何服务发送 IP;数据库未就绪时暂用 CleanIP.io")
|
||||
}
|
||||
}
|
||||
|
||||
/// 对应的在线数据源;本地数据库没有
|
||||
var provider: GeoProvider? {
|
||||
switch self {
|
||||
case .cleanIP: .cleanIP
|
||||
case .ipapi: .ipapi
|
||||
case .dbip: .dbip
|
||||
case .ipinfo: .ipinfo
|
||||
case .localDatabase: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 菜单栏风格:整体选一种套用到所有指标,个别指标可以单独指定(AppSettings.styleOverrides);风格内部的排版保持一致
|
||||
public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
|
||||
case stacked, inline, icon, ring, pie, history, line, meter, dot
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
/// 温度、风扇没有百分比,图形类风格画不出来,只给文字类的三种
|
||||
static func options(for item: MenuBarItem) -> [MenuBarStyle] {
|
||||
switch item {
|
||||
case .temperature, .fan: [.stacked, .inline, .icon]
|
||||
default: allCases
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .stacked: tr("双行文字")
|
||||
@@ -172,6 +235,7 @@ public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
|
||||
case .ring: tr("圆环")
|
||||
case .pie: tr("饼图")
|
||||
case .history: tr("柱状历史")
|
||||
case .line: tr("折线历史")
|
||||
case .meter: tr("电量条")
|
||||
case .dot: tr("状态圆点")
|
||||
}
|
||||
@@ -185,6 +249,7 @@ public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
|
||||
case .ring: tr("圆环表示当前占用比例")
|
||||
case .pie: tr("饼图表示当前占用比例")
|
||||
case .history: tr("10 根柱子是最近 10 次采样(约 20 秒)的变化")
|
||||
case .line: tr("折线是最近 30 次采样(约 1 分钟)的走势")
|
||||
case .meter: tr("竖向电量条表示当前占用比例")
|
||||
case .dot: tr("绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)")
|
||||
}
|
||||
@@ -235,13 +300,13 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
|
||||
/// 主窗口侧边栏的页面
|
||||
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
case overview, system, history, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller, startupItems
|
||||
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsHelper, settingsAbout
|
||||
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsAccount, settingsHelper, settingsAbout
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
static let monitors: [PanelTab] = [.overview, .system, .history, .cpu, .gpu, .memory, .disk, .network, .thermal]
|
||||
static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller]
|
||||
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsHelper, .settingsAbout]
|
||||
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsAccount, .settingsHelper, .settingsAbout]
|
||||
|
||||
var isSettings: Bool { Self.settings.contains(self) }
|
||||
|
||||
@@ -267,6 +332,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
case .settingsGeneral: tr("通用")
|
||||
case .settingsMenuBar: tr("菜单栏")
|
||||
case .settingsNotifications: tr("通知")
|
||||
case .settingsAccount: tr("账号与同步")
|
||||
case .settingsHelper: tr("辅助工具")
|
||||
case .settingsAbout: tr("关于")
|
||||
}
|
||||
@@ -291,6 +357,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
case .settingsGeneral: "gearshape"
|
||||
case .settingsMenuBar: "menubar.rectangle"
|
||||
case .settingsNotifications: "bell.badge"
|
||||
case .settingsAccount: "person.crop.circle"
|
||||
case .settingsHelper: "lock.shield"
|
||||
case .settingsAbout: "info.circle"
|
||||
}
|
||||
@@ -303,17 +370,27 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
case .gpu: .gpu
|
||||
case .memory: .memory
|
||||
case .network: .network
|
||||
case .disk: .disk
|
||||
case .thermal: .temperature
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面右上角可以直接开关的菜单栏项目:温度与风扇页有温度、风扇两项
|
||||
var menuBarItems: [MenuBarItem] {
|
||||
switch self {
|
||||
case .thermal: [.temperature, .fan]
|
||||
default: menuBarItem.map { [$0] } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
init(item: MenuBarItem) {
|
||||
switch item {
|
||||
case .cpu: self = .cpu
|
||||
case .gpu: self = .gpu
|
||||
case .memory: self = .memory
|
||||
case .network: self = .network
|
||||
case .disk: self = .disk
|
||||
case .temperature, .fan: self = .thermal
|
||||
}
|
||||
}
|
||||
@@ -384,6 +461,10 @@ public final class AppSettings {
|
||||
public var probeTarget: ProbeTarget {
|
||||
didSet { defaults.set(probeTarget.rawValue, forKey: Keys.probeTarget) }
|
||||
}
|
||||
/// 公网 IP 归属地的数据源
|
||||
public var geoSource: GeoSource {
|
||||
didSet { defaults.set(geoSource.rawValue, forKey: Keys.geoSource) }
|
||||
}
|
||||
/// 打开网络详情时查询公网 IP(会访问 Cloudflare / ipify)
|
||||
public var publicIPLookup: Bool {
|
||||
didSet { defaults.set(publicIPLookup, forKey: Keys.publicIPLookup) }
|
||||
@@ -404,6 +485,14 @@ public final class AppSettings {
|
||||
public var alertCPUTemperature: Int {
|
||||
didSet { defaults.set(alertCPUTemperature, forKey: Keys.alertCPUTemperature) }
|
||||
}
|
||||
/// CPU 持续高负载提醒的总占用阈值(百分比)
|
||||
public var alertCPULoad: Int {
|
||||
didSet { defaults.set(alertCPULoad, forKey: Keys.alertCPULoad) }
|
||||
}
|
||||
/// CPU 详情顶部走势图显示最近多少秒
|
||||
public var cpuChartSeconds: Int {
|
||||
didSet { defaults.set(cpuChartSeconds, forKey: Keys.cpuChartSeconds) }
|
||||
}
|
||||
/// 界面语言:界面文字立即切换;系统提供的名称(显示器、应用名)在下次启动时跟着切换
|
||||
public var language: AppLanguage {
|
||||
didSet {
|
||||
@@ -438,6 +527,8 @@ public final class AppSettings {
|
||||
public static let batteryFloorOptions = [10, 20, 30, 40]
|
||||
public static let fanSafetyOptions = [85, 90, 95, 100]
|
||||
public static let alertTemperatureOptions = [85, 90, 95, 100]
|
||||
public static let alertLoadOptions = [70, 80, 90]
|
||||
public static let cpuChartOptions = [60, 180, 300]
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -462,12 +553,14 @@ public final class AppSettings {
|
||||
appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system
|
||||
cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash)
|
||||
menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate
|
||||
hiddenPopoverSections = Set(defaults.stringArray(forKey: Keys.hiddenPopoverSections)?.compactMap(PopoverSection.init(rawValue:)) ?? [])
|
||||
hiddenPopoverSections = defaults.stringArray(forKey: Keys.hiddenPopoverSections)
|
||||
.map { Set($0.compactMap(PopoverSection.init(rawValue:))) } ?? PopoverSection.hiddenByDefault
|
||||
probeEnabled = defaults.object(forKey: Keys.probeEnabled) as? Bool ?? true
|
||||
probeInBackground = defaults.object(forKey: Keys.probeInBackground) as? Bool ?? true
|
||||
probeSeconds = Self.probeOptions.contains(defaults.integer(forKey: Keys.probeSeconds))
|
||||
? defaults.integer(forKey: Keys.probeSeconds) : 2
|
||||
probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare
|
||||
geoSource = defaults.string(forKey: Keys.geoSource).flatMap(GeoSource.init(rawValue:)) ?? .cleanIP
|
||||
publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true
|
||||
geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity)
|
||||
geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true
|
||||
@@ -481,6 +574,10 @@ public final class AppSettings {
|
||||
enabledAlerts = Set(defaults.stringArray(forKey: Keys.enabledAlerts)?.compactMap(AlertKind.init(rawValue:)) ?? [])
|
||||
alertCPUTemperature = Self.alertTemperatureOptions.contains(defaults.integer(forKey: Keys.alertCPUTemperature))
|
||||
? defaults.integer(forKey: Keys.alertCPUTemperature) : 95
|
||||
alertCPULoad = Self.alertLoadOptions.contains(defaults.integer(forKey: Keys.alertCPULoad))
|
||||
? defaults.integer(forKey: Keys.alertCPULoad) : 80
|
||||
cpuChartSeconds = Self.cpuChartOptions.contains(defaults.integer(forKey: Keys.cpuChartSeconds))
|
||||
? defaults.integer(forKey: Keys.cpuChartSeconds) : 60
|
||||
}
|
||||
|
||||
/// 按固定顺序返回已启用的菜单栏项目
|
||||
@@ -528,6 +625,7 @@ public final class AppSettings {
|
||||
static let probeSeconds = "probeSeconds"
|
||||
static let probeInBackground = "probeInBackground"
|
||||
static let probeTarget = "probeTarget"
|
||||
static let geoSource = "geoSource"
|
||||
static let publicIPLookup = "publicIPLookup"
|
||||
static let geoIncludeCity = "geoIncludeCity"
|
||||
static let geoAutoUpdate = "geoAutoUpdate"
|
||||
@@ -537,5 +635,7 @@ public final class AppSettings {
|
||||
static let language = "language"
|
||||
static let enabledAlerts = "enabledAlerts"
|
||||
static let alertCPUTemperature = "alertCPUTemperature"
|
||||
static let alertCPULoad = "alertCPULoad"
|
||||
static let cpuChartSeconds = "cpuChartSeconds"
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ enum Log {
|
||||
static let power = Logger(subsystem: subsystem, category: "power")
|
||||
static let network = Logger(subsystem: subsystem, category: "network")
|
||||
static let update = Logger(subsystem: subsystem, category: "update")
|
||||
static let sync = Logger(subsystem: subsystem, category: "sync")
|
||||
}
|
||||
|
||||
/// 导出诊断信息:版本与系统、辅助工具状态、主要设置、最近 3 天的运行日志、清理记录与崩溃报告,打成一个 zip。
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import AppKit
|
||||
import Cleaner
|
||||
import Foundation
|
||||
import HelperShared
|
||||
import Localization
|
||||
import Metrics
|
||||
import Observation
|
||||
|
||||
/// 磁盘页上能动手的部分:文件系统检查、Time Machine 本地快照、其他已装载的磁盘、家目录空间分析
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DiskToolsController {
|
||||
struct Outcome: Equatable {
|
||||
let text: String
|
||||
let isError: Bool
|
||||
}
|
||||
|
||||
enum VerifyPhase: Equatable {
|
||||
case idle
|
||||
case running
|
||||
case finished(VolumeVerification)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
enum ScanPhase: Equatable {
|
||||
case idle
|
||||
case scanning(SpaceScanProgress)
|
||||
case finished(SpaceScanResult)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
// MARK: 文件系统检查
|
||||
|
||||
private(set) var verifyPhase: VerifyPhase = .idle
|
||||
private(set) var lastVerified: Date? {
|
||||
didSet { defaults.set(lastVerified, forKey: Keys.lastVerified) }
|
||||
}
|
||||
private(set) var lastVerifiedOK: Bool {
|
||||
didSet { defaults.set(lastVerifiedOK, forKey: Keys.lastVerifiedOK) }
|
||||
}
|
||||
|
||||
// MARK: 本地快照
|
||||
|
||||
private(set) var snapshots: [LocalSnapshot] = []
|
||||
private(set) var snapshotsLoaded = false
|
||||
private(set) var isDeletingSnapshots = false
|
||||
private(set) var snapshotOutcome: Outcome?
|
||||
|
||||
// MARK: 其他磁盘
|
||||
|
||||
private(set) var volumes: [MountedVolume] = []
|
||||
private(set) var ejecting: Set<String> = []
|
||||
private(set) var volumeOutcome: Outcome?
|
||||
|
||||
// MARK: 空间占用
|
||||
|
||||
private(set) var scanPhase: ScanPhase = .idle
|
||||
private(set) var trashOutcome: Outcome?
|
||||
@ObservationIgnored private var scanTask: Task<Void, Never>?
|
||||
|
||||
@ObservationIgnored private let helper: HelperClient
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private var volumeObservers: [NSObjectProtocol] = []
|
||||
|
||||
init(helper: HelperClient, defaults: UserDefaults = .standard) {
|
||||
self.helper = helper
|
||||
self.defaults = defaults
|
||||
lastVerified = defaults.object(forKey: Keys.lastVerified) as? Date
|
||||
lastVerifiedOK = defaults.object(forKey: Keys.lastVerifiedOK) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// 磁盘页打开时刷新快照与卷宗;接入、拔出磁盘时跟着刷新
|
||||
func pageOpened() {
|
||||
refreshSnapshots()
|
||||
refreshVolumes()
|
||||
guard volumeObservers.isEmpty else { return }
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification] {
|
||||
volumeObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.refreshVolumes() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 检查
|
||||
|
||||
func verify() {
|
||||
guard verifyPhase != .running else { return }
|
||||
verifyPhase = .running
|
||||
Task {
|
||||
let result = await Task.detached { VolumeVerifier.verify() }.value
|
||||
verifyPhase = .finished(result)
|
||||
lastVerified = result.date
|
||||
lastVerifiedOK = result.ok
|
||||
if result.ok {
|
||||
Log.app.notice("磁盘检查通过")
|
||||
} else {
|
||||
Log.app.error("磁盘检查发现问题:\(result.summary, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 快照
|
||||
|
||||
func refreshSnapshots() {
|
||||
Task {
|
||||
let list = await Task.detached { LocalSnapshots.list() }.value
|
||||
snapshots = list
|
||||
snapshotsLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
/// 删掉全部本地快照:辅助工具够新就走它,否则请求一次管理员授权
|
||||
func deleteAllSnapshots() {
|
||||
guard !isDeletingSnapshots, !snapshots.isEmpty else { return }
|
||||
let identifiers = snapshots.map(\.identifier).filter(LocalSnapshotCommand.isValidIdentifier)
|
||||
guard !identifiers.isEmpty else {
|
||||
snapshotOutcome = Outcome(text: tr("快照名称格式不认识,没有删除"), isError: true)
|
||||
return
|
||||
}
|
||||
isDeletingSnapshots = true
|
||||
snapshotOutcome = nil
|
||||
Task {
|
||||
defer { isDeletingSnapshots = false }
|
||||
let error: String?
|
||||
if let version = await helper.remoteProtocolVersion(), version >= 4 {
|
||||
error = await helper.deleteLocalSnapshots(identifiers: identifiers)
|
||||
} else {
|
||||
error = await MaintenanceController.runWithAdministratorPrompt(
|
||||
shell: LocalSnapshotCommand.shellCommand(identifiers: identifiers),
|
||||
prompt: tr("OpenStats 需要管理员权限来删除 Time Machine 本地快照。"))
|
||||
}
|
||||
if let error {
|
||||
Log.app.error("删除本地快照失败:\(error, privacy: .public)")
|
||||
snapshotOutcome = Outcome(text: error, isError: error != MaintenanceController.cancelled)
|
||||
} else {
|
||||
Log.app.notice("已删除 \(identifiers.count) 个本地快照")
|
||||
snapshotOutcome = Outcome(text: tr("已删除 \(identifiers.count) 个快照,腾出的空间稍后会反映在“可清除”里"), isError: false)
|
||||
}
|
||||
refreshSnapshots()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 卷宗
|
||||
|
||||
func refreshVolumes() {
|
||||
Task {
|
||||
volumes = await Task.detached { MountedVolumes.list() }.value
|
||||
}
|
||||
}
|
||||
|
||||
func eject(_ volume: MountedVolume) {
|
||||
guard !ejecting.contains(volume.id) else { return }
|
||||
ejecting.insert(volume.id)
|
||||
volumeOutcome = nil
|
||||
Task {
|
||||
defer { ejecting.remove(volume.id) }
|
||||
do {
|
||||
try await Task.detached { try NSWorkspace.shared.unmountAndEjectDevice(at: volume.url) }.value
|
||||
volumeOutcome = Outcome(text: tr("已推出“\(volume.name)”"), isError: false)
|
||||
} catch {
|
||||
volumeOutcome = Outcome(text: tr("无法推出“\(volume.name)”:\(error.localizedDescription)"), isError: true)
|
||||
}
|
||||
refreshVolumes()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 空间分析
|
||||
|
||||
func startScan() {
|
||||
guard scanTask == nil else { return }
|
||||
scanPhase = .scanning(SpaceScanProgress(items: 0, bytes: 0, current: ""))
|
||||
trashOutcome = nil
|
||||
scanTask = Task {
|
||||
defer { scanTask = nil }
|
||||
do {
|
||||
// 用户点了按钮在等结果,不用 utility:那一档的磁盘读取会被系统限速
|
||||
let result = try await Task.detached(priority: .userInitiated) {
|
||||
try SpaceScanner.scan { progress in
|
||||
Task { @MainActor in self.report(progress) }
|
||||
}
|
||||
}.value
|
||||
scanPhase = .finished(result)
|
||||
Log.app.notice("空间分析完成:\(result.scannedItems) 个条目")
|
||||
} catch is CancellationError {
|
||||
scanPhase = .idle
|
||||
} catch {
|
||||
scanPhase = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func report(_ progress: SpaceScanProgress) {
|
||||
if case .scanning = scanPhase { scanPhase = .scanning(progress) }
|
||||
}
|
||||
|
||||
func cancelScan() {
|
||||
scanTask?.cancel()
|
||||
}
|
||||
|
||||
/// 把扫描结果里的一个文件移到废纸篓,然后从结果里去掉它
|
||||
func trash(_ file: SpaceScanResult.File) {
|
||||
do {
|
||||
try SpaceScanner.trash(file.url)
|
||||
trashOutcome = Outcome(text: tr("已把“\(file.name)”移到废纸篓"), isError: false)
|
||||
if case .finished(let result) = scanPhase {
|
||||
scanPhase = .finished(SpaceScanResult(root: result.root, folders: result.folders,
|
||||
largestFiles: result.largestFiles.filter { $0.id != file.id },
|
||||
scannedItems: result.scannedItems, totalBytes: result.totalBytes, date: result.date))
|
||||
}
|
||||
Log.app.notice("空间分析:移到废纸篓 \(file.url.path, privacy: .private)")
|
||||
} catch {
|
||||
trashOutcome = Outcome(text: tr("无法移到废纸篓:\(error.localizedDescription)"), isError: true)
|
||||
}
|
||||
}
|
||||
|
||||
func reveal(_ url: URL) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let lastVerified = "diskLastVerified"
|
||||
static let lastVerifiedOK = "diskLastVerifiedOK"
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,10 @@ public final class HelperClient {
|
||||
await call { proxy, reply in proxy.setDNSServers(service: service, servers: servers, reply: reply) }
|
||||
}
|
||||
|
||||
func deleteLocalSnapshots(identifiers: [String]) async -> String? {
|
||||
await call { proxy, reply in proxy.deleteLocalSnapshots(identifiers: identifiers, reply: reply) }
|
||||
}
|
||||
|
||||
/// 已安装的辅助工具的协议版本;旧版本不认识新增的方法,调用前先确认
|
||||
func remoteProtocolVersion() async -> Int? {
|
||||
refreshStatus()
|
||||
|
||||
@@ -80,7 +80,7 @@ public final class MaintenanceController {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static var cancelled: String { tr("已取消") }
|
||||
nonisolated static var cancelled: String { tr("已取消") }
|
||||
|
||||
/// 未安装辅助工具时的回退:通过 AppleScript 请求一次性管理员授权执行命令。
|
||||
/// 命令只由固定路径与已校验的参数拼成,这里再做 AppleScript 字符串转义
|
||||
|
||||
@@ -3,6 +3,17 @@ import Metrics
|
||||
import Observation
|
||||
import SMC
|
||||
|
||||
/// 一次采样的时间与数值
|
||||
public struct TimedValue: Sendable, Equatable {
|
||||
public let date: Date
|
||||
public let value: Double
|
||||
|
||||
public init(date: Date, value: Double) {
|
||||
self.date = date
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
/// 主线程上的最新指标与短历史,只保留界面需要的数据
|
||||
@MainActor
|
||||
@Observable
|
||||
@@ -14,6 +25,9 @@ public final class MetricsStore {
|
||||
|
||||
public private(set) var cpu: CPULoad?
|
||||
public private(set) var cpuTotal = History<Double>(capacity: historyCapacity)
|
||||
/// 带时间戳的 CPU 总占用,供可选 1 / 3 / 5 分钟的走势图按时间定位;每秒一次时正好装下 5 分钟
|
||||
public private(set) var cpuTimeline = History<TimedValue>(capacity: timelineCapacity)
|
||||
public static let timelineCapacity = 300
|
||||
/// 每次采样各核心的占用,用于核心热力图
|
||||
public private(set) var coreHistory = History<[Double]>(capacity: historyCapacity)
|
||||
|
||||
@@ -34,6 +48,8 @@ public final class MetricsStore {
|
||||
/// 启动时读取一次,确保面板首次打开时布局(是否有电池卡片)就已确定
|
||||
public private(set) var battery: BatteryStatus? = BatterySampler.sample()
|
||||
public private(set) var processes: [ProcessUsage] = []
|
||||
/// 按应用汇总的磁盘读写排行,分数是最近十几秒的平均速率
|
||||
private(set) var diskRanking = ActivityRanking()
|
||||
public private(set) var systemCounts: SystemCounts?
|
||||
public private(set) var sensors: SensorReadings?
|
||||
public private(set) var power: PowerReading?
|
||||
@@ -61,6 +77,7 @@ public final class MetricsStore {
|
||||
if let cpu = snapshot.cpu {
|
||||
self.cpu = cpu
|
||||
cpuTotal.append(cpu.total)
|
||||
cpuTimeline.append(TimedValue(date: snapshot.date, value: cpu.total))
|
||||
coreHistory.append(cpu.perCore)
|
||||
}
|
||||
if let memory = snapshot.memory {
|
||||
@@ -86,7 +103,14 @@ public final class MetricsStore {
|
||||
}
|
||||
if let disk = snapshot.disk { self.disk = disk }
|
||||
if let battery = snapshot.battery { self.battery = battery }
|
||||
if let processes = snapshot.processes { self.processes = processes }
|
||||
if let processes = snapshot.processes {
|
||||
self.processes = processes
|
||||
var activity: [String: Double] = [:]
|
||||
for row in ProcessRowModel.grouped(processes) {
|
||||
activity[row.id] = row.disk.map { $0.read + $0.write } ?? 0
|
||||
}
|
||||
diskRanking.update(activity)
|
||||
}
|
||||
if let counts = snapshot.systemCounts { systemCounts = counts }
|
||||
if let sensors = snapshot.sensors { mergeSensors(sensors) }
|
||||
}
|
||||
|
||||
@@ -16,10 +16,28 @@ public final class NetworkController {
|
||||
public static let probeCapacity = 120
|
||||
|
||||
public private(set) var details: NetworkDetails?
|
||||
public private(set) var publicAddresses: PublicAddresses?
|
||||
/// 公网 IP 信息按地址族各存一份:两份都含 IPv4 与 IPv6 地址,归属地、纯净度等是各自那个地址的
|
||||
public private(set) var publicResults: [IPFamily: PublicAddresses] = [:]
|
||||
/// 界面上当前看的是哪一族;只有一族时自动跟着
|
||||
public var publicFamily: IPFamily = .v4
|
||||
/// 当前地址族的结果;所选那族没有时退到另一族
|
||||
public var publicAddresses: PublicAddresses? {
|
||||
publicResults[publicFamily] ?? publicResults[publicFamily == .v4 ? .v6 : .v4]
|
||||
}
|
||||
/// 两族都有归属地结果时才提供切换
|
||||
public var hasDualStack: Bool {
|
||||
publicResults[.v4] != nil && publicResults[.v6] != nil
|
||||
}
|
||||
/// 界面上标注的地址族:双栈时显示,单栈不显示
|
||||
public var shownFamily: IPFamily? {
|
||||
guard hasDualStack else { return nil }
|
||||
return publicResults[publicFamily] != nil ? publicFamily : (publicFamily == .v4 ? .v6 : .v4)
|
||||
}
|
||||
public private(set) var isLookingUpPublic = false
|
||||
public private(set) var probes = History<ProbeSample>(capacity: probeCapacity)
|
||||
public private(set) var processes: [NetworkProcessUsage] = []
|
||||
/// 各进程流量的平滑排行,列表按它排序而不是按瞬时速率
|
||||
@ObservationIgnored private var processRanking = ActivityRanking()
|
||||
|
||||
@ObservationIgnored private let settings: AppSettings
|
||||
@ObservationIgnored private var probeTask: Task<Void, Never>?
|
||||
@@ -37,9 +55,66 @@ public final class NetworkController {
|
||||
|
||||
@ObservationIgnored private let geo: GeoDatabaseController
|
||||
|
||||
/// 上次查到的公网 IP 信息:点开弹窗时先显示它,只有超过 7 天或公网 IP 变了才重新查归属地
|
||||
private struct PublicCache: Codable {
|
||||
var results: [String: PublicAddresses]
|
||||
var geoDates: [String: Date]
|
||||
var source: String
|
||||
}
|
||||
static let geoCacheLifetime: TimeInterval = 7 * 24 * 60 * 60
|
||||
private static let cacheKey = "publicAddressCache3"
|
||||
|
||||
/// 用户按过“重置统计”后的累计流量基线:界面显示 当前累计 − 基线。
|
||||
/// 随开机时间一起保存,重启后系统计数器归零、基线作废,自动回到开机后累计
|
||||
public struct TrafficBaseline: Codable, Equatable, Sendable {
|
||||
public var download: UInt64
|
||||
public var upload: UInt64
|
||||
public var date: Date
|
||||
var bootTime: TimeInterval?
|
||||
}
|
||||
public private(set) var trafficBaseline: TrafficBaseline?
|
||||
private static let trafficBaselineKey = "trafficBaseline"
|
||||
private static let bootTime: TimeInterval? = {
|
||||
var boot = timeval()
|
||||
var size = MemoryLayout<timeval>.size
|
||||
return sysctlbyname("kern.boottime", &boot, &size, nil, 0) == 0 ? TimeInterval(boot.tv_sec) : nil
|
||||
}()
|
||||
|
||||
init(settings: AppSettings, geo: GeoDatabaseController) {
|
||||
self.settings = settings
|
||||
self.geo = geo
|
||||
if let data = UserDefaults.standard.data(forKey: Self.cacheKey),
|
||||
let cache = try? JSONDecoder().decode(PublicCache.self, from: data),
|
||||
cache.source == settings.geoSource.rawValue, settings.publicIPLookup {
|
||||
publicResults = Dictionary(uniqueKeysWithValues: cache.results.compactMap { key, value in IPFamily(rawValue: key).map { ($0, value) } })
|
||||
}
|
||||
if let data = UserDefaults.standard.data(forKey: Self.trafficBaselineKey),
|
||||
let baseline = try? JSONDecoder().decode(TrafficBaseline.self, from: data),
|
||||
baseline.bootTime == Self.bootTime {
|
||||
trafficBaseline = baseline
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 流量统计
|
||||
|
||||
/// 从现在起重新累计上传与下载:把当前计数器记成基线
|
||||
public func resetTraffic(_ rate: NetworkRate) {
|
||||
let baseline = TrafficBaseline(download: rate.totalDownloaded, upload: rate.totalUploaded, date: Date(), bootTime: Self.bootTime)
|
||||
trafficBaseline = baseline
|
||||
UserDefaults.standard.set(try? JSONEncoder().encode(baseline), forKey: Self.trafficBaselineKey)
|
||||
}
|
||||
|
||||
/// 改回开机后的累计
|
||||
public func clearTrafficBaseline() {
|
||||
trafficBaseline = nil
|
||||
UserDefaults.standard.removeObject(forKey: Self.trafficBaselineKey)
|
||||
}
|
||||
|
||||
/// 扣掉基线后的累计流量;计数器比基线小(接口变动)时按 0 算
|
||||
public func trafficTotals(_ rate: NetworkRate) -> (download: UInt64, upload: UInt64) {
|
||||
guard let baseline = trafficBaseline else { return (rate.totalDownloaded, rate.totalUploaded) }
|
||||
return (rate.totalDownloaded >= baseline.download ? rate.totalDownloaded - baseline.download : 0,
|
||||
rate.totalUploaded >= baseline.upload ? rate.totalUploaded - baseline.upload : 0)
|
||||
}
|
||||
|
||||
// MARK: 统计
|
||||
@@ -131,7 +206,7 @@ public final class NetworkController {
|
||||
}.value
|
||||
sampler = next
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.processes = usage
|
||||
self.processes = self.rankedProcesses(usage)
|
||||
tick += 1
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
}
|
||||
@@ -144,27 +219,80 @@ public final class NetworkController {
|
||||
|
||||
// MARK: 公网 IP
|
||||
|
||||
func lookUpPublicAddresses() {
|
||||
/// 先只取公网地址(Cloudflare,便宜且不限额),再按地址族各查一次归属地:地址没变、结果不满 7 天、
|
||||
/// 数据源没换的那一族沿用缓存,否则重新查并写回缓存。`force` 为真时(用户点了刷新)两族都重查
|
||||
func lookUpPublicAddresses(force: Bool = false) {
|
||||
guard settings.publicIPLookup, !isLookingUpPublic else { return }
|
||||
isLookingUpPublic = true
|
||||
// 有缓存可显示时不转圈,后台悄悄核对
|
||||
isLookingUpPublic = publicResults.isEmpty || force
|
||||
let localIPv4 = details?.physical?.ipv4 ?? []
|
||||
Task {
|
||||
// 有本地 GeoLite2 时只向外查询公网 IP,归属地与 ASN 在本机查
|
||||
let local = geo.isAvailable
|
||||
var result = await PublicAddressLookup.fetch(includeGeo: !local)
|
||||
if local, let address = result.ipv4 ?? result.ipv6, let location = geo.locate(address) {
|
||||
result.countryCode = location.countryCode ?? result.countryCode
|
||||
result.city = location.city
|
||||
result.asn = location.asn
|
||||
result.organization = location.organization
|
||||
result.source = .localDatabase
|
||||
let source = settings.geoSource
|
||||
let provider = source.provider ?? .cleanIP
|
||||
let useLocal = source == .localDatabase && geo.isAvailable
|
||||
let base = await PublicAddressLookup.fetch(includeGeo: false, provider: provider)
|
||||
let cached = Self.loadCache()
|
||||
let now = Date()
|
||||
|
||||
@MainActor func resolve(_ family: IPFamily, ip: String?) async -> PublicAddresses? {
|
||||
guard let ip else { return nil }
|
||||
if !force, let cached, cached.source == source.rawValue,
|
||||
let old = cached.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip,
|
||||
let date = cached.geoDates[family.rawValue], now.timeIntervalSince(date) < Self.geoCacheLifetime {
|
||||
var kept = old
|
||||
kept.ipv4 = base.ipv4
|
||||
kept.ipv6 = base.ipv6
|
||||
return kept
|
||||
}
|
||||
var result = base
|
||||
if !useLocal, let info = await PublicAddressLookup.geo(for: ip, provider: provider) {
|
||||
result.apply(info)
|
||||
}
|
||||
// 选了本地 GeoLite2,或在线查不到时,退回本地数据库
|
||||
if useLocal || (result.asn == nil && result.city == nil && geo.isAvailable), let location = geo.locate(ip) {
|
||||
result.countryCode = location.countryCode ?? result.countryCode
|
||||
result.city = location.city
|
||||
result.asn = location.asn
|
||||
result.organization = location.organization
|
||||
result.source = .localDatabase
|
||||
}
|
||||
guard result.asn != nil || result.city != nil || result.purity != nil else {
|
||||
// 这次没查到,地址又没变,先继续用旧的
|
||||
if let old = cached?.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip { return old }
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
publicAddresses = result
|
||||
|
||||
async let v4 = resolve(.v4, ip: base.ipv4)
|
||||
async let v6 = resolve(.v6, ip: base.ipv6)
|
||||
var results: [IPFamily: PublicAddresses] = [:]
|
||||
if let v4 = await v4 { results[.v4] = v4 }
|
||||
if let v6 = await v6 { results[.v6] = v6 }
|
||||
|
||||
var dates = cached?.source == source.rawValue ? cached?.geoDates ?? [:] : [:]
|
||||
for (family, result) in results {
|
||||
let previous = cached?.results[family.rawValue]
|
||||
let sameAsCached = previous?.asn == result.asn && previous?.purity == result.purity && previous?.city == result.city
|
||||
if force || !sameAsCached || dates[family.rawValue] == nil { dates[family.rawValue] = now }
|
||||
}
|
||||
Self.saveCache(PublicCache(results: Dictionary(uniqueKeysWithValues: results.map { ($0.key.rawValue, $0.value) }),
|
||||
geoDates: dates, source: source.rawValue))
|
||||
publicResults = results
|
||||
if publicResults[publicFamily] == nil, let only = publicResults.keys.first { publicFamily = only }
|
||||
lastPublicLookup = (Date(), localIPv4)
|
||||
isLookingUpPublic = false
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadCache() -> PublicCache? {
|
||||
UserDefaults.standard.data(forKey: cacheKey).flatMap { try? JSONDecoder().decode(PublicCache.self, from: $0) }
|
||||
}
|
||||
|
||||
private static func saveCache(_ cache: PublicCache) {
|
||||
UserDefaults.standard.set(try? JSONEncoder().encode(cache), forKey: cacheKey)
|
||||
}
|
||||
|
||||
/// 10 分钟内且本地地址未变化时沿用上次结果
|
||||
private func lookUpPublicAddressesIfStale() {
|
||||
guard settings.publicIPLookup else { return }
|
||||
@@ -174,8 +302,9 @@ public final class NetworkController {
|
||||
}
|
||||
|
||||
func clearPublicAddresses() {
|
||||
publicAddresses = nil
|
||||
publicResults = [:]
|
||||
lastPublicLookup = nil
|
||||
UserDefaults.standard.removeObject(forKey: Self.cacheKey)
|
||||
}
|
||||
|
||||
// MARK: 探测
|
||||
@@ -219,7 +348,39 @@ public final class NetworkController {
|
||||
details.physical?.hardwareAddress = "a4:83:e7:12:34:56"
|
||||
details.dnsServers = details.physical?.manualDNS.isEmpty == false ? details.physical!.manualDNS : ["192.168.1.1"]
|
||||
self.details = details
|
||||
publicAddresses = PublicAddresses(ipv4: "203.0.113.24", ipv6: nil, countryCode: "CN",
|
||||
city: "Shanghai", asn: "AS64500", organization: "EXAMPLE NETWORK")
|
||||
var sample = PublicAddresses(ipv4: "203.0.113.24", ipv6: nil, countryCode: "CN",
|
||||
city: "上海", region: "上海", asn: "AS64500", organization: "EXAMPLE NETWORK")
|
||||
sample.cityEnglish = "Shanghai"
|
||||
sample.networkType = "Residential"
|
||||
sample.connectionType = "Cable/DSL"
|
||||
sample.ipType = "Residential IP"
|
||||
sample.isNative = true
|
||||
sample.residentialProbability = 96
|
||||
sample.purity = PublicAddresses.Purity(score: 94, grade: "A", confidence: 100)
|
||||
sample.risk = PublicAddresses.Risk(score: 4, label: "Very Clean")
|
||||
publicResults = [.v4: sample]
|
||||
}
|
||||
}
|
||||
|
||||
extension NetworkController {
|
||||
/// 用最近十几秒的平均流量给进程排序:刚安静下来的进程还会在榜上停留一会儿,列表不会忽隐忽现
|
||||
func rankedProcesses(_ usage: [NetworkProcessUsage]) -> [NetworkProcessUsage] {
|
||||
var current: [String: Double] = [:]
|
||||
var byID: [String: NetworkProcessUsage] = [:]
|
||||
for process in usage {
|
||||
let key = "\(process.pid)"
|
||||
current[key] = process.download + process.upload
|
||||
byID[key] = process
|
||||
}
|
||||
processRanking.update(current)
|
||||
return processRanking.ranked(limit: 5).compactMap { byID[$0.id] }
|
||||
}
|
||||
}
|
||||
|
||||
/// 公网地址的地址族
|
||||
public enum IPFamily: String, CaseIterable, Identifiable, Sendable, Codable {
|
||||
case v4, v6
|
||||
|
||||
public var id: String { rawValue }
|
||||
public var title: String { self == .v4 ? "IPv4" : "IPv6" }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
import Localization
|
||||
|
||||
/// 同步到云端的设置文档:只含偏好,不含本机状态(当前页签、辅助工具安装状态、历史库、归属地库)。
|
||||
/// 枚举以原始值保存,字段全部可选:旧版本读到新字段会忽略,新版本读到旧文档不会解码失败;
|
||||
/// 应用时逐项校验,不认识的值跳过
|
||||
public struct SettingsDocument: Codable, Equatable, Sendable {
|
||||
public var schema: Int? = 1
|
||||
public var menuBarItems: [String]?
|
||||
public var menuBarStyle: String?
|
||||
public var networkStyle: String?
|
||||
public var styleOverrides: [String: String]?
|
||||
public var refreshSeconds: Int?
|
||||
public var colorizeHighLoad: Bool?
|
||||
public var useFahrenheit: Bool?
|
||||
public var lidModeBatteryFloor: Int?
|
||||
public var fanSafetyTemperature: Int?
|
||||
public var appearance: String?
|
||||
public var menuBarLayout: String?
|
||||
public var hiddenPopoverSections: [String]?
|
||||
public var probeEnabled: Bool?
|
||||
public var probeInBackground: Bool?
|
||||
public var probeSeconds: Int?
|
||||
public var probeTarget: String?
|
||||
public var geoSource: String?
|
||||
public var publicIPLookup: Bool?
|
||||
public var geoIncludeCity: Bool?
|
||||
public var geoAutoUpdate: Bool?
|
||||
public var enabledAlerts: [String]?
|
||||
public var alertCPUTemperature: Int?
|
||||
public var alertCPULoad: Int?
|
||||
public var language: String?
|
||||
public var hotKeys: [String: HotKey]?
|
||||
public var historyEnabled: Bool?
|
||||
public var autoCheckUpdates: Bool?
|
||||
public var cleanPrefersTrash: Bool?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
extension AppSettings {
|
||||
/// 当前设置导出成文档。读取这里列出的每一项也就登记了观察,SyncController 借此得知设置变化
|
||||
public func exportDocument() -> SettingsDocument {
|
||||
var doc = SettingsDocument()
|
||||
doc.menuBarItems = menuBarItems.map(\.rawValue).sorted()
|
||||
doc.menuBarStyle = menuBarStyle.rawValue
|
||||
doc.networkStyle = networkStyle.rawValue
|
||||
doc.styleOverrides = Dictionary(uniqueKeysWithValues: styleOverrides.map { ($0.key.rawValue, $0.value.rawValue) })
|
||||
doc.refreshSeconds = refreshSeconds
|
||||
doc.colorizeHighLoad = colorizeHighLoad
|
||||
doc.useFahrenheit = useFahrenheit
|
||||
doc.lidModeBatteryFloor = lidModeBatteryFloor
|
||||
doc.fanSafetyTemperature = fanSafetyTemperature
|
||||
doc.appearance = appearance.rawValue
|
||||
doc.menuBarLayout = menuBarLayout.rawValue
|
||||
doc.hiddenPopoverSections = hiddenPopoverSections.map(\.rawValue).sorted()
|
||||
doc.probeEnabled = probeEnabled
|
||||
doc.probeInBackground = probeInBackground
|
||||
doc.probeSeconds = probeSeconds
|
||||
doc.probeTarget = probeTarget.rawValue
|
||||
doc.geoSource = geoSource.rawValue
|
||||
doc.publicIPLookup = publicIPLookup
|
||||
doc.geoIncludeCity = geoIncludeCity
|
||||
doc.geoAutoUpdate = geoAutoUpdate
|
||||
doc.enabledAlerts = enabledAlerts.map(\.rawValue).sorted()
|
||||
doc.alertCPUTemperature = alertCPUTemperature
|
||||
doc.alertCPULoad = alertCPULoad
|
||||
doc.language = language.rawValue
|
||||
doc.hotKeys = Dictionary(uniqueKeysWithValues: hotKeys.map { ($0.key.rawValue, $0.value) })
|
||||
doc.historyEnabled = historyEnabled
|
||||
doc.autoCheckUpdates = autoCheckUpdates
|
||||
doc.cleanPrefersTrash = cleanPrefersTrash
|
||||
return doc
|
||||
}
|
||||
|
||||
/// 把文档套用到当前设置。只改有变化的项,避免无谓地触发持久化与界面重建
|
||||
public func apply(_ doc: SettingsDocument) {
|
||||
func assign<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<AppSettings, T>, _ value: T?) {
|
||||
guard let value, self[keyPath: keyPath] != value else { return }
|
||||
self[keyPath: keyPath] = value
|
||||
}
|
||||
func option(_ value: Int?, in options: [Int]) -> Int? {
|
||||
value.flatMap { options.contains($0) ? $0 : nil }
|
||||
}
|
||||
|
||||
assign(\.menuBarItems, doc.menuBarItems.map { Set($0.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
|
||||
Dictionary(uniqueKeysWithValues: overrides.compactMap { key, value in
|
||||
guard let item = MenuBarItem(rawValue: key), let style = MenuBarStyle(rawValue: value) else { return nil }
|
||||
return (item, style)
|
||||
})
|
||||
})
|
||||
assign(\.refreshSeconds, option(doc.refreshSeconds, in: Self.refreshOptions))
|
||||
assign(\.colorizeHighLoad, doc.colorizeHighLoad)
|
||||
assign(\.useFahrenheit, doc.useFahrenheit)
|
||||
assign(\.lidModeBatteryFloor, option(doc.lidModeBatteryFloor, in: Self.batteryFloorOptions))
|
||||
assign(\.fanSafetyTemperature, option(doc.fanSafetyTemperature, in: Self.fanSafetyOptions))
|
||||
assign(\.appearance, doc.appearance.flatMap(AppearanceMode.init(rawValue:)))
|
||||
assign(\.menuBarLayout, doc.menuBarLayout.flatMap(MenuBarLayout.init(rawValue:)))
|
||||
assign(\.hiddenPopoverSections, doc.hiddenPopoverSections.map { Set($0.compactMap(PopoverSection.init(rawValue:))) })
|
||||
assign(\.probeEnabled, doc.probeEnabled)
|
||||
assign(\.probeInBackground, doc.probeInBackground)
|
||||
assign(\.probeSeconds, option(doc.probeSeconds, in: Self.probeOptions))
|
||||
assign(\.probeTarget, doc.probeTarget.flatMap(ProbeTarget.init(rawValue:)))
|
||||
assign(\.geoSource, doc.geoSource.flatMap(GeoSource.init(rawValue:)))
|
||||
assign(\.publicIPLookup, doc.publicIPLookup)
|
||||
assign(\.geoIncludeCity, doc.geoIncludeCity)
|
||||
assign(\.geoAutoUpdate, doc.geoAutoUpdate)
|
||||
assign(\.enabledAlerts, doc.enabledAlerts.map { Set($0.compactMap(AlertKind.init(rawValue:))) })
|
||||
assign(\.alertCPUTemperature, option(doc.alertCPUTemperature, in: Self.alertTemperatureOptions))
|
||||
assign(\.alertCPULoad, option(doc.alertCPULoad, in: Self.alertLoadOptions))
|
||||
assign(\.language, doc.language.flatMap(AppLanguage.init(rawValue:)))
|
||||
assign(\.hotKeys, doc.hotKeys.map { stored in
|
||||
Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in
|
||||
HotKeyAction(rawValue: key).map { ($0, value) }
|
||||
})
|
||||
})
|
||||
assign(\.historyEnabled, doc.historyEnabled)
|
||||
assign(\.autoCheckUpdates, doc.autoCheckUpdates)
|
||||
assign(\.cleanPrefersTrash, doc.cleanPrefersTrash)
|
||||
}
|
||||
|
||||
/// 出厂设置对应的文档:用一个空的 defaults 域构造一次即可,不会写盘
|
||||
public static func defaultDocument() -> SettingsDocument {
|
||||
let name = "com.openstats.defaults-probe"
|
||||
let defaults = UserDefaults(suiteName: name) ?? .standard
|
||||
defaults.removePersistentDomain(forName: name)
|
||||
return AppSettings(defaults: defaults).exportDocument()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import AccountSync
|
||||
import AppKit
|
||||
import AuthenticationServices
|
||||
import Foundation
|
||||
import Localization
|
||||
import Observation
|
||||
|
||||
/// 账号与设置同步:用系统的登录窗口走 GitHub / Google / Apple 授权,令牌存钥匙串;
|
||||
/// 设置一变就在 2 秒后整份推送,启动、唤醒与每 15 分钟拉一次。云端以最后写入为准,
|
||||
/// 只有第一次登录且两边都有内容时让用户选一次
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class SyncController {
|
||||
public enum Phase: Equatable {
|
||||
case idle
|
||||
case signingIn
|
||||
case syncing
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// 首次登录时本机与云端设置不同,等用户选择
|
||||
public struct Conflict: Equatable {
|
||||
public let remoteUpdatedAt: Date
|
||||
public let remoteDevice: String?
|
||||
}
|
||||
|
||||
public private(set) var phase: Phase = .idle
|
||||
public private(set) var user: SyncUser? {
|
||||
didSet { defaults.set(user.flatMap { try? JSONEncoder().encode($0) }, forKey: Keys.user) }
|
||||
}
|
||||
public private(set) var lastSyncedAt: Date? {
|
||||
didSet { defaults.set(lastSyncedAt, forKey: Keys.lastSyncedAt) }
|
||||
}
|
||||
public private(set) var conflict: Conflict?
|
||||
/// 服务器上配置好的登录方式;取不到时全部显示
|
||||
public private(set) var providers: [SyncProvider] = SyncProvider.allCases
|
||||
|
||||
public var isSignedIn: Bool { token != nil && user != nil }
|
||||
|
||||
/// 登录窗口挂在哪个窗口上,由 AppController 提供主窗口
|
||||
@ObservationIgnored var presentationAnchor: @MainActor () -> NSWindow? = { nil }
|
||||
|
||||
@ObservationIgnored private let settings: AppSettings
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let client: SyncClient
|
||||
@ObservationIgnored private let tokens: TokenStore
|
||||
@ObservationIgnored private var token: String?
|
||||
@ObservationIgnored private var lastSyncedVersion: Int {
|
||||
didSet { defaults.set(lastSyncedVersion, forKey: Keys.lastSyncedVersion) }
|
||||
}
|
||||
/// 上次推送或套用过的文档:本机再次读到同样的内容就不用推,也用来识别套用云端设置引起的回声
|
||||
@ObservationIgnored private var lastKnownDocument: SettingsDocument? {
|
||||
didSet { defaults.set(lastKnownDocument.flatMap { try? JSONEncoder().encode($0) }, forKey: Keys.lastKnownDocument) }
|
||||
}
|
||||
@ObservationIgnored private var pendingRemote: RemoteSettings<SettingsDocument>?
|
||||
@ObservationIgnored private var pushTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var pullTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var authSession: ASWebAuthenticationSession?
|
||||
@ObservationIgnored private var anchorProvider: AnchorProvider?
|
||||
@ObservationIgnored private var timer: Timer?
|
||||
@ObservationIgnored private var observing = false
|
||||
|
||||
static let pushDelay: Duration = .seconds(2)
|
||||
static let pullInterval: TimeInterval = 15 * 60
|
||||
|
||||
init(settings: AppSettings, defaults: UserDefaults = .standard, client: SyncClient = SyncClient(), tokens: TokenStore = KeychainTokenStore()) {
|
||||
self.settings = settings
|
||||
self.defaults = defaults
|
||||
self.client = client
|
||||
self.tokens = tokens
|
||||
user = defaults.data(forKey: Keys.user).flatMap { try? JSONDecoder().decode(SyncUser.self, from: $0) }
|
||||
lastSyncedAt = defaults.object(forKey: Keys.lastSyncedAt) as? Date
|
||||
lastSyncedVersion = defaults.integer(forKey: Keys.lastSyncedVersion)
|
||||
lastKnownDocument = defaults.data(forKey: Keys.lastKnownDocument).flatMap { try? JSONDecoder().decode(SettingsDocument.self, from: $0) }
|
||||
}
|
||||
|
||||
/// 启动时调用:已登录就开始观察设置并补一次同步
|
||||
public func start() {
|
||||
token = tokens.read()
|
||||
guard let token else {
|
||||
if user != nil { clearLocalSession() }
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
Task { [weak self] in
|
||||
guard let self, let fetched = try? await self.client.me(token: token) else { return }
|
||||
self.user = fetched
|
||||
self.start()
|
||||
}
|
||||
return
|
||||
}
|
||||
beginObserving()
|
||||
if lastKnownDocument != settings.exportDocument() {
|
||||
schedulePush()
|
||||
} else {
|
||||
pullSoon()
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号页打开时:未登录就刷新可用的登录方式,已登录就拉一次
|
||||
func pageOpened() {
|
||||
if isSignedIn {
|
||||
pullSoon()
|
||||
} else {
|
||||
Task { [weak self] in
|
||||
guard let self, let fetched = try? await self.client.providers(), !fetched.isEmpty else { return }
|
||||
self.providers = fetched
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 登录
|
||||
|
||||
public func signIn(with provider: SyncProvider) {
|
||||
guard phase != .signingIn else { return }
|
||||
let verifier = PKCE.verifier()
|
||||
let url = client.loginURL(provider: provider, challenge: PKCE.challenge(for: verifier))
|
||||
let anchor = AnchorProvider(anchor: presentationAnchor)
|
||||
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: SyncClient.callbackScheme) { [weak self] callback, error in
|
||||
Task { @MainActor in self?.finishSignIn(callback: callback, error: error, verifier: verifier) }
|
||||
}
|
||||
session.presentationContextProvider = anchor
|
||||
// 保留浏览器里已登录的第三方账号,用户不必再输一次密码
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
anchorProvider = anchor
|
||||
authSession = session
|
||||
phase = .signingIn
|
||||
Log.sync.notice("开始登录:\(provider.rawValue, privacy: .public)")
|
||||
if !session.start() {
|
||||
phase = .failed(tr("无法打开登录窗口"))
|
||||
authSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func cancelSignIn() {
|
||||
authSession?.cancel()
|
||||
authSession = nil
|
||||
if phase == .signingIn { phase = .idle }
|
||||
}
|
||||
|
||||
private func finishSignIn(callback: URL?, error: Error?, verifier: String) {
|
||||
authSession = nil
|
||||
anchorProvider = nil
|
||||
if let error {
|
||||
let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin
|
||||
phase = cancelled ? .idle : .failed(error.localizedDescription)
|
||||
return
|
||||
}
|
||||
guard let callback else {
|
||||
phase = .failed(SyncError.invalidCallback.localizedDescription)
|
||||
return
|
||||
}
|
||||
switch SyncClient.parseCallback(callback) {
|
||||
case .failure(.cancelled):
|
||||
phase = .idle
|
||||
case .failure(let error):
|
||||
phase = .failed(error.localizedDescription)
|
||||
case .success(let code):
|
||||
Task { await exchange(code: code, verifier: verifier) }
|
||||
}
|
||||
}
|
||||
|
||||
private func exchange(code: String, verifier: String) async {
|
||||
do {
|
||||
let session = try await client.exchange(code: code, verifier: verifier, device: Self.deviceName)
|
||||
try tokens.write(session.token)
|
||||
token = session.token
|
||||
user = session.user
|
||||
lastSyncedVersion = 0
|
||||
lastKnownDocument = nil
|
||||
conflict = nil
|
||||
phase = .idle
|
||||
Log.sync.notice("登录成功:\(session.user.providers.joined(separator: ","), privacy: .public)")
|
||||
beginObserving()
|
||||
await reconcileAfterSignIn()
|
||||
} catch {
|
||||
Log.sync.error("登录失败:\(error.localizedDescription, privacy: .public)")
|
||||
phase = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 第一次登录:云端没有就上传;本机还是出厂设置就直接用云端;两边都有且不同就问用户
|
||||
private func reconcileAfterSignIn() async {
|
||||
guard let token else { return }
|
||||
phase = .syncing
|
||||
do {
|
||||
let local = settings.exportDocument()
|
||||
guard let remote = try await client.fetchSettings(token: token, as: SettingsDocument.self) else {
|
||||
phase = .idle
|
||||
await push()
|
||||
return
|
||||
}
|
||||
if remote.document == local || local == AppSettings.defaultDocument() {
|
||||
applyRemote(remote)
|
||||
} else {
|
||||
pendingRemote = remote
|
||||
conflict = Conflict(remoteUpdatedAt: remote.updatedAt, remoteDevice: remote.device)
|
||||
}
|
||||
phase = .idle
|
||||
} catch {
|
||||
handle(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func resolveConflict(useRemote: Bool) {
|
||||
guard conflict != nil else { return }
|
||||
conflict = nil
|
||||
if useRemote, let remote = pendingRemote {
|
||||
applyRemote(remote)
|
||||
} else {
|
||||
Task { await push() }
|
||||
}
|
||||
pendingRemote = nil
|
||||
}
|
||||
|
||||
// MARK: 退出
|
||||
|
||||
public func signOut() {
|
||||
if let token {
|
||||
Task { [client] in try? await client.logout(token: token) }
|
||||
}
|
||||
clearLocalSession()
|
||||
Log.sync.notice("已退出登录")
|
||||
}
|
||||
|
||||
public func deleteAccount() {
|
||||
guard let token else { return }
|
||||
phase = .syncing
|
||||
Task {
|
||||
do {
|
||||
try await client.deleteAccount(token: token)
|
||||
clearLocalSession()
|
||||
Log.sync.notice("已删除云端账号")
|
||||
} catch {
|
||||
handle(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearLocalSession() {
|
||||
pushTask?.cancel()
|
||||
pullTask?.cancel()
|
||||
pushTask = nil
|
||||
pullTask = nil
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
tokens.delete()
|
||||
token = nil
|
||||
user = nil
|
||||
conflict = nil
|
||||
pendingRemote = nil
|
||||
lastSyncedAt = nil
|
||||
lastSyncedVersion = 0
|
||||
lastKnownDocument = nil
|
||||
observing = false
|
||||
phase = .idle
|
||||
}
|
||||
|
||||
// MARK: 同步
|
||||
|
||||
/// 用户点“立即同步”:有本机改动就推,否则拉
|
||||
public func syncNow() {
|
||||
guard isSignedIn, conflict == nil else { return }
|
||||
if lastKnownDocument != settings.exportDocument() {
|
||||
pushTask?.cancel()
|
||||
pushTask = nil
|
||||
Task { await push() }
|
||||
} else {
|
||||
pullTask?.cancel()
|
||||
pullTask = nil
|
||||
Task { await pull() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 唤醒或打开账号页时稍后拉一次,合并短时间内的多次触发
|
||||
func pullSoon() {
|
||||
guard isSignedIn, pullTask == nil else { return }
|
||||
pullTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.pullTask = nil
|
||||
await self.pull()
|
||||
}
|
||||
}
|
||||
|
||||
private func beginObserving() {
|
||||
guard !observing else { return }
|
||||
observing = true
|
||||
observeSettings()
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: Self.pullInterval, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.pullSoon() }
|
||||
}
|
||||
timer?.tolerance = 60
|
||||
}
|
||||
|
||||
private func observeSettings() {
|
||||
guard observing else { return }
|
||||
withObservationTracking {
|
||||
_ = settings.exportDocument()
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self, self.observing else { return }
|
||||
self.schedulePush()
|
||||
self.observeSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func schedulePush() {
|
||||
pushTask?.cancel()
|
||||
pushTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.pushDelay)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.pushTask = nil
|
||||
await self.push()
|
||||
}
|
||||
}
|
||||
|
||||
private func push() async {
|
||||
guard let token, conflict == nil else { return }
|
||||
let document = settings.exportDocument()
|
||||
guard document != lastKnownDocument else { return }
|
||||
phase = .syncing
|
||||
do {
|
||||
let receipt = try await client.putSettings(token: token, document: document, device: Self.deviceName)
|
||||
lastKnownDocument = document
|
||||
lastSyncedVersion = receipt.version
|
||||
lastSyncedAt = receipt.updatedAt
|
||||
phase = .idle
|
||||
Log.sync.info("已上传设置,版本 \(receipt.version)")
|
||||
} catch {
|
||||
handle(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func pull() async {
|
||||
guard let token, conflict == nil, pushTask == nil else { return }
|
||||
do {
|
||||
guard let remote = try await client.fetchSettings(token: token, as: SettingsDocument.self) else { return }
|
||||
if remote.version != lastSyncedVersion {
|
||||
applyRemote(remote)
|
||||
Log.sync.info("已套用云端设置,版本 \(remote.version)")
|
||||
}
|
||||
if case .failed = phase { phase = .idle }
|
||||
} catch {
|
||||
handle(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyRemote(_ remote: RemoteSettings<SettingsDocument>) {
|
||||
// 先记下再套用:套用触发的观察回调会发现内容一致,不会再推回去
|
||||
lastKnownDocument = remote.document
|
||||
lastSyncedVersion = remote.version
|
||||
lastSyncedAt = remote.updatedAt
|
||||
settings.apply(remote.document)
|
||||
// 文档里不认识的值被跳过后本机内容可能与云端不完全一致,以本机实际内容为准避免反复推送
|
||||
lastKnownDocument = settings.exportDocument()
|
||||
}
|
||||
|
||||
private func handle(_ error: Error) {
|
||||
if let syncError = error as? SyncError, syncError == .unauthorized {
|
||||
Log.sync.error("令牌已失效")
|
||||
clearLocalSession()
|
||||
phase = .failed(syncError.localizedDescription)
|
||||
return
|
||||
}
|
||||
Log.sync.error("同步失败:\(error.localizedDescription, privacy: .public)")
|
||||
phase = .failed(error.localizedDescription)
|
||||
}
|
||||
|
||||
static var deviceName: String {
|
||||
Host.current().localizedName ?? "Mac"
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let user = "syncUser"
|
||||
static let lastSyncedAt = "syncLastSyncedAt"
|
||||
static let lastSyncedVersion = "syncLastSyncedVersion"
|
||||
static let lastKnownDocument = "syncLastKnownDocument"
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录窗口需要一个挂靠的窗口:优先主窗口,其次当前主窗口
|
||||
private final class AnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
|
||||
private let anchor: @MainActor () -> NSWindow?
|
||||
|
||||
init(anchor: @escaping @MainActor () -> NSWindow?) {
|
||||
self.anchor = anchor
|
||||
}
|
||||
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
MainActor.assumeIsolated {
|
||||
anchor() ?? NSApp.keyWindow ?? NSApp.windows.first(where: \.isVisible) ?? NSWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ public final class UninstallerController {
|
||||
public private(set) var isRemoving = false
|
||||
public private(set) var outcome: (text: String, isError: Bool)?
|
||||
var chosen: Set<String> = []
|
||||
var removeFromDock = true
|
||||
/// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新
|
||||
private var runningRevision = 0
|
||||
@ObservationIgnored private var observers: [NSObjectProtocol] = []
|
||||
@@ -121,8 +120,8 @@ public final class UninstallerController {
|
||||
self.outcome = (tr("没有移动任何文件\(message.map { tr(":\($0)") } ?? "")"), true)
|
||||
return
|
||||
}
|
||||
var dock = false
|
||||
if self.removeFromDock { dock = Self.removeDockTile(for: app.url) }
|
||||
// 应用已经进了废纸篓,程序坞里的图标只会变成问号,一并移除
|
||||
let dock = Self.removeDockTile(for: app.url)
|
||||
Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),移到废纸篓 \(movedCount) 项")
|
||||
let partial = movedCount < urls.count ? tr(",\(urls.count - movedCount) 项未能移动") : ""
|
||||
self.outcome = (tr("已将 \(app.name) 与 \(movedCount - 1) 项残留移到废纸篓,约 \(Format.bytes(freed, base: .decimal))\(dock ? tr(",已从程序坞移除") : "")\(partial)。需要时可以在废纸篓里放回。"),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import AccountSync
|
||||
|
||||
@Suite struct PKCETests {
|
||||
@Test func challengeMatchesRFCExample() {
|
||||
// RFC 7636 附录 B
|
||||
#expect(PKCE.challenge(for: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
|
||||
}
|
||||
|
||||
@Test func verifierIsURLSafeAndUnique() {
|
||||
let a = PKCE.verifier(), b = PKCE.verifier()
|
||||
#expect(a != b)
|
||||
#expect(a.count == 43)
|
||||
#expect(a.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" })
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct SyncClientTests {
|
||||
@Test func buildsLoginURL() throws {
|
||||
let client = SyncClient(baseURL: URL(string: "https://sync.test/api/v1")!)
|
||||
let url = client.loginURL(provider: .github, challenge: "abc")
|
||||
#expect(url.absoluteString == "https://sync.test/api/v1/auth/github/start?challenge=abc")
|
||||
}
|
||||
|
||||
@Test func parsesCallback() {
|
||||
#expect(SyncClient.parseCallback(URL(string: "openstats://auth/callback?code=xyz")!) == .success("xyz"))
|
||||
#expect(SyncClient.parseCallback(URL(string: "openstats://auth/callback?error=cancelled")!) == .failure(.cancelled))
|
||||
#expect(SyncClient.parseCallback(URL(string: "openstats://auth/callback?error=bad")!) == .failure(.server(0, "bad")))
|
||||
#expect(SyncClient.parseCallback(URL(string: "https://evil.test/?code=xyz")!) == .failure(.invalidCallback))
|
||||
}
|
||||
|
||||
@Test func decodesRemoteSettings() throws {
|
||||
struct Doc: Codable, Equatable, Sendable { var refreshSeconds: Int }
|
||||
let json = #"{"version":3,"updatedAt":"2026-09-15T08:00:00Z","device":"MacBook","document":{"refreshSeconds":5}}"#
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let remote = try decoder.decode(RemoteSettings<Doc>.self, from: Data(json.utf8))
|
||||
#expect(remote.version == 3)
|
||||
#expect(remote.device == "MacBook")
|
||||
#expect(remote.document == Doc(refreshSeconds: 5))
|
||||
#expect(remote.updatedAt == Date(timeIntervalSince1970: 1_789_459_200))
|
||||
}
|
||||
|
||||
@Test func memoryTokenStoreRoundTrips() throws {
|
||||
let store = MemoryTokenStore()
|
||||
#expect(store.read() == nil)
|
||||
try store.write("t")
|
||||
#expect(store.read() == "t")
|
||||
store.delete()
|
||||
#expect(store.read() == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Cleaner
|
||||
|
||||
@Suite struct LocalSnapshotsTests {
|
||||
@Test func parsesTmutilOutput() {
|
||||
let output = """
|
||||
Snapshots for disk /:
|
||||
com.apple.TimeMachine.2026-09-14-120000.local
|
||||
com.apple.TimeMachine.2026-09-15-083015.local
|
||||
com.apple.os.update-ABC
|
||||
"""
|
||||
let snapshots = LocalSnapshots.parse(output)
|
||||
#expect(snapshots.map(\.identifier) == ["2026-09-15-083015", "2026-09-14-120000"])
|
||||
#expect(snapshots.allSatisfy { $0.date != nil })
|
||||
#expect(LocalSnapshots.parse("Snapshots for disk /:\n").isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct VolumeVerifierTests {
|
||||
@Test func interpretsDiskutilOutput() {
|
||||
let ok = VolumeVerifier.interpret(status: 0, output: "Checking the fsroot tree\nThe volume /dev/rdisk3s1 appears to be OK\nFile system check exit code is 0\n")
|
||||
#expect(ok.ok)
|
||||
let bad = VolumeVerifier.interpret(status: 8, output: "Checking the fsroot tree\nerror: btn: invalid key order\nFile system check exit code is 8\n")
|
||||
#expect(!bad.ok)
|
||||
#expect(bad.summary.contains("invalid key order"))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct SpaceScannerTests {
|
||||
private func scratch() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("openstats-space-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
private func write(_ url: URL, bytes: Int) throws {
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data(repeating: 0xAB, count: bytes).write(to: url)
|
||||
}
|
||||
|
||||
@Test func sizesFoldersAndFindsLargestFiles() throws {
|
||||
let root = try scratch()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let big = 60_000_000
|
||||
try write(root.appendingPathComponent("Movies/holiday.mov"), bytes: big)
|
||||
try write(root.appendingPathComponent("Documents/notes.txt"), bytes: 1_000)
|
||||
try write(root.appendingPathComponent("Documents/Photos.photoslibrary/data/1.jpg"), bytes: big)
|
||||
try write(root.appendingPathComponent("Documents/Photos.photoslibrary/data/2.jpg"), bytes: big)
|
||||
try write(root.appendingPathComponent("loose.bin"), bytes: big)
|
||||
|
||||
nonisolated(unsafe) var reports = 0
|
||||
let result = try SpaceScanner.scan(root: root) { _ in reports += 1 }
|
||||
#expect(result.folders.first?.name == "Documents")
|
||||
#expect(result.folders.map(\.name).contains("Movies"))
|
||||
#expect(result.totalBytes >= UInt64(4 * big))
|
||||
#expect(reports > 0)
|
||||
// 图库按整体算一个,里面的照片不单独出现
|
||||
let names = result.largestFiles.map(\.name)
|
||||
#expect(names.first == "Photos.photoslibrary")
|
||||
#expect(names.contains("holiday.mov") && names.contains("loose.bin"))
|
||||
#expect(!names.contains("1.jpg"))
|
||||
#expect(!names.contains("notes.txt"))
|
||||
}
|
||||
|
||||
@Test func trashRulesStayInsideHomeAndAwayFromLibrary() {
|
||||
let home = URL(fileURLWithPath: "/Users/tester")
|
||||
#expect(SpaceScanner.canTrash(home.appendingPathComponent("Downloads/big.dmg"), home: home))
|
||||
#expect(SpaceScanner.canTrash(home.appendingPathComponent("Movies/a/b.mov"), home: home))
|
||||
#expect(!SpaceScanner.canTrash(home.appendingPathComponent("Library/Caches/x"), home: home))
|
||||
#expect(!SpaceScanner.canTrash(home.appendingPathComponent("Downloads"), home: home))
|
||||
#expect(!SpaceScanner.canTrash(home.appendingPathComponent(".docker/data.raw"), home: home))
|
||||
#expect(!SpaceScanner.canTrash(URL(fileURLWithPath: "/Applications/Xcode.app"), home: home))
|
||||
#expect(!SpaceScanner.canTrash(home.appendingPathComponent("Downloads/../../other/file"), home: home))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Testing
|
||||
@testable import HelperShared
|
||||
|
||||
@Suite struct LocalSnapshotCommandTests {
|
||||
@Test func acceptsOnlyDateIdentifiers() {
|
||||
#expect(LocalSnapshotCommand.isValidIdentifier("2026-09-14-120000"))
|
||||
#expect(!LocalSnapshotCommand.isValidIdentifier("2026-09-14"))
|
||||
#expect(!LocalSnapshotCommand.isValidIdentifier("2026-09-14-120000; rm -rf /"))
|
||||
#expect(!LocalSnapshotCommand.isValidIdentifier("2026-09-14-12000a"))
|
||||
#expect(LocalSnapshotCommand.shellCommand(identifiers: ["2026-09-14-120000"]) == "/usr/bin/tmutil deletelocalsnapshots 2026-09-14-120000")
|
||||
}
|
||||
}
|
||||
@@ -172,27 +172,60 @@ struct LiveSamplerTests {
|
||||
}
|
||||
|
||||
@Suite struct GeoParsingTests {
|
||||
@Test func parsesIpinfoResponse() throws {
|
||||
let json = #"{"ip":"82.139.234.155","city":"Frankfurt am Main","country":"DE","org":"AS151338 POLONETWORK LIMITED"}"#
|
||||
let result = try #require(PublicAddressLookup.parseGeo(Data(json.utf8)))
|
||||
#expect(result.ipv4 == "82.139.234.155")
|
||||
#expect(result.ipv6 == nil)
|
||||
#expect(result.countryCode == "DE")
|
||||
#expect(result.city == "Frankfurt am Main")
|
||||
#expect(result.asn == "AS151338")
|
||||
#expect(result.organization == "POLONETWORK LIMITED")
|
||||
@Test func parsesIpapiResponse() throws {
|
||||
let json = #"{"ip":"32.5.140.2","is_bogon":false,"company":"AT&T Global Network Services, LLC","asn":"AS7018 AT&T Enterprises, LLC","city":"New York City","region":"New York","country":"United States","lat":40.7,"lon":-74.0,"timezone":"America/New_York"}"#
|
||||
let info = try #require(PublicAddressLookup.parseGeo(Data(json.utf8)))
|
||||
#expect(info.countryCode == "US")
|
||||
#expect(info.city == "New York City")
|
||||
#expect(info.region == "New York")
|
||||
#expect(info.asn == "AS7018")
|
||||
#expect(info.organization == "AT&T Global Network Services, LLC")
|
||||
}
|
||||
|
||||
@Test func fallsBackToASNOrganizationAndRejectsBadCountry() throws {
|
||||
let json = #"{"ip":"2606:4700:4700::1111","asn":"Cloudflare","country":"../../etc"}"#
|
||||
let info = try #require(PublicAddressLookup.parseGeo(Data(json.utf8)))
|
||||
#expect(info.countryCode == nil)
|
||||
#expect(info.asn == nil)
|
||||
#expect(info.organization == "Cloudflare")
|
||||
}
|
||||
|
||||
@Test func parsesCleanIPResponse() throws {
|
||||
let json = #"{"ok":true,"ip":"32.5.140.2","hostname":"host.example.net","geo":{"country":"美国","country_code":"US","country_en":"United States","region":"佛罗里达州","city":"玛丽湖"},"geo_sources":[{"source":"maxmind","country_code":"US"},{"source":"ipinfo","region":"Florida","city":"Lake Mary"}],"network":{"asn":7018,"asn_name":"AT&T Enterprises, LLC","isp":"AT&T Enterprises, LLC","network_type":"Residential","connection_type":"Cable/DSL","native_or_broadcast":"NATIVE"},"risk":{"risk_score":2,"risk_label":"Very Clean","is_vpn":false,"is_proxy":false,"is_datacenter":false,"is_abuser":true},"purity":{"score":98,"grade":"A+","confidence":100,"ip_type":"Residential IP","is_native":true,"residential_probability":98,"recommendation":"该 IP 整体较干净"}}"#
|
||||
let info = try #require(PublicAddressLookup.parseCleanIP(Data(json.utf8)))
|
||||
#expect(info.countryCode == "US")
|
||||
#expect(info.city == "玛丽湖" && info.cityEnglish == "Lake Mary" && info.regionEnglish == "Florida")
|
||||
#expect(info.asn == "AS7018")
|
||||
#expect(info.organization == "AT&T Enterprises, LLC")
|
||||
#expect(info.hostname == "host.example.net")
|
||||
#expect(info.networkType == "Residential" && info.connectionType == "Cable/DSL")
|
||||
#expect(info.ipType == "Residential IP" && info.isNative == true && info.residentialProbability == 98)
|
||||
#expect(info.purity == PublicAddresses.Purity(score: 98, grade: "A+", confidence: 100, recommendation: "该 IP 整体较干净"))
|
||||
#expect(info.risk == PublicAddresses.Risk(score: 2, label: "Very Clean", flags: ["abuser"]))
|
||||
#expect(info.reportURL?.absoluteString == "https://cleanip.io/32.5.140.2")
|
||||
#expect(PublicAddressLookup.parseCleanIP(Data(#"{"ok":false,"error":"bad"}"#.utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test func parsesDBIPAndIpinfo() throws {
|
||||
let dbip = try #require(PublicAddressLookup.parseDBIP(Data(#"{"ipAddress":"32.5.140.2","countryCode":"US","stateProv":"New York","city":"New York"}"#.utf8)))
|
||||
#expect(dbip.countryCode == "US" && dbip.region == "New York" && dbip.city == "New York" && dbip.asn == nil)
|
||||
let ipinfo = try #require(PublicAddressLookup.parseIpinfo(Data(#"{"ip":"82.139.234.155","city":"Frankfurt am Main","region":"Hesse","country":"DE","org":"AS151338 POLONETWORK LIMITED"}"#.utf8)))
|
||||
#expect(ipinfo.countryCode == "DE" && ipinfo.city == "Frankfurt am Main")
|
||||
#expect(ipinfo.asn == "AS151338" && ipinfo.organization == "POLONETWORK LIMITED")
|
||||
}
|
||||
|
||||
@Test func mapsCountryNames() {
|
||||
#expect(PublicAddressLookup.countryCode(fromName: "Germany") == "DE")
|
||||
#expect(PublicAddressLookup.countryCode(fromName: "China") == "CN")
|
||||
#expect(PublicAddressLookup.countryCode(fromName: "Hong Kong") == "HK")
|
||||
#expect(PublicAddressLookup.countryCode(fromName: "south korea") == "KR")
|
||||
#expect(PublicAddressLookup.countryCode(fromName: "Atlantis") == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsUnsafeCountryCode() {
|
||||
#expect(PublicAddressLookup.validCountryCode("cn") == "CN")
|
||||
#expect(PublicAddressLookup.validCountryCode("../x") == nil)
|
||||
#expect(PublicAddressLookup.validCountryCode("USA") == nil)
|
||||
let json = #"{"ip":"2606:4700:4700::1111","country":"../../etc","org":"Cloudflare"}"#
|
||||
let result = PublicAddressLookup.parseGeo(Data(json.utf8))
|
||||
#expect(result?.ipv6 == "2606:4700:4700::1111")
|
||||
#expect(result?.countryCode == nil)
|
||||
#expect(result?.asn == nil)
|
||||
#expect(result?.organization == "Cloudflare")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Metrics
|
||||
@testable import Metrics
|
||||
import Testing
|
||||
@testable import OpenStatsUI
|
||||
|
||||
@@ -21,6 +21,10 @@ private func isolatedDefaults() -> UserDefaults {
|
||||
#expect(settings.panelTab == .overview)
|
||||
#expect(settings.menuBarItems == [.cpu, .memory, .network])
|
||||
#expect(settings.probeEnabled && settings.probeInBackground && settings.autoCheckUpdates)
|
||||
// 从未调整过弹窗区块时,默认隐藏的区块生效;走势默认 1 分钟
|
||||
#expect(settings.hiddenPopoverSections == PopoverSection.hiddenByDefault)
|
||||
#expect(!settings.isVisible(.cpuCores) && settings.isVisible(.cpuHeatmap))
|
||||
#expect(settings.cpuChartSeconds == 60)
|
||||
}
|
||||
|
||||
@Test func persistsChanges() {
|
||||
@@ -190,6 +194,43 @@ private func isolatedDefaults() -> UserDefaults {
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct CoreRoleTests {
|
||||
@Test func describesEachTierInPlainWords() {
|
||||
let fast = CPUCluster(id: 0, name: "超级核", coreIndices: [12, 13])
|
||||
let middle = CPUCluster(id: 1, name: "性能核", coreIndices: [4, 5])
|
||||
let slow = CPUCluster(id: 2, name: "能效核", coreIndices: [0, 1])
|
||||
let three = CPUTopology(brand: "Apple", logicalCores: 6, clusters: [fast, middle, slow])
|
||||
#expect(fast.role(in: three) == "最快,重活优先交给它们")
|
||||
#expect(middle.role(in: three) == "速度与省电介于两者之间")
|
||||
#expect(slow.role(in: three) == "更省电,负责后台和轻量任务")
|
||||
// 只有一类核心(Intel)时没有分工可言
|
||||
let single = CPUTopology(brand: "Intel", logicalCores: 8, clusters: [CPUCluster(id: 0, name: "核心", coreIndices: Array(0..<8))])
|
||||
#expect(single.clusters[0].role(in: single) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct TimedLineChartTests {
|
||||
@Test func positionsByTimeAndBreaksAtGaps() {
|
||||
let end = Date(timeIntervalSince1970: 1_000)
|
||||
let points = [
|
||||
TimedValue(date: end.addingTimeInterval(-70), value: 0.9), // 超出时长,不画
|
||||
TimedValue(date: end.addingTimeInterval(-60), value: 0),
|
||||
TimedValue(date: end.addingTimeInterval(-55), value: 1),
|
||||
TimedValue(date: end.addingTimeInterval(-50), value: 0.5),
|
||||
TimedValue(date: end.addingTimeInterval(-10), value: 0.5), // 与上一点隔了 40 秒(睡眠),断开
|
||||
TimedValue(date: end.addingTimeInterval(-5), value: 0.5),
|
||||
TimedValue(date: end, value: 0.5),
|
||||
]
|
||||
let segments = TimedLineChart.segments(points, duration: 60, end: end, in: CGSize(width: 120, height: 40))
|
||||
#expect(segments.count == 2)
|
||||
#expect(segments[0].map(\.x) == [0, 10, 20])
|
||||
#expect(segments[1].map(\.x) == [100, 110, 120])
|
||||
// 0 在底、1 在顶,越大越靠上
|
||||
#expect(segments[0][0].y > segments[0][2].y && segments[0][2].y > segments[0][1].y)
|
||||
#expect(TimedLineChart.segments([], duration: 60, end: end, in: CGSize(width: 120, height: 40)).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct AlertTrackerTests {
|
||||
@Test func firesOnceAfterSustainAndRespectsCooldown() {
|
||||
var tracker = AlertTracker()
|
||||
@@ -250,3 +291,99 @@ private func isolatedDefaults() -> UserDefaults {
|
||||
#expect(HotKey(keyCode: 0x7A, modifiers: [.command], key: "\u{F704}").display == "⌘F1")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite struct SettingsDocumentTests {
|
||||
@Test func exportsAndAppliesRoundTrip() {
|
||||
let source = AppSettings(defaults: isolatedDefaults())
|
||||
source.menuBarItems = [.gpu, .temperature]
|
||||
source.menuBarStyle = .ring
|
||||
source.styleOverrides = [.gpu: .pie]
|
||||
source.refreshSeconds = 5
|
||||
source.useFahrenheit = true
|
||||
source.hiddenPopoverSections = [.cpuHeatmap, .networkDNS]
|
||||
source.probeTarget = .aliyun
|
||||
source.enabledAlerts = [.cpuLoad]
|
||||
source.alertCPULoad = 90
|
||||
source.hotKeys = [.showProcesses: HotKey(keyCode: 35, modifiers: [.command, .option], key: "p")]
|
||||
source.panelTab = .thermal
|
||||
|
||||
let document = source.exportDocument()
|
||||
let target = AppSettings(defaults: isolatedDefaults())
|
||||
target.panelTab = .cleaner
|
||||
target.apply(document)
|
||||
|
||||
#expect(target.menuBarItems == [.gpu, .temperature])
|
||||
#expect(target.menuBarStyle == .ring)
|
||||
#expect(target.styleOverrides == [.gpu: .pie])
|
||||
#expect(target.refreshSeconds == 5)
|
||||
#expect(target.useFahrenheit)
|
||||
#expect(target.hiddenPopoverSections == [.cpuHeatmap, .networkDNS])
|
||||
#expect(target.probeTarget == .aliyun)
|
||||
#expect(target.enabledAlerts == [.cpuLoad])
|
||||
#expect(target.alertCPULoad == 90)
|
||||
#expect(target.hotKeys[.showProcesses]?.keyCode == 35)
|
||||
// 当前页签属于本机状态,不随文档走
|
||||
#expect(target.panelTab == .cleaner)
|
||||
#expect(target.exportDocument() == document)
|
||||
}
|
||||
|
||||
@Test func skipsUnknownAndInvalidValues() {
|
||||
let settings = AppSettings(defaults: isolatedDefaults())
|
||||
var document = SettingsDocument()
|
||||
document.menuBarItems = ["cpu", "hologram"]
|
||||
document.menuBarStyle = "neon"
|
||||
document.refreshSeconds = 7
|
||||
document.probeTarget = "mars"
|
||||
document.language = "english"
|
||||
settings.apply(document)
|
||||
#expect(settings.menuBarItems == [.cpu])
|
||||
#expect(settings.menuBarStyle == .stacked)
|
||||
#expect(settings.refreshSeconds == 2)
|
||||
#expect(settings.probeTarget == .cloudflare)
|
||||
#expect(settings.language == .english)
|
||||
settings.language = .system
|
||||
}
|
||||
|
||||
@Test func documentSurvivesJSONWithMissingFields() throws {
|
||||
let data = Data(#"{"schema":1,"refreshSeconds":3,"futureField":true}"#.utf8)
|
||||
let document = try JSONDecoder().decode(SettingsDocument.self, from: data)
|
||||
#expect(document.refreshSeconds == 3)
|
||||
#expect(document.menuBarItems == nil)
|
||||
let settings = AppSettings(defaults: isolatedDefaults())
|
||||
settings.apply(document)
|
||||
#expect(settings.refreshSeconds == 3)
|
||||
#expect(settings.menuBarItems == [.cpu, .memory, .network])
|
||||
}
|
||||
|
||||
@Test func freshSettingsEqualDefaultDocument() {
|
||||
let settings = AppSettings(defaults: isolatedDefaults())
|
||||
#expect(settings.exportDocument() == AppSettings.defaultDocument())
|
||||
settings.colorizeHighLoad = true
|
||||
#expect(settings.exportDocument() != AppSettings.defaultDocument())
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct ActivityRankingTests {
|
||||
@Test func smoothsAndKeepsRecentlyActiveEntries() {
|
||||
var ranking = ActivityRanking()
|
||||
ranking.update(["a": 10_000, "b": 100_000])
|
||||
#expect(ranking.ranked(limit: 5).map(\.id) == ["b", "a"])
|
||||
// b 突然安静,a 继续:b 的分数衰减但仍在榜上,a 慢慢追上
|
||||
for _ in 0..<3 { ranking.update(["a": 10_000]) }
|
||||
let entries = ranking.ranked(limit: 5)
|
||||
#expect(entries.map(\.id).contains("b"))
|
||||
#expect(entries.first?.id == "b")
|
||||
for _ in 0..<6 { ranking.update(["a": 10_000]) }
|
||||
#expect(ranking.ranked(limit: 5).first?.id == "a")
|
||||
// 长时间没有活动后从榜上移除
|
||||
for _ in 0..<40 { ranking.update([:]) }
|
||||
#expect(ranking.isEmpty)
|
||||
}
|
||||
|
||||
@Test func limitsAndBreaksTiesByID() {
|
||||
var ranking = ActivityRanking()
|
||||
ranking.update(["x": 5_000, "y": 5_000, "z": 9_000])
|
||||
#expect(ranking.ranked(limit: 2).map(\.id) == ["z", "x"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user