- 账号与同步: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 与译文同步
111 lines
5.1 KiB
Swift
111 lines
5.1 KiB
Swift
import Foundation
|
|
import HelperShared
|
|
import Localization
|
|
import Metrics
|
|
import Observation
|
|
|
|
/// 刷新 DNS、释放内存、设置 DNS 服务器:已安装辅助工具时直接执行,否则弹出系统管理员授权
|
|
@MainActor
|
|
@Observable
|
|
public final class MaintenanceController {
|
|
public struct Outcome: Equatable {
|
|
let text: String
|
|
let isError: Bool
|
|
}
|
|
|
|
public private(set) var running: MaintenanceCommand?
|
|
public private(set) var outcomes: [MaintenanceCommand: Outcome] = [:]
|
|
public private(set) var isApplyingDNS = false
|
|
public private(set) var dnsOutcome: Outcome?
|
|
|
|
@ObservationIgnored private let helper: HelperClient
|
|
|
|
init(helper: HelperClient) {
|
|
self.helper = helper
|
|
}
|
|
|
|
func run(_ command: MaintenanceCommand) async {
|
|
guard running == nil else { return }
|
|
running = command
|
|
defer { running = nil }
|
|
|
|
let before = MemorySampler().sample()
|
|
helper.refreshStatus()
|
|
let prompt = command == .flushDNS ? tr("OpenStats 需要管理员权限来刷新 DNS 缓存。") : tr("OpenStats 需要管理员权限来释放内存。")
|
|
let error = helper.isReady
|
|
? await helper.run(command)
|
|
: await Self.runWithAdministratorPrompt(shell: command.shellCommand, prompt: prompt)
|
|
|
|
if let error {
|
|
Log.app.error("\(command.rawValue, privacy: .public) 失败:\(error, privacy: .public)")
|
|
outcomes[command] = Outcome(text: error, isError: error != Self.cancelled)
|
|
return
|
|
}
|
|
switch command {
|
|
case .flushDNS:
|
|
outcomes[command] = Outcome(text: tr("DNS 缓存已刷新"), isError: false)
|
|
case .purgeMemory:
|
|
let after = MemorySampler().sample()
|
|
let freed = (before?.cached ?? 0) > (after?.cached ?? 0) ? before!.cached - after!.cached : 0
|
|
outcomes[command] = Outcome(text: freed > 0 ? tr("已释放 \(Format.bytes(freed)) 缓存内存") : tr("内存已整理"), isError: false)
|
|
}
|
|
}
|
|
|
|
/// 为网络服务设置 DNS(空数组为恢复自动),完成后刷新 DNS 缓存
|
|
func setDNSServers(_ servers: [String], service: String) async {
|
|
guard !isApplyingDNS else { return }
|
|
guard DNSConfiguration.isValidServiceName(service), servers.count <= DNSConfiguration.maxServers,
|
|
servers.allSatisfy(DNSConfiguration.isValidAddress) else {
|
|
dnsOutcome = Outcome(text: tr("DNS 地址格式不正确"), isError: true)
|
|
return
|
|
}
|
|
isApplyingDNS = true
|
|
dnsOutcome = nil
|
|
defer { isApplyingDNS = false }
|
|
|
|
let error: String?
|
|
if let version = await helper.remoteProtocolVersion(), version >= 3 {
|
|
error = await helper.setDNSServers(service: service, servers: servers)
|
|
} else {
|
|
// killall 在 mDNSResponder 恰好重启时可能返回非 0,不视为失败
|
|
let shell = DNSConfiguration.shellCommand(service: service, servers: servers)
|
|
+ " && /usr/bin/dscacheutil -flushcache && (/usr/bin/killall -HUP mDNSResponder || true)"
|
|
error = await Self.runWithAdministratorPrompt(shell: shell, prompt: tr("OpenStats 需要管理员权限来修改“\(service)”的 DNS。"))
|
|
}
|
|
if let error {
|
|
Log.network.error("设置 DNS 失败:\(error, privacy: .public)")
|
|
dnsOutcome = Outcome(text: error, isError: error != Self.cancelled)
|
|
} else {
|
|
dnsOutcome = Outcome(text: servers.isEmpty ? tr("已恢复自动获取 DNS") : tr("DNS 已设置为 \(servers.joined(separator: tr("、")))"), isError: false)
|
|
}
|
|
}
|
|
|
|
nonisolated static var cancelled: String { tr("已取消") }
|
|
|
|
/// 未安装辅助工具时的回退:通过 AppleScript 请求一次性管理员授权执行命令。
|
|
/// 命令只由固定路径与已校验的参数拼成,这里再做 AppleScript 字符串转义
|
|
static func runWithAdministratorPrompt(shell: String, prompt: String) async -> String? {
|
|
let escape = { (text: String) in text.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") }
|
|
let script = "do shell script \"\(escape(shell))\" with administrator privileges with prompt \"\(escape(prompt))\""
|
|
return await Task.detached {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
process.arguments = ["-e", script]
|
|
let errorPipe = Pipe()
|
|
process.standardError = errorPipe
|
|
process.standardOutput = Pipe()
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
return error.localizedDescription
|
|
}
|
|
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
|
process.waitUntilExit()
|
|
guard process.terminationStatus != 0 else { return nil }
|
|
let message = String(decoding: data, as: UTF8.self)
|
|
// -128:用户在授权对话框中点了取消
|
|
return message.contains("-128") ? cancelled : message.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}.value
|
|
}
|
|
}
|