D8w%S%CQoucsc2C^B
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 ScanResult
- nonisolated init() {}
-
- var selectedDisk: DiskInfo? {
- disks.first { $0.id == selectedDiskID }
+ init(isDemo: Bool = ProcessInfo.processInfo.arguments.contains("--demo"),
+ scanner: @escaping @Sendable () async throws -> ScanResult = { try await SmartCtlService.shared.scan() }) {
+ self.isDemo = isDemo
+ self.scanner = scanner
+ let savedAppearance = AppAppearance(rawValue: UserDefaults.standard.string(forKey: "appearance") ?? "system") ?? .system
+ appearance = isDemo && ProcessInfo.processInfo.arguments.contains("--light") ? .light : savedAppearance
+ let saved = UserDefaults.standard.object(forKey: "refreshInterval") as? Double ?? 60
+ refreshInterval = [0, 30, 60, 300].contains(saved) ? saved : 60
+ if isDemo {
+ disks = DemoData.disks
+ selectedDiskID = disks.first?.id
+ lastRefreshed = .now
+ }
}
+ var selectedDisk: DiskInfo? { disks.first { $0.id == selectedDiskID } }
func startAutoRefresh() {
- timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
- self?.refresh()
+ stopAutoRefresh()
+ guard refreshInterval > 0, !isDemo else { return }
+ timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.refresh() }
}
}
-
- func stopAutoRefresh() {
- timer?.invalidate()
- timer = nil
- }
+ func stopAutoRefresh() { timer?.invalidate(); timer = nil }
func refresh() {
- guard !isLoading else { return }
+ guard !isLoading, !isDemo else { return }
isLoading = true
- error = nil
Task {
+ defer { isLoading = false }
do {
- let result = try await SmartCtlService.shared.scan()
- await MainActor.run {
- disks = result
- lastRefreshed = .now
- isLoading = false
- }
- } catch {
- await MainActor.run {
- self.error = error.localizedDescription
- isLoading = false
- }
- }
+ let result = try await scanner()
+ disks = result.disks
+ error = result.warning
+ lastRefreshed = .now
+ if !disks.contains(where: { $0.id == selectedDiskID }) { selectedDiskID = disks.first?.id }
+ } catch { self.error = error.localizedDescription }
}
}
}
diff --git a/Sources/SMARTastic/Services/SmartCtlService.swift b/Sources/SMARTastic/Services/SmartCtlService.swift
index 8839736..5f1dff0 100644
--- a/Sources/SMARTastic/Services/SmartCtlService.swift
+++ b/Sources/SMARTastic/Services/SmartCtlService.swift
@@ -1,340 +1,93 @@
import Foundation
+import Darwin
+
+struct ScanResult {
+ var disks: [DiskInfo]
+ var warning: String?
+}
actor SmartCtlService {
static let shared = SmartCtlService()
- private let smartctl = "/opt/homebrew/bin/smartctl"
-
- private init() {}
-
- func scan() throws -> [DiskInfo] {
- var bySerial: [String: DiskInfo] = [:]
-
- // 1) NVMe via smartctl --scan (IOService paths)
- if let scan = try? run(smartctl, "--scan") {
- for line in scan.components(separatedBy: .newlines) where line.contains("NVMe") {
- if let range = line.range(of: " -d nvme") {
- let path = String(line[.. [String] {
- var disks: [String] = []
- for i in 0...20 {
- let dev = "/dev/disk\(i)"
- guard FileManager.default.isReadableFile(atPath: dev) else { continue }
- guard let info = try? run("/usr/sbin/diskutil", "info", "-plist", dev) else { continue }
- guard info.contains("Virtual") && info.contains("") else { continue }
- disks.append(dev)
- }
- return disks
- }
-
- // MARK: - Disk Reader
-
- private func readDisk(device: String) -> DiskInfo? {
- if let output = try? run(smartctl, "-a", device) {
- if output.contains("NVMe") {
- return parseNVMe(device: device, output: output)
- }
- if output.contains("ATA") || output.contains("Device Model:") {
- return parseATA(device: device, output: output)
- }
- }
- return basicInfo(device: device)
- }
-
- private func basicInfo(device: String) -> DiskInfo? {
- let ident = device.components(separatedBy: "/").last ?? "?"
- let output = try? run("/usr/sbin/diskutil", "info", "-plist", device)
- guard let data = output?.data(using: .utf8),
- let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
- else {
- return DiskInfo(
- id: ident, model: "Unknown", serial: ident, firmware: "-",
- size: "?", driveType: .unknown, interface: "?",
- smartAvailable: false, smartPassed: false,
- temperature: 0, percentageUsed: 0, availableSpare: 0,
- dataReadTB: 0, dataWrittenTB: 0, powerOnHours: 0,
- powerCycles: 0, unsafeShutdowns: 0, mediaErrors: 0
- )
- }
-
- let model = (dict["MediaName"] as? String) ?? "Unknown"
- let serial = (dict["SerialNumber"] as? String) ?? ident
- let totalSize = (dict["TotalSize"] as? UInt64) ?? 0
- let size = formatBytes("\(totalSize)")
-
- return DiskInfo(
- id: serial,
- model: model,
- serial: serial,
- firmware: "-",
- size: size,
- driveType: .hdd,
- interface: "USB",
- smartAvailable: false,
- smartPassed: false,
- temperature: 0,
- percentageUsed: 0,
- availableSpare: 0,
- dataReadTB: 0,
- dataWrittenTB: 0,
- powerOnHours: 0,
- powerCycles: 0,
- unsafeShutdowns: 0,
- mediaErrors: 0
- )
- }
-
- // MARK: - NVMe Parser
-
- private func parseNVMe(device: String) -> DiskInfo? {
- guard let output = try? run(smartctl, "-a", device) else { return nil }
- return parseNVMe(device: device, output: output)
- }
-
- private func parseNVMe(device: String, output: String) -> DiskInfo {
- let info = parseKeyValues(output)
- let smart = parseSmartSection(output, marker: "SMART/Health Information")
-
- let model = info["Model Number"] ?? "Unknown"
- let serial = info["Serial Number"] ?? "-"
- let fw = info["Firmware Version"] ?? "-"
- let size = formatBytes(info["Total NVM Capacity"] ?? "-")
-
- func d(_ key: String) -> Double? {
- if let v = smart[key] ?? info[key] {
- let s = v.replacingOccurrences(of: "[^0-9.,]", with: "", options: .regularExpression).replacingOccurrences(of: ",", with: ".")
- return Double(s)
- }
- return nil
- }
- func i(_ key: String) -> Int? {
- if let v = smart[key] ?? info[key] {
- let s = v.replacingOccurrences(of: "[^0-9]", with: "", options: .regularExpression)
- return Int(s)
- }
- return nil
- }
-
- return DiskInfo(
- id: serial,
- model: model,
- serial: serial,
- firmware: fw,
- size: size,
- driveType: .ssd,
- interface: "NVMe",
- smartAvailable: true,
- smartPassed: output.contains("SMART overall-health self-assessment test result: PASSED"),
- temperature: d("Temperature") ?? 0,
- percentageUsed: d("Percentage Used") ?? 0,
- availableSpare: d("Available Spare") ?? 100,
- dataReadTB: parseDataUnits(smart["Data Units Read"] ?? "0"),
- dataWrittenTB: parseDataUnits(smart["Data Units Written"] ?? "0"),
- powerOnHours: i("Power On Hours") ?? 0,
- powerCycles: i("Power Cycles") ?? 0,
- unsafeShutdowns: i("Unsafe Shutdowns") ?? 0,
- mediaErrors: i("Media and Data Integrity Errors") ?? 0
- )
- }
-
- // MARK: - ATA Parser
-
- private func parseATA(device: String, output: String) -> DiskInfo {
- let info = parseKeyValues(output)
- let attrs = parseATAattributes(output)
-
- let model = info["Device Model"] ?? info["Model Number"] ?? "Unknown"
- let serial = info["Serial Number"] ?? "-"
- let fw = info["Firmware Version"] ?? info["Revision"] ?? "-"
- let sizeRaw = info["User Capacity"] ?? info["Total NVM Capacity"] ?? "-"
- let rotation = info["Rotation Rate"] ?? ""
- let isHDD = rotation.contains("rpm")
- let driveType: DriveType = isHDD ? .hdd : .ssd
- let interface = info["SAT"] ?? info["ATA Version"] ?? "ATA"
-
- let smartPassed = output.contains("SMART overall-health self-assessment test result: PASSED")
- || output.contains("SMART Health Status: OK")
-
- let tempRaw = attrs["194"]?.value ?? attrs["Temperature_Celsius"]?.value ?? "0"
- let temp = Double(tempRaw) ?? 0
-
- let pohRaw = attrs["9"]?.value ?? attrs["Power_On_Hours"]?.value ?? "0"
- let poh = Int(pohRaw) ?? 0
-
- let cyclesRaw = attrs["12"]?.value ?? attrs["Power_Cycle_Count"]?.value ?? "0"
- let cycles = Int(cyclesRaw) ?? 0
-
- let reallocRaw = attrs["5"]?.raw ?? attrs["Reallocated_Sector_Ct"]?.raw ?? "0"
- let realloc = Int(reallocRaw) ?? 0
-
- let pendingRaw = attrs["197"]?.raw ?? attrs["Current_Pending_Sector"]?.raw ?? "0"
- let pending = Int(pendingRaw) ?? 0
-
- let mediaErrors = realloc + pending
- let size = formatBytes(sizeRaw)
-
- return DiskInfo(
- id: serial,
- model: model,
- serial: serial,
- firmware: fw,
- size: size,
- driveType: driveType,
- interface: interface,
- smartAvailable: true,
- smartPassed: smartPassed,
- temperature: temp,
- percentageUsed: 0,
- availableSpare: 100,
- dataReadTB: 0,
- dataWrittenTB: 0,
- powerOnHours: poh,
- powerCycles: cycles,
- unsafeShutdowns: 0,
- mediaErrors: mediaErrors
- )
- }
-
- // MARK: - ATA Attribute Parser
-
- private struct ATAAttr {
- let name: String
- let value: String
- let worst: String
- let threshold: String
- let raw: String
- }
-
- private func parseATAattributes(_ text: String) -> [String: ATAAttr] {
- var attrs: [String: ATAAttr] = [:]
- guard let headerRange = text.range(of: "Vendor Specific SMART Attributes with Thresholds") else { return attrs }
-
- let slice = text[headerRange.upperBound...]
- guard let firstNewline = slice.firstIndex(of: "\n") else { return attrs }
- let block = text[text.index(after: firstNewline)...]
- guard let footerRange = block.range(of: "\n\n") else { return attrs }
- let attrBlock = block[..= 10, let _ = Int(parts[0]) else { continue }
- let id = String(parts[0])
- let name = parts[1...].dropLast(5).joined(separator: " ")
- let val = String(parts[parts.count - 5])
- let worst = String(parts[parts.count - 4])
- let thresh = String(parts[parts.count - 3])
- let raw = String(parts[parts.count - 1])
- let attr = ATAAttr(name: name, value: val, worst: worst, threshold: thresh, raw: raw)
- attrs[id] = attr
- attrs[name] = attr
- }
- return attrs
- }
-
- // MARK: - Shared Parsers
-
- private func parseKeyValues(_ text: String) -> [String: String] {
- var dict: [String: String] = [:]
- for line in text.components(separatedBy: .newlines) {
- if let colon = line.firstIndex(of: ":") {
- let key = line[.. ScanResult {
+ let smartctl = ["/opt/homebrew/bin/smartctl", "/usr/local/bin/smartctl", "/usr/bin/smartctl"]
+ .first { FileManager.default.isExecutableFile(atPath: $0) }
+ let listing = try CommandRunner.run("/usr/sbin/diskutil", ["list", "-plist", "physical"])
+ guard listing.status == 0,
+ let plist = try PropertyListSerialization.propertyList(from: listing.data, format: nil) as? [String: Any],
+ let devices = plist["WholeDisks"] as? [String] else {
+ throw SmartCtlError.commandFailed(loc("error.discovery"))
}
- return dict
- }
-
- private func parseSmartSection(_ text: String, marker: String) -> [String: String] {
- guard let range = text.range(of: marker) else { return [:] }
- return parseKeyValues(String(text[range.lowerBound...]))
- }
-
- private func parseDataUnits(_ raw: String) -> Double {
- let parts = raw.components(separatedBy: "[").map { $0.trimmingCharacters(in: .whitespaces) }
- if parts.count >= 2 {
- let inner = parts[1]
- .replacingOccurrences(of: "]", with: "")
- .replacingOccurrences(of: "TB", with: "")
- .replacingOccurrences(of: "GB", with: "")
- .trimmingCharacters(in: .whitespaces)
- .replacingOccurrences(of: ",", with: ".")
- if let val = Double(inner) {
- return parts[1].contains("GB") ? val / 1000 : val
- }
- }
- if let first = parts.first, let val = Double(first) {
- return val * 512.0 / 1_000_000_000_000.0
+ var disks: [DiskInfo] = []
+ var warnings: [String] = []
+ if smartctl == nil { warnings.append(loc("error.install")) }
+ for identifier in devices {
+ let device = "/dev/" + identifier
+ do {
+ let result = try CommandRunner.run("/usr/sbin/diskutil", ["info", "-plist", device])
+ guard result.status == 0,
+ let info = try PropertyListSerialization.propertyList(from: result.data, format: nil) as? [String: Any] else {
+ throw SmartCtlError.commandFailed(loc("error.discovery"))
+ }
+ var disk = DiskInfo(id: device, model: info["MediaName"] as? String ?? identifier,
+ serial: info["SerialNumber"] as? String,
+ capacityBytes: SmartParser.number(info["TotalSize"]),
+ driveType: (info["SolidState"] as? Bool).map { $0 ? .ssd : .hdd } ?? .unknown,
+ interface: info["BusProtocol"] as? String ?? "—")
+ if let smartctl {
+ do {
+ let output = try CommandRunner.run(smartctl, ["-a", "-j", device])
+ // smartctl uses a bitmask: nonzero often means valid data with health/read warnings.
+ disk = try SmartParser.parse(output.data, device: device, fallback: disk)
+ if output.status != 0 && disk.diagnostic == nil {
+ disk.diagnostic = locf("error.exit_status", output.status)
+ }
+ } catch { disk.diagnostic = error.localizedDescription }
+ } else { disk.diagnostic = loc("error.install") }
+ disks.append(disk)
+ } catch { warnings.append("\(identifier): \(error.localizedDescription)") }
}
- return 0
- }
-
- private func formatBytes(_ raw: String) -> String {
- let cleaned = raw
- .replacingOccurrences(of: "[^0-9.,]", with: "", options: .regularExpression)
- .replacingOccurrences(of: ",", with: ".")
- guard let bytes = Double(cleaned) else { return raw }
- let tb = bytes / 1_000_000_000_000
- if tb >= 1 { return String(format: "%.2f TB", tb) }
- let gb = bytes / 1_000_000_000
- return String(format: "%.1f GB", gb)
+ return ScanResult(disks: disks.sorted { $0.model.localizedStandardCompare($1.model) == .orderedAscending },
+ warning: warnings.isEmpty ? nil : warnings.joined(separator: "\n"))
}
+}
- private func run(_ args: String...) throws -> String {
+struct CommandOutput { let data: Data; let status: Int32 }
+
+enum CommandRunner {
+ /// File-backed output prevents pipe-buffer deadlocks; each command has a hard deadline.
+ static func run(_ executable: String, _ arguments: [String], timeout: TimeInterval = 12) throws -> CommandOutput {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700])
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let outputURL = directory.appendingPathComponent("output")
+ FileManager.default.createFile(atPath: outputURL.path, contents: nil, attributes: [.posixPermissions: 0o600])
+ let handle = try FileHandle(forWritingTo: outputURL)
+ defer { try? handle.close() }
let process = Process()
- process.executableURL = URL(fileURLWithPath: args[0])
- process.arguments = Array(args.dropFirst())
-
- let outputPipe = Pipe()
- let errorPipe = Pipe()
- process.standardOutput = outputPipe
- process.standardError = errorPipe
-
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ process.standardOutput = handle
+ // smartctl supplies diagnostics in JSON; diskutil failures get an actionable app error.
+ process.standardError = FileHandle.nullDevice
try process.run()
- process.waitUntilExit()
-
- let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
-
- if process.terminationStatus != 0 {
- let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
- let err = String(data: errorData, encoding: .utf8) ?? "Unknown error"
- throw SmartCtlError.commandFailed(err)
+ let deadline = ProcessInfo.processInfo.systemUptime + timeout
+ while process.isRunning && ProcessInfo.processInfo.systemUptime < deadline { Thread.sleep(forTimeInterval: 0.02) }
+ if process.isRunning {
+ process.terminate()
+ let grace = ProcessInfo.processInfo.systemUptime + 0.25
+ while process.isRunning && ProcessInfo.processInfo.systemUptime < grace { Thread.sleep(forTimeInterval: 0.01) }
+ if process.isRunning { kill(process.processIdentifier, SIGKILL) }
+ process.waitUntilExit()
+ throw SmartCtlError.commandFailed(locf("error.timeout", URL(fileURLWithPath: executable).lastPathComponent))
}
-
- return String(data: outputData, encoding: .utf8) ?? ""
+ process.waitUntilExit()
+ return CommandOutput(data: try Data(contentsOf: outputURL), status: process.terminationStatus)
}
}
enum SmartCtlError: LocalizedError {
case commandFailed(String)
- var errorDescription: String? {
- switch self {
- case .commandFailed(let msg): return String(format: loc("smartctl_error"), msg)
- }
- }
+ var errorDescription: String? { if case .commandFailed(let message) = self { return message }; return nil }
}
diff --git a/Sources/SMARTastic/Services/SmartParser.swift b/Sources/SMARTastic/Services/SmartParser.swift
new file mode 100644
index 0000000..23f8623
--- /dev/null
+++ b/Sources/SMARTastic/Services/SmartParser.swift
@@ -0,0 +1,65 @@
+import Foundation
+import CoreFoundation
+
+/// Interpret smartctl's JSON, never its locale-dependent human-readable columns.
+enum SmartParser {
+ static func parse(_ data: Data, device: String, fallback: DiskInfo? = nil) throws -> DiskInfo {
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw SmartCtlError.commandFailed(loc("error.invalid_json"))
+ }
+ var disk = fallback ?? DiskInfo(id: device, model: device, driveType: .unknown, interface: "—")
+ disk.model = json["model_name"] as? String ?? json["product"] as? String ?? disk.model
+ disk.serial = nonempty(json["serial_number"]) ?? disk.serial
+ disk.firmware = nonempty(json["firmware_version"]) ?? disk.firmware
+ disk.capacityBytes = number((json["user_capacity"] as? [String: Any])?["bytes"]) ?? number(json["nvme_total_capacity"]) ?? disk.capacityBytes
+ let dev = json["device"] as? [String: Any]
+ disk.interface = dev?["protocol"] as? String ?? disk.interface
+ let nvme = json["nvme_smart_health_information_log"] as? [String: Any]
+ let table = (json["ata_smart_attributes"] as? [String: Any])?["table"] as? [[String: Any]] ?? []
+ if nvme != nil || disk.interface == "NVMe" { disk.driveType = .ssd }
+ else if let rotation = number(json["rotation_rate"]) { disk.driveType = rotation == 0 ? .ssd : .hdd }
+ disk.smartPassed = (json["smart_status"] as? [String: Any])?["passed"] as? Bool
+ disk.smartAvailable = disk.smartPassed != nil || nvme != nil || !table.isEmpty
+ func raw(_ id: Int) -> Double? {
+ number((table.first { ($0["id"] as? Int) == id }?["raw"] as? [String: Any])?["value"])
+ }
+ func integer(_ value: Double?) -> Int? {
+ guard let value, value >= 0, value < Double(Int.max) else { return nil }
+ return Int(value)
+ }
+ disk.temperature = number((json["temperature"] as? [String: Any])?["current"]) ?? number(nvme?["temperature"])
+ // Attribute 194's raw integer can pack min/max values. Only use smartctl's decoded temperature.
+ disk.percentageUsed = number(nvme?["percentage_used"])
+ disk.availableSpare = number(nvme?["available_spare"])
+ disk.spareThreshold = number(nvme?["available_spare_threshold"])
+ disk.criticalWarning = integer(number(nvme?["critical_warning"]))
+ // One NVMe data unit is 1,000 × 512 bytes (NVMe specification).
+ disk.dataReadTB = number(nvme?["data_units_read"]).map { $0 * 512_000 / 1e12 }
+ disk.dataWrittenTB = number(nvme?["data_units_written"]).map { $0 * 512_000 / 1e12 }
+ disk.powerOnHours = integer(number((json["power_on_time"] as? [String: Any])?["hours"]) ?? number(nvme?["power_on_hours"]) ?? raw(9))
+ disk.powerCycles = integer(number(json["power_cycle_count"]) ?? number(nvme?["power_cycles"]) ?? raw(12))
+ disk.unsafeShutdowns = integer(number(nvme?["unsafe_shutdowns"]))
+ if let errors = integer(number(nvme?["media_errors"])) { disk.mediaErrors = errors }
+ else {
+ // ATA IDs are vendor-specific: e.g. 198 can mean Host_Reads_GiB.
+ let errorNames: Set = ["Reallocated_Sector_Ct", "Reallocated_Sector_Count", "Retired_Block_Count", "Current_Pending_Sector", "Offline_Uncorrectable"]
+ let counts = table.filter { errorNames.contains($0["name"] as? String ?? "") }
+ .compactMap { number(($0["raw"] as? [String: Any])?["value"]) }
+ disk.mediaErrors = counts.isEmpty ? nil : integer(counts.reduce(0, +))
+ }
+ let messages = (json["smartctl"] as? [String: Any])?["messages"] as? [[String: Any]] ?? []
+ disk.diagnostic = messages.compactMap { $0["string"] as? String }.joined(separator: "\n")
+ if disk.diagnostic?.isEmpty == true { disk.diagnostic = nil }
+ return disk
+ }
+
+ static func number(_ value: Any?) -> Double? {
+ guard let value = value as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID() else { return nil }
+ let number = value.doubleValue
+ return number.isFinite && number >= 0 ? number : nil
+ }
+ private static func nonempty(_ value: Any?) -> String? {
+ guard let text = value as? String, !text.trimmingCharacters(in: .whitespaces).isEmpty, text != "-" else { return nil }
+ return text
+ }
+}
diff --git a/Sources/SMARTastic/Views/ContentView.swift b/Sources/SMARTastic/Views/ContentView.swift
index 2916ac9..70dc675 100644
--- a/Sources/SMARTastic/Views/ContentView.swift
+++ b/Sources/SMARTastic/Views/ContentView.swift
@@ -1,195 +1,215 @@
import SwiftUI
+import UniformTypeIdentifiers
struct ContentView: View {
@Environment(AppModel.self) private var model
-
+ @State private var titlebarInset: CGFloat = 0
+ @State private var search = ""
+ @State private var exporting = false
+ @State private var report: ReportDocument?
+ @State private var exportError: String?
+
+ private var filteredDisks: [DiskInfo] {
+ model.disks.filter { search.isEmpty || $0.model.localizedCaseInsensitiveContains(search) || $0.interface.localizedCaseInsensitiveContains(search) }
+ }
var body: some View {
+ @Bindable var model = model
NavigationSplitView {
- sidebar
- .navigationSplitViewColumnWidth(min: 380, ideal: 400, max: 460)
+ VStack(spacing: 0) {
+ HStack(spacing: 10) {
+ if let url = AppResources.bundle.url(forResource: "logo", withExtension: "png"), let image = NSImage(contentsOf: url) {
+ Image(nsImage: image).resizable().scaledToFit().frame(width: 38, height: 38).clipShape(RoundedRectangle(cornerRadius: 9))
+ }
+ VStack(alignment: .leading, spacing: 3) {
+ Text("SMARTastic").font(.system(size: 14, weight: .semibold))
+ Text(loc("sidebar.subtitle")).font(.system(size: 12)).foregroundStyle(.secondary)
+ }
+ Spacer()
+ }.padding(18)
+ TextField(loc("search.placeholder"), text: $search)
+ .textFieldStyle(.roundedBorder).padding(.horizontal, 16).padding(.bottom, 12)
+ List(selection: $model.selectedDiskID) {
+ Section(locf(model.disks.count == 1 ? "disk_count_one" : "disk_count_other", model.disks.count)) {
+ ForEach(filteredDisks) { disk in DiskCardView(disk: disk, isSelected: model.selectedDiskID == disk.id).tag(disk.id) }
+ }
+ }.listStyle(.sidebar)
+ if filteredDisks.isEmpty {
+ Text(loc(search.isEmpty ? "empty.no_drives" : "search.empty"))
+ .font(.system(size: 13)).foregroundStyle(.secondary).padding()
+ }
+ Divider()
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(loc("appearance.title")).font(.system(size: 12)).foregroundStyle(.secondary)
+ Spacer()
+ AppearanceToggle()
+ }
+ Picker(loc("refresh.interval"), selection: $model.refreshInterval) {
+ Text(loc("refresh.manual")).tag(0.0)
+ Text(loc("refresh.30s")).tag(30.0)
+ Text(loc("refresh.1m")).tag(60.0)
+ Text(loc("refresh.5m")).tag(300.0)
+ }.disabled(model.isDemo)
+ if let last = model.lastRefreshed {
+ HStack {
+ Text(loc("refresh.updated"))
+ Text(last, style: .time)
+ }.font(.system(size: 12)).foregroundStyle(.secondary)
+ }
+ }.padding(16)
+ }.navigationSplitViewColumnWidth(min: 260, ideal: 290, max: 360)
} detail: {
- detail
+ Group {
+ if let disk = model.selectedDisk {
+ DiskDetailView(disk: disk, isDemo: model.isDemo, scanWarning: model.error)
+ .ignoresSafeArea(.container, edges: .top)
+ } else {
+ VStack(spacing: 0) {
+ if let error = model.error { notice(error, icon: "exclamationmark.triangle", color: .orange) }
+ if model.isLoading {
+ ProgressView(loc("scan.loading")).frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ContentUnavailableView {
+ Label(loc(model.disks.isEmpty ? "empty.no_drives" : "detail.no_selection"), systemImage: "externaldrive")
+ } description: { Text(loc("empty.help")) } actions: {
+ Button(loc("button.scan")) { model.refresh() }.disabled(model.isDemo)
+ }
+ }
+ }
+ }
+ }.background(Color(nsColor: .windowBackgroundColor))
}
- .onAppear {
- if model.disks.isEmpty {
- model.refresh()
- }
- model.startAutoRefresh()
+ .background(WindowChrome())
+ .toolbarBackground(.hidden, for: .windowToolbar)
+ .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { titlebarInset = $0 }
+ .overlay(alignment: .topTrailing) {
+ windowActions.padding(.trailing, 24).padding(.top, 24).offset(y: -titlebarInset)
}
- .onDisappear {
- model.stopAutoRefresh()
+ .fileExporter(isPresented: $exporting, document: report, contentType: .json, defaultFilename: "SMARTastic-report") { result in
+ if case .failure(let error) = result { exportError = error.localizedDescription }
}
+ .alert(loc("report.failed"), isPresented: Binding(get: { exportError != nil }, set: { if !$0 { exportError = nil } })) {
+ Button("OK") { exportError = nil }
+ } message: { Text(exportError ?? "") }
+ .onAppear { if model.lastRefreshed == nil { model.refresh() }; model.startAutoRefresh() }
+ .onDisappear { model.stopAutoRefresh() }
}
-
- // MARK: - Sidebar
-
- private var sidebar: some View {
- VStack(spacing: 0) {
- header
- Divider()
- if let error = model.error {
- errorView(error)
- } else if model.disks.isEmpty && !model.isLoading {
- emptyView
- } else {
- list
- }
- Divider()
- footer
+ private var windowActions: some View {
+ HStack(spacing: 8) {
+ if model.isLoading { ProgressView().controlSize(.small) }
+ Button {
+ do { report = try ReportDocument(disks: model.disks, sampledAt: model.lastRefreshed, demo: model.isDemo, warning: model.error); exporting = true }
+ catch { exportError = error.localizedDescription }
+ } label: { Label(loc("report.export"), systemImage: "square.and.arrow.up") }
+ .disabled(model.disks.isEmpty || model.isLoading)
+ .help(loc("report.help"))
+ .modifier(NativeCircleAction())
+ Button { model.refresh() } label: { Label(loc("button.refresh.help"), systemImage: "arrow.clockwise") }
+ .disabled(model.isLoading || model.isDemo)
+ .modifier(NativeCircleAction())
}
- .background()
}
-
- private var header: some View {
- HStack(spacing: 12) {
- ZStack {
- RoundedRectangle(cornerRadius: 10)
- .fill(.blue.gradient)
- .frame(width: 34, height: 34)
- Image(systemName: "internaldrive")
- .font(.body.weight(.medium))
- .foregroundStyle(.white)
- }
-
- VStack(alignment: .leading, spacing: 1) {
- Text("SMARTastic")
- .font(.headline.weight(.semibold))
- Text(LocalizedStringKey("sidebar.subtitle"), bundle: .module)
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
-
- if model.isLoading {
- ProgressView()
- .scaleEffect(0.7)
- .frame(width: 20, height: 20)
- }
-
- Button { model.refresh() } label: {
- Image(systemName: "arrow.clockwise")
- .font(.body)
- }
- .buttonStyle(.plain)
- .disabled(model.isLoading)
- .help(loc("button.refresh.help"))
- }
- .padding(.horizontal, 16)
- .padding(.vertical, 10)
- }
-
- private var list: some View {
- ScrollView {
- LazyVStack(spacing: 8) {
- ForEach(model.disks) { disk in
- DiskCardView(disk: disk, isSelected: model.selectedDiskID == disk.id)
- .onTapGesture {
- model.selectedDiskID = disk.id
- }
- .padding(.horizontal, 12)
- }
- }
- .padding(.vertical, 12)
- }
- }
-
- private var emptyView: some View {
- VStack(spacing: 16) {
- Spacer()
- Image(systemName: "externaldrive.badge.questionmark")
- .font(.system(size: 36))
- .foregroundStyle(.tertiary)
- Text(LocalizedStringKey("empty.no_drives"), bundle: .module)
- .font(.callout)
- .foregroundStyle(.secondary)
- Button(loc("button.scan")) { model.refresh() }
- .buttonStyle(.borderedProminent)
- .controlSize(.small)
- Spacer()
- }
- .frame(maxWidth: .infinity)
+ private func notice(_ text: String, icon: String, color: Color) -> some View {
+ Label(text, systemImage: icon).font(.system(size: 13)).foregroundStyle(color)
+ .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12).background(color.opacity(0.08))
}
+}
- private func errorView(_ msg: String) -> some View {
- VStack(spacing: 12) {
- Spacer()
- Image(systemName: "exclamationmark.triangle.fill")
- .font(.title2)
- .foregroundStyle(.orange)
- Text(msg)
- .font(.caption)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal)
- Button(loc("button.retry")) { model.refresh() }
- .buttonStyle(.borderedProminent)
- .controlSize(.small)
- Spacer()
+struct ReportDocument: FileDocument {
+ static var readableContentTypes: [UTType] { [.json] }
+ var data: Data
+ init(disks: [DiskInfo], sampledAt: Date?, demo: Bool, warning: String?) throws {
+ struct Report: Encodable {
+ let schemaVersion = 1
+ let sampledAt: Date?
+ let demo: Bool
+ let warning: String?
+ let disks: [DiskInfo]
}
- .frame(maxWidth: .infinity)
+ // Reports are intended for sharing. Serial numbers are omitted by default.
+ let sanitized = disks.map { disk in var copy = disk; copy.serial = nil; return copy }
+ let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]; encoder.dateEncodingStrategy = .iso8601
+ data = try encoder.encode(Report(sampledAt: sampledAt, demo: demo, warning: warning, disks: sanitized))
}
+ init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
+ func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
+}
- private var footer: some View {
- HStack(spacing: 6) {
- if !model.disks.isEmpty {
- HStack(spacing: 4) {
- Circle()
- .fill(.green)
- .frame(width: 6, height: 6)
- Text(verbatim: locf(model.disks.count == 1 ? "disk_count_one" : "disk_count_other", model.disks.count))
- .font(.caption2)
- }
- .foregroundStyle(.secondary)
- if let last = model.lastRefreshed {
- Text("•")
- .foregroundStyle(.tertiary)
- Text(verbatim: locf("time.ago", relative(last)))
- .font(.caption2)
- .foregroundStyle(.tertiary)
- }
- }
-
- Spacer()
+struct AppearanceToggle: View {
+ @Environment(AppModel.self) private var model
+ @Environment(\.colorScheme) private var colorScheme
+ @Namespace private var thumb
- if let last = model.lastRefreshed {
- Text(last, style: .time)
- .font(.caption2)
- .foregroundStyle(.tertiary)
+ var body: some View {
+ HStack(spacing: 2) {
+ option(.light, symbol: "sun.max.fill")
+ option(.dark, symbol: "moon.stars.fill")
+ }
+ .padding(3)
+ .background(Color.primary.opacity(0.06), in: Capsule())
+ .overlay(Capsule().strokeBorder(Color.primary.opacity(0.07), lineWidth: 1))
+ .contextMenu {
+ Button { model.appearance = .system } label: {
+ Label(loc("appearance.system"), systemImage: "desktopcomputer")
}
}
- .padding(.horizontal, 16)
- .padding(.vertical, 8)
+ .help(loc("appearance.help"))
}
-
- private func relative(_ date: Date) -> String {
- let f = RelativeDateTimeFormatter()
- f.unitsStyle = .abbreviated
- return f.localizedString(for: date, relativeTo: .now)
+ private func option(_ appearance: AppAppearance, symbol: String) -> some View {
+ let selected = appearance.colorScheme == colorScheme
+ return Button {
+ withAnimation(.easeInOut(duration: 0.18)) { model.appearance = appearance }
+ } label: {
+ Image(systemName: symbol)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(selected ? (appearance == .light ? Color.orange : Color.cyan) : Color.secondary)
+ .frame(width: 34, height: 26)
+ .background {
+ if selected {
+ Capsule().fill(Color(nsColor: .controlBackgroundColor))
+ .shadow(color: .black.opacity(0.1), radius: 3, y: 1)
+ .matchedGeometryEffect(id: "appearance-thumb", in: thumb)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(appearance.label)
+ .accessibilityAddTraits(selected ? .isSelected : [])
}
+}
- // MARK: - Detail
- @ViewBuilder
- private var detail: some View {
- if let disk = model.selectedDisk {
- DiskDetailView(disk: disk)
+private struct NativeCircleAction: ViewModifier {
+ @ViewBuilder func body(content: Content) -> some View {
+ if #available(macOS 26, *) {
+ content.labelStyle(.iconOnly).buttonStyle(.glass)
+ .buttonBorderShape(.circle).controlSize(.large)
} else {
- noSelection
+ content.labelStyle(.iconOnly).buttonStyle(.bordered)
+ .buttonBorderShape(.circle).controlSize(.large)
}
}
+}
- private var noSelection: some View {
- VStack(spacing: 20) {
- Image(systemName: "externaldrive")
- .font(.system(size: 48))
- .foregroundStyle(.tertiary)
- Text(LocalizedStringKey("detail.no_selection"), bundle: .module)
- .font(.title3.weight(.medium))
- .foregroundStyle(.secondary)
+/// Let the scroll view occupy the full window behind the transparent toolbar.
+private struct WindowChrome: NSViewRepresentable {
+ func makeNSView(context: Context) -> ChromeView { ChromeView() }
+ func updateNSView(_ view: ChromeView, context: Context) { view.configureWindow() }
+ final class ChromeView: NSView {
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ configureWindow()
+ }
+ func configureWindow() {
+ guard let window else { return }
+ window.titleVisibility = .hidden
+ window.titlebarAppearsTransparent = true
+ window.styleMask.insert(.fullSizeContentView)
+ window.toolbarStyle = .unifiedCompact
+ window.titlebarSeparatorStyle = .none
+ window.toolbar?.showsBaselineSeparator = false
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background()
}
}
diff --git a/Sources/SMARTastic/Views/DiskCardView.swift b/Sources/SMARTastic/Views/DiskCardView.swift
index 64ac634..6a13799 100644
--- a/Sources/SMARTastic/Views/DiskCardView.swift
+++ b/Sources/SMARTastic/Views/DiskCardView.swift
@@ -2,130 +2,21 @@ import SwiftUI
struct DiskCardView: View {
let disk: DiskInfo
- var isSelected: Bool = false
- @State private var hovering = false
-
- private var borderColor: Color {
- if isSelected { return disk.healthColor.opacity(0.6) }
- if hovering { return Color.primary.opacity(0.15) }
- return Color.gray.opacity(0.25)
- }
-
+ var isSelected = false
var body: some View {
- HStack(spacing: 12) {
- icon
- info
- Spacer(minLength: 8)
- indicators
- }
- .padding(12)
- .background(background)
- .onHover { hovering = $0 }
- .scaleEffect(hovering ? 1.015 : 1)
- .animation(.interpolatingSpring(duration: 0.25), value: hovering)
- }
-
- // MARK: - Icon
-
- private var icon: some View {
- ZStack {
- RoundedRectangle(cornerRadius: 10)
- .fill(disk.healthColor.gradient)
- .frame(width: 40, height: 40)
- .shadow(color: disk.healthColor.opacity(0.3), radius: 4, y: 2)
- Image(systemName: disk.icon)
- .font(.body.weight(.medium))
- .foregroundStyle(.white)
- }
- }
-
- // MARK: - Info
-
- private var info: some View {
- VStack(alignment: .leading, spacing: 2) {
- Text(disk.model)
- .font(.body.weight(.semibold))
- .lineLimit(1)
- .truncationMode(.tail)
-
- HStack(spacing: 4) {
- Text(disk.size)
- Text(disk.interface).foregroundStyle(.tertiary)
- if disk.smartAvailable {
- Text("\u{2022}").foregroundStyle(.tertiary)
- Text(disk.driveType.rawValue).foregroundStyle(.secondary)
- }
- }
- .font(.caption2)
- .foregroundStyle(.secondary)
-
- if disk.smartAvailable {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: disk.icon).font(.title3).foregroundStyle(.secondary).frame(width: 25).padding(.top, 3)
+ VStack(alignment: .leading, spacing: 6) {
+ Text(disk.model).font(.system(size: 13, weight: .semibold)).lineLimit(2)
+ Text("\(disk.size) · \(disk.interface)").font(.system(size: 12)).foregroundStyle(.secondary)
HStack(spacing: 4) {
- Circle().fill(disk.healthColor).frame(width: 6, height: 6)
+ Image(systemName: disk.health.symbol)
Text(disk.healthLabel)
- .font(.caption2)
- .foregroundStyle(disk.healthColor)
- .fixedSize()
- if disk.driveType == .ssd {
- Text("\u{2022}").foregroundStyle(.tertiary).font(.caption2)
- Text("\(Int(disk.percentageUsed))%").font(.caption2).foregroundStyle(.secondary)
- }
- }
- } else {
- HStack(spacing: 4) {
- Image(systemName: "questionmark.circle").font(.caption2)
- Text(LocalizedStringKey("badge.smart_na_card"), bundle: .module).font(.caption2)
- }
- .foregroundStyle(.tertiary)
- }
- }
- }
-
- // MARK: - Indicators
-
- @ViewBuilder
- private var indicators: some View {
- if disk.smartAvailable {
- HStack(spacing: 6) {
- if disk.driveType == .ssd {
- ring(value: disk.percentageUsed, maxValue: 100, inverted: true,
- color: disk.healthColor, label: "%")
- }
- ring(value: disk.temperature, maxValue: disk.driveType == .ssd ? 85 : 65,
- inverted: false, color: disk.tempColor, label: "\u{00B0}")
- }
- }
- }
-
- private func ring(value: Double, maxValue: Double, inverted: Bool, color: Color, label: String) -> some View {
- let p = Swift.max(0, min(1, inverted ? 1 - (value / maxValue) : value / maxValue))
- let display = Int(inverted ? Swift.max(0, maxValue - value) : value)
- return ZStack {
- Circle().stroke(.quaternary.opacity(0.4), lineWidth: 4)
- Circle().trim(from: 0, to: p)
- .stroke(AngularGradient(colors: [color.opacity(0.4), color], center: .center),
- style: StrokeStyle(lineWidth: 4, lineCap: .round))
- .rotationEffect(.degrees(-90))
- .animation(.smooth(duration: 0.5), value: p)
- VStack(spacing: 0) {
- Text("\(display)").font(.system(size: 10, weight: .bold)).monospacedDigit()
- Text(label).font(.system(size: 6, weight: .medium)).foregroundStyle(.secondary)
- }
- }
- .frame(width: 32, height: 32)
- }
-
- // MARK: - Background
-
- private var background: some View {
- RoundedRectangle(cornerRadius: 14)
- .fill(.ultraThinMaterial)
- .overlay {
- RoundedRectangle(cornerRadius: 14)
- .stroke(borderColor, lineWidth: isSelected ? 2 : 0.5)
+ Spacer(minLength: 4)
+ Text(metric(disk.temperature, suffix: " °C")).monospacedDigit()
+ }.font(.system(size: 12)).foregroundStyle(isSelected ? .white : disk.healthColor)
}
- .shadow(color: isSelected ? disk.healthColor.opacity(0.15) :
- hovering ? .black.opacity(0.08) : .clear,
- radius: 8, y: 3)
+ }.padding(.vertical, 8)
+ .accessibilityElement(children: .combine)
}
}
diff --git a/Sources/SMARTastic/Views/DiskDetailView.swift b/Sources/SMARTastic/Views/DiskDetailView.swift
index fa47a94..f5343e5 100644
--- a/Sources/SMARTastic/Views/DiskDetailView.swift
+++ b/Sources/SMARTastic/Views/DiskDetailView.swift
@@ -2,427 +2,149 @@ import SwiftUI
struct DiskDetailView: View {
let disk: DiskInfo
-
+ var isDemo = false
+ var scanWarning: String?
+ @State private var detailWidth: CGFloat = 0
+ private var columns: [GridItem] {
+ Array(repeating: GridItem(.flexible(), spacing: 12), count: detailWidth >= 720 ? 4 : 2)
+ }
var body: some View {
ScrollView {
- VStack(spacing: 24) {
+ VStack(alignment: .leading, spacing: 22) {
+ if isDemo {
+ Label(loc("demo.notice"), systemImage: "photo")
+ .font(.system(size: 12)).foregroundStyle(.secondary)
+ }
+ if let scanWarning {
+ Label(scanWarning, systemImage: "exclamationmark.triangle")
+ .font(.system(size: 13)).foregroundStyle(.orange).textSelection(.enabled)
+ }
header
+ status
if disk.smartAvailable {
- healthOverview
- performanceSection
- attributesSection
- } else {
- noSmartSection
- }
- infoSection
- }
- .padding(24)
- }
- .background()
- }
-
- // MARK: - Header
-
- private var header: some View {
- HStack(spacing: 16) {
- ZStack {
- RoundedRectangle(cornerRadius: 16)
- .fill(disk.healthColor.gradient)
- .frame(width: 64, height: 64)
- .shadow(color: disk.healthColor.opacity(0.35), radius: 8, y: 4)
- Image(systemName: disk.icon)
- .font(.title.weight(.medium))
- .foregroundStyle(.white)
- }
-
- VStack(alignment: .leading, spacing: 5) {
- Text(disk.model)
- .font(.title2.weight(.semibold))
- HStack(spacing: 8) {
- Badge(disk.driveType.rawValue, color: .blue)
- Badge(disk.interface, color: .secondary)
- if disk.smartAvailable {
- Badge(loc(disk.smartPassed ? "badge.smart_ok" : "badge.smart_error"),
- color: disk.smartPassed ? .green : .red)
- Badge(disk.healthLabel, color: disk.healthColor)
- } else {
- Badge(loc("badge.smart_na"), color: .gray)
+ section(loc("section.health")) {
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: disk.driveType == .ssd && detailWidth >= 720 ? 4 : 2), spacing: 12) {
+ healthMetrics
+ }
+ }
+ section(loc("section.usage")) {
+ LazyVGrid(columns: columns, spacing: 12) {
+ MetricTile(label: loc("metric.written"), value: metric(disk.dataWrittenTB, suffix: " TB", decimals: 2), icon: "arrow.down.to.line", color: .cyan)
+ MetricTile(label: loc("metric.read"), value: metric(disk.dataReadTB, suffix: " TB", decimals: 2), icon: "arrow.up.to.line", color: .cyan)
+ MetricTile(label: loc("metric.power_on_time"), value: metric(disk.powerOnHours), icon: "clock", detail: loc("metric.power_on_detail"))
+ MetricTile(label: loc("metric.power_cycles"), value: metric(disk.powerCycles), icon: "power")
+ }
+ }
+ if disk.percentageUsed != nil {
+ VStack(alignment: .leading, spacing: 8) {
+ Label(loc("endurance.title"), systemImage: "info.circle").font(.subheadline.weight(.medium))
+ Text(loc("endurance.explanation")).font(.system(size: 13)).foregroundStyle(.secondary)
+ if let daily = disk.dailyWriteGB {
+ Text(loc("detail.avg_write_rate") + ": " + metric(daily, suffix: " " + loc("metric.per_day"), decimals: 1)).font(.system(size: 13))
+ }
+ }.padding(16).frame(maxWidth: .infinity, alignment: .leading).background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 18))
}
}
- }
-
- Spacer()
-
- if disk.smartAvailable {
- healthScoreBadge
- }
- }
- }
-
- private var healthScoreBadge: some View {
- VStack(spacing: 2) {
- ZStack {
- Circle()
- .stroke(.quaternary, lineWidth: 5)
- .frame(width: 56, height: 56)
- Circle()
- .trim(from: 0, to: Double(disk.healthScore) / 100)
- .stroke(
- AngularGradient(colors: [disk.healthColor.opacity(0.4), disk.healthColor],
- center: .center),
- style: StrokeStyle(lineWidth: 5, lineCap: .round)
- )
- .rotationEffect(.degrees(-90))
- .frame(width: 56, height: 56)
- .animation(.smooth, value: disk.healthScore)
- Text("\(disk.healthScore)")
- .font(.title3.weight(.bold))
- .monospacedDigit()
- }
- Text(LocalizedStringKey("label.health"), bundle: .module)
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- }
-
- // MARK: - Health Overview
-
- private var healthOverview: some View {
- VStack(alignment: .leading, spacing: 12) {
- sectionTitle(loc("section.health"), icon: "heart.text.clipboard")
-
- HStack(spacing: 20) {
- if disk.driveType == .ssd {
- LargeGauge(
- value: disk.percentageUsed,
- maxValue: 100,
- label: loc("gauge.lifespan"),
- unit: loc("gauge.lifespan_unit"),
- inverted: true,
- color: disk.healthColor,
- detail: disk.remainingLifeEstimate
- )
- }
-
- LargeGauge(
- value: disk.temperature,
- maxValue: disk.driveType == .ssd ? 85 : 65,
- label: loc("gauge.temperature"),
- unit: "\u{00B0}C",
- inverted: false,
- color: disk.tempColor,
- detail: disk.tempLabel
- )
-
- LargeGauge(
- value: disk.availableSpare,
- maxValue: 100,
- label: loc("gauge.spare"),
- unit: "%",
- inverted: false,
- color: .green,
- detail: loc("gauge.spare_detail")
- )
-
- LargeGauge(
- value: Double(disk.mediaErrors),
- maxValue: max(100, Double(disk.mediaErrors) * 2),
- label: loc("gauge.media_errors"),
- unit: "",
- inverted: false,
- color: disk.mediaErrors == 0 ? .green : .red,
- detail: disk.mediaErrors == 0 ? loc("gauge.media_errors_detail_none") : "\(disk.mediaErrors)"
- )
- }
- }
- .padding(16)
- .background(RoundedRectangle(cornerRadius: 14).fill(.ultraThinMaterial))
- }
-
- // MARK: - Performance
-
- private var performanceSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- sectionTitle(loc("section.usage"), icon: "chart.bar.fill")
-
- LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
- if disk.driveType == .ssd {
- MetricBox(icon: "arrow.down.circle", label: loc("metric.read"),
- value: "\(String(format: "%.1f", disk.dataReadTB)) TB",
- detail: "\(String(format: "%.1f", disk.dailyReadGB)) \(loc("metric.per_day"))")
- MetricBox(icon: "arrow.up.circle", label: loc("metric.written"),
- value: "\(String(format: "%.1f", disk.dataWrittenTB)) TB",
- detail: "\(String(format: "%.1f", disk.dailyWriteGB)) \(loc("metric.per_day"))")
- } else {
- MetricBox(icon: "arrow.down.circle", label: loc("metric.read"), value: "\u{2014}", detail: "")
- MetricBox(icon: "arrow.up.circle", label: loc("metric.written"), value: "\u{2014}", detail: "")
- }
- MetricBox(icon: "clock", label: loc("metric.power_on_time"),
- value: disk.powerOnFormatted,
- detail: "\(disk.powerOnHours) \(loc("metric.power_on_detail"))")
- MetricBox(icon: "power", label: loc("metric.power_cycles"),
- value: "\(disk.powerCycles)",
- detail: loc("metric.power_cycles_detail"))
- if disk.driveType == .ssd {
- MetricBox(icon: "exclamationmark.triangle", label: loc("metric.unsafe_shutdowns"),
- value: "\(disk.unsafeShutdowns)",
- detail: loc("metric.unsafe_shutdowns_detail"))
+ section(loc("section.drive_info")) {
+ VStack(spacing: 0) {
+ InfoRow(label: loc("info.serial"), value: disk.serial ?? "—")
+ Divider()
+ InfoRow(label: loc("info.firmware"), value: disk.firmware ?? "—")
+ Divider()
+ InfoRow(label: loc("info.capacity"), value: disk.size)
+ Divider()
+ InfoRow(label: loc("metric.unsafe_shutdowns"), value: metric(disk.unsafeShutdowns))
+ }.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 18))
}
- MetricBox(icon: "ant", label: loc("gauge.media_errors"),
- value: "\(disk.mediaErrors)",
- detail: disk.mediaErrors == 0 ? loc("metric.media_errors_none") : loc("metric.media_errors_some"))
- }
- }
- .padding(16)
- .background(RoundedRectangle(cornerRadius: 14).fill(.ultraThinMaterial))
- }
-
- // MARK: - Attributes (table for ATA, key metrics for NVMe)
-
- private var attributesSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- sectionTitle(loc("section.life_prognosis"), icon: "calendar")
-
- HStack(spacing: 16) {
- if disk.driveType == .ssd {
- DetailBox(label: loc("detail.life_consumed"),
- value: "\(String(format: "%.1f", disk.percentageUsed))%",
- icon: "timer")
- DetailBox(label: loc("detail.avg_write_rate"),
- value: "\(String(format: "%.1f", disk.dailyWriteGB)) \(loc("metric.per_day"))",
- icon: "speedometer")
- DetailBox(label: loc("detail.remaining_life"),
- value: disk.remainingLifeEstimate,
- icon: "hourglass")
- } else {
- DetailBox(label: loc("detail.power_on"),
- value: disk.powerOnFormatted,
- icon: "clock")
- DetailBox(label: loc("detail.power_cycles"),
- value: "\(disk.powerCycles)",
- icon: "power")
+ if let diagnostic = disk.diagnostic {
+ DisclosureGroup(loc("diagnostics.title")) {
+ Text(diagnostic).font(.system(.caption, design: .monospaced)).textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading).padding(.top, 8)
+ }.font(.system(size: 13)).foregroundStyle(.secondary)
}
+ Text(loc("data.disclaimer")).font(.system(size: 12)).foregroundStyle(.secondary)
}
+ .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { detailWidth = $0 }
+ .padding(26).padding(.top, 22).frame(maxWidth: 1100)
+ .frame(maxWidth: .infinity)
}
- .padding(16)
- .background(RoundedRectangle(cornerRadius: 14).fill(.ultraThinMaterial))
}
-
- // MARK: - No SMART
-
- private var noSmartSection: some View {
- VStack(spacing: 16) {
- HStack(spacing: 12) {
- Image(systemName: "exclamationmark.triangle.fill")
- .font(.title2)
- .foregroundStyle(.orange)
- VStack(alignment: .leading, spacing: 2) {
- Text(LocalizedStringKey("nosmart.title"), bundle: .module)
- .font(.headline)
- Text(LocalizedStringKey("nosmart.message"), bundle: .module)
- .font(.callout)
- .foregroundStyle(.secondary)
- }
- }
- .padding(16)
- .background(RoundedRectangle(cornerRadius: 12).fill(.ultraThinMaterial))
+ @ViewBuilder private var healthMetrics: some View {
+ MetricTile(label: loc("gauge.temperature"), value: metric(disk.temperature, suffix: " °C"), icon: "thermometer.medium", color: disk.tempColor)
+ MetricTile(label: loc("gauge.media_errors"), value: metric(disk.mediaErrors), icon: "exclamationmark.circle", color: (disk.mediaErrors ?? 0) > 0 ? .orange : .secondary)
+ if disk.driveType == .ssd {
+ MetricTile(label: loc("endurance.remaining"), value: metric(disk.remainingEndurance, suffix: " %"), icon: "chart.bar", color: disk.healthColor, progress: disk.remainingEndurance)
+ MetricTile(label: loc("gauge.spare"), value: metric(disk.availableSpare, suffix: " %"), icon: "square.stack.3d.up", color: .teal, progress: disk.availableSpare)
}
}
-
- // MARK: - Drive Info
-
- private var infoSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- sectionTitle(loc("section.drive_info"), icon: "info.circle.fill")
-
- VStack(spacing: 0) {
- InfoRow(label: loc("info.serial"), value: disk.serial)
- Divider().padding(.leading, 120)
- InfoRow(label: loc("info.firmware"), value: disk.firmware)
- Divider().padding(.leading, 120)
- InfoRow(label: loc("info.capacity"), value: disk.size)
- Divider().padding(.leading, 120)
- InfoRow(label: loc("info.interface"), value: disk.interface)
- if disk.driveType == .ssd {
- Divider().padding(.leading, 120)
- InfoRow(label: loc("info.tbw"),
- value: disk.percentageUsed > 0
- ? "\(String(format: "%.0f", disk.dataWrittenTB / disk.percentageUsed * 100)) TB"
- : "\u{2014}")
- }
+ private var header: some View {
+ HStack(spacing: 14) {
+ Image(systemName: disk.icon).font(.system(size: 28)).foregroundStyle(.cyan)
+ .frame(width: 58, height: 58).background(.cyan.opacity(0.09), in: RoundedRectangle(cornerRadius: 18))
+ VStack(alignment: .leading, spacing: 6) {
+ Text(disk.model).font(.system(size: 21, weight: .semibold)).textSelection(.enabled).fixedSize(horizontal: false, vertical: true)
+ Text("\(disk.size) · \(disk.driveType.rawValue) · \(disk.interface)").font(.system(size: 13)).foregroundStyle(.secondary)
}
- .background(RoundedRectangle(cornerRadius: 12).fill(.ultraThinMaterial))
+ Spacer(minLength: 0)
}
}
-
- // MARK: - Helpers
-
- private func sectionTitle(_ text: String, icon: String) -> some View {
- Label(text, systemImage: icon)
- .font(.headline.weight(.semibold))
- .foregroundStyle(.primary)
- }
-}
-
-// MARK: - Large Gauge
-
-struct LargeGauge: View {
- let value: Double
- let maxValue: Double
- let label: String
- let unit: String
- let inverted: Bool
- let color: Color
- let detail: String
-
- private var progress: Double {
- let p = value / maxValue
- return max(0, min(1, inverted ? 1 - p : p))
- }
-
- private var displayValue: Int {
- Int(inverted ? max(0, maxValue - value) : value)
- }
-
- var body: some View {
- VStack(spacing: 8) {
- ZStack {
- Circle()
- .stroke(.quaternary.opacity(0.4), lineWidth: 8)
- Circle()
- .trim(from: 0, to: progress)
- .stroke(
- AngularGradient(colors: [color.opacity(0.3), color], center: .center),
- style: StrokeStyle(lineWidth: 8, lineCap: .round)
- )
- .rotationEffect(.degrees(-90))
- .animation(.smooth(duration: 0.6), value: progress)
-
- VStack(spacing: 0) {
- Text("\(displayValue)")
- .font(.title2.weight(.bold))
- .monospacedDigit()
- if !unit.isEmpty {
- Text(unit)
- .font(.system(.caption2, design: .rounded))
- .foregroundStyle(.secondary)
- }
- }
+ private var status: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: disk.health.symbol).font(.title2).foregroundStyle(disk.healthColor)
+ VStack(alignment: .leading, spacing: 5) {
+ Text(disk.healthLabel).font(.system(size: 14, weight: .semibold))
+ Text(loc(!disk.smartAvailable ? "nosmart.message" : disk.health == .critical ? "status.critical" : disk.health == .warning ? "status.warning" : disk.health == .healthy ? "status.healthy" : "status.unknown"))
+ .font(.system(size: 13)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
}
- .frame(width: 90, height: 90)
-
- Text(label)
- .font(.caption.weight(.medium))
- .foregroundStyle(.secondary)
-
- Text(detail)
- .font(.caption2)
- .foregroundStyle(color)
- }
- .frame(maxWidth: .infinity)
+ Spacer(minLength: 0)
+ }.padding(16).frame(maxWidth: .infinity, alignment: .leading)
+ .background(disk.healthColor.opacity(0.07), in: RoundedRectangle(cornerRadius: 18))
}
-}
-
-// MARK: - Subviews
-
-struct Badge: View {
- let text: String
- let color: Color
-
- init(_ text: String, color: Color) {
- self.text = text
- self.color = color
- }
-
- var body: some View {
- Text(text)
- .font(.caption2.weight(.semibold))
- .foregroundStyle(.white)
- .padding(.horizontal, 8)
- .padding(.vertical, 3)
- .background(color.opacity(0.85))
- .clipShape(Capsule())
- }
-}
-
-struct MetricBox: View {
- let icon: String
- let label: String
- let value: String
- let detail: String
-
- var body: some View {
- HStack(spacing: 10) {
- Image(systemName: icon)
- .font(.title3)
- .foregroundStyle(.secondary)
- .frame(width: 24)
-
- VStack(alignment: .leading, spacing: 1) {
- Text(value)
- .font(.callout.weight(.semibold))
- .monospacedDigit()
- Text(label)
- .font(.caption2)
- .foregroundStyle(.secondary)
- if !detail.isEmpty {
- Text(detail)
- .font(.caption2)
- .foregroundStyle(.tertiary)
- }
- }
-
- Spacer()
+ private func section(_ title: String, @ViewBuilder content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(title).font(.system(size: 15, weight: .semibold))
+ content()
}
- .padding(10)
- .background(RoundedRectangle(cornerRadius: 10).fill(.regularMaterial))
}
}
-struct DetailBox: View {
+struct MetricTile: View {
let label: String
let value: String
let icon: String
-
+ var color: Color = .secondary
+ var detail: String = ""
+ var progress: Double? = nil
var body: some View {
- HStack(spacing: 10) {
- Image(systemName: icon)
- .font(.title3)
- .foregroundStyle(.secondary)
-
- VStack(alignment: .leading, spacing: 1) {
- Text(value)
- .font(.callout.weight(.semibold))
- .monospacedDigit()
- Text(label)
- .font(.caption2)
- .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 6) { Image(systemName: icon).foregroundStyle(color); Text(label).foregroundStyle(Color.secondary) }.font(.system(size: 13))
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Text(value).font(.system(size: 23, weight: .semibold)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.6)
+ if !detail.isEmpty { Text(detail).font(.system(size: 12)).foregroundStyle(.secondary) }
}
-
- Spacer()
- }
- .padding(12)
- .background(RoundedRectangle(cornerRadius: 10).fill(.regularMaterial))
+ if let progress {
+ GeometryReader { geometry in
+ ZStack(alignment: .leading) {
+ Capsule().fill(color.opacity(0.15))
+ Capsule().fill(color).frame(width: geometry.size.width * max(0, min(100, progress)) / 100)
+ }
+ }.frame(height: 5).accessibilityHidden(true)
+ }
+ }.frame(minWidth: 130, maxWidth: .infinity, minHeight: 84, alignment: .leading).padding(16)
+ .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 18))
+ .overlay(RoundedRectangle(cornerRadius: 18).stroke(.primary.opacity(0.05), lineWidth: 1))
+ .accessibilityElement(children: .combine)
}
}
struct InfoRow: View {
let label: String
let value: String
-
var body: some View {
- HStack {
- Text(label)
- .font(.callout)
- .foregroundStyle(.secondary)
- .frame(width: 110, alignment: .leading)
- Spacer()
- Text(value)
- .font(.callout.weight(.medium))
- .monospacedDigit()
- .lineLimit(1)
- }
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
+ HStack(alignment: .top, spacing: 16) {
+ Text(label).foregroundStyle(.secondary)
+ Spacer(minLength: 12)
+ Text(value).monospacedDigit().textSelection(.enabled).multilineTextAlignment(.trailing)
+ }.font(.system(size: 13)).padding(.horizontal, 16).padding(.vertical, 11)
}
}
diff --git a/Sources/SMARTastic/Views/HealthGaugeView.swift b/Sources/SMARTastic/Views/HealthGaugeView.swift
deleted file mode 100644
index 223a8c4..0000000
--- a/Sources/SMARTastic/Views/HealthGaugeView.swift
+++ /dev/null
@@ -1,44 +0,0 @@
-import SwiftUI
-
-struct HealthGaugeView: View {
- let value: Double
- let maxValue: Double
- let label: String
- let color: Color
- var isInverted: Bool = false
-
- private var progress: Double {
- let p = value / maxValue
- return isInverted ? max(0, min(1, 1 - p)) : max(0, min(1, p))
- }
-
- var body: some View {
- VStack(spacing: 6) {
- ZStack {
- Circle()
- .stroke(.quaternary, lineWidth: 6)
- Circle()
- .trim(from: 0, to: progress)
- .stroke(
- AngularGradient(
- gradient: Gradient(colors: [color.opacity(0.5), color]),
- center: .center,
- startAngle: .degrees(-90),
- endAngle: .degrees(270)
- ),
- style: StrokeStyle(lineWidth: 6, lineCap: .round)
- )
- .rotationEffect(.degrees(-90))
- Text(isInverted ? "\(Int((1 - progress) * 100))%" : "\(Int(progress * 100))%")
- .font(.title3.weight(.semibold))
- .monospacedDigit()
- .contentTransition(.numericText())
- }
- .frame(width: 56, height: 56)
-
- Text(label)
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- }
-}
diff --git a/Tests/Release/test_prepare_release.py b/Tests/Release/test_prepare_release.py
new file mode 100644
index 0000000..ad5de8d
--- /dev/null
+++ b/Tests/Release/test_prepare_release.py
@@ -0,0 +1,205 @@
+"""Offline regression tests for the release continuation decisions.
+
+Run with: python3 -m unittest discover -s Tests/Release -v
+"""
+import io
+import json
+import os
+from pathlib import Path
+import plistlib
+import runpy
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import patch
+import urllib.error
+
+
+SCRIPT = Path(__file__).resolve().parents[2] / "scripts/prepare-release.py"
+SOURCE = "a" * 40
+VERSION = "1.1.0"
+REPOSITORY = "example/SMARTastic"
+
+
+class PrepareReleaseTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ old_cwd = Path.cwd()
+ os.chdir(self.root)
+ self.addCleanup(os.chdir, old_cwd)
+ self.environment = {
+ "VERSION": VERSION,
+ "GITHUB_REPOSITORY": REPOSITORY,
+ "GITHUB_SHA": SOURCE,
+ "GH_TOKEN": "offline-test-placeholder",
+ "GITHUB_RUN_ATTEMPT": "1",
+ "GITHUB_RUN_ID": "123",
+ "GITHUB_RUN_NUMBER": "15",
+ "GITHUB_ENV": str(self.root / "github-env"),
+ "GITHUB_OUTPUT": str(self.root / "github-output"),
+ }
+ self.release = None
+ self.tag_commit = None
+ self.previous = {"head_sha": SOURCE, "run_number": 8}
+ self.artifacts = []
+ self.build_conclusions = ["skipped"]
+ self.commands = []
+ self.restore = lambda: None
+
+ def urlopen(self, request):
+ self.assertEqual(request.full_url,
+ f"https://api.github.com/repos/{REPOSITORY}/releases/tags/v{VERSION}")
+ if self.release is None:
+ raise urllib.error.HTTPError(request.full_url, 404, "Not Found", {}, None)
+ return io.BytesIO(json.dumps(self.release).encode())
+
+ def check_output(self, arguments, **kwargs):
+ self.commands.append(list(arguments))
+ if arguments[:2] == ("git", "ls-remote"):
+ return f"{self.tag_commit}\trefs/tags/v{VERSION}\n" if self.tag_commit else ""
+ if arguments[:2] == ("git", "rev-parse"):
+ return self.tag_commit + "\n"
+ if arguments[:2] == ("gh", "api"):
+ endpoint = arguments[2]
+ if endpoint.endswith("/artifacts"):
+ return json.dumps({"artifacts": self.artifacts})
+ if endpoint.endswith("/jobs?filter=all"):
+ return json.dumps({"jobs": [{"steps": [
+ {"name": "Build Universal app and notarize", "conclusion": conclusion}
+ for conclusion in self.build_conclusions
+ ]}]})
+ self.assertEqual(endpoint, f"repos/{REPOSITORY}/actions/runs/123")
+ return json.dumps(self.previous)
+ self.fail(f"Unexpected subprocess.check_output: {arguments!r}")
+
+ def run_command(self, arguments, **kwargs):
+ self.commands.append(list(arguments))
+ allowed = {"git", "gh", "shasum", "ditto", "python3"}
+ self.assertIn(arguments[0], allowed)
+ if arguments[:3] == ["gh", "run", "download"]:
+ self.restore()
+ return subprocess.CompletedProcess(arguments, 0)
+
+ def execute(self):
+ with patch.dict(os.environ, self.environment, clear=True), \
+ patch("urllib.request.urlopen", side_effect=self.urlopen), \
+ patch("subprocess.check_output", side_effect=self.check_output), \
+ patch("subprocess.run", side_effect=self.run_command):
+ runpy.run_path(str(SCRIPT), run_name="__main__")
+
+ def assert_result(self, mode, build=None):
+ self.assertEqual((self.root / "github-output").read_text(), f"mode={mode}\n")
+ env_file = self.root / "github-env"
+ if build is None:
+ self.assertFalse(env_file.exists())
+ else:
+ self.assertEqual(env_file.read_text(), f"BUILD_NUMBER={build}\n")
+
+ def has_command(self, *prefix):
+ return any(command[:len(prefix)] == list(prefix) for command in self.commands)
+
+ def resume_with_artifact(self, *, archive=True, final=False, submitted=True):
+ self.environment["RESUME_RUN_ID"] = "123"
+ self.artifacts = [{"name": "smartastic-notarization-123", "expired": False}]
+
+ def restore():
+ output = self.root / ".build/releases"
+ state = output / f".state-{VERSION}"
+ state.mkdir(parents=True)
+ (state / "source-commit").write_text(SOURCE + "\n")
+ if submitted:
+ (state / "submission-started").touch()
+ (state / "submission.json").write_text('{"id":"existing-apple-submission"}')
+ if archive:
+ (state / "submission.zip").touch()
+ (state / "submission.sha256").write_text("fixture checksum checked by mocked shasum")
+ contents = state / "SMARTastic.app/Contents"
+ contents.mkdir(parents=True)
+ (contents / "Info.plist").write_bytes(plistlib.dumps({
+ "CFBundleShortVersionString": VERSION,
+ "CFBundleVersion": "7",
+ }))
+ if final:
+ (output / f"SMARTastic-{VERSION}.zip").touch()
+ (output / f"SMARTastic-{VERSION}.zip.sha256").touch()
+
+ self.restore = restore
+
+ def test_new_release_builds_with_new_build_number(self):
+ self.execute()
+ self.assert_result("build", 16)
+ self.assertFalse(self.has_command("gh", "run", "download"))
+
+ def test_existing_matching_release_continues_at_tap(self):
+ self.tag_commit = SOURCE
+ self.release = {"draft": False}
+ self.execute()
+ self.assert_result("tap")
+ self.assertTrue(self.has_command("gh", "release", "download"))
+ self.assertTrue(self.has_command("shasum", "-a", "256", "-c", f"SMARTastic-{VERSION}.zip.sha256"))
+ self.assertFalse(self.has_command("gh", "run", "download"))
+
+ def test_mismatching_tag_stops_before_downloading_artifacts(self):
+ self.tag_commit = "b" * 40
+ self.release = {"draft": False}
+ with self.assertRaisesRegex(SystemExit, "another source commit"):
+ self.execute()
+ self.assertFalse(self.has_command("gh", "release", "download"))
+ self.assertFalse((self.root / "github-output").exists())
+
+ def test_saved_submission_preserves_bundle_build_number(self):
+ self.resume_with_artifact()
+ self.execute()
+ self.assert_result("build", 7)
+ self.assertTrue(self.has_command("gh", "run", "download", "123"))
+ self.assertTrue(self.has_command("shasum", "-a", "256", "-c", "submission.sha256"))
+ self.assertTrue(self.has_command("ditto", "-x", "-k"))
+
+ def test_saved_final_archive_continues_at_publish(self):
+ self.resume_with_artifact(final=True)
+ self.execute()
+ self.assert_result("publish", 7)
+ self.assertTrue(self.has_command("shasum", "-a", "256", "-c", f"SMARTastic-{VERSION}.zip.sha256"))
+
+ def test_pre_build_failure_rerun_without_artifact_is_safe(self):
+ self.environment["GITHUB_RUN_ATTEMPT"] = "2"
+ self.execute()
+ self.assert_result("build", 9)
+ self.assertFalse(self.has_command("gh", "run", "download"))
+
+ def test_possible_submission_without_artifact_stops(self):
+ self.environment["GITHUB_RUN_ATTEMPT"] = "2"
+ self.build_conclusions = ["failure", "skipped"]
+ with self.assertRaisesRegex(SystemExit, "possible submission"):
+ self.execute()
+ self.assertFalse((self.root / "github-output").exists())
+
+ def test_partial_build_without_submission_is_safe(self):
+ self.resume_with_artifact(archive=False, submitted=False)
+ self.execute()
+ self.assert_result("build", 9)
+ self.assertFalse(self.has_command("ditto"))
+
+ def test_submission_marker_without_archive_requires_recovery(self):
+ self.resume_with_artifact(archive=False)
+ with self.assertRaisesRegex(SystemExit, "lacks its archive"):
+ self.execute()
+
+ def test_unknown_prior_build_status_does_not_allow_new_submission(self):
+ self.environment["GITHUB_RUN_ATTEMPT"] = "2"
+ self.build_conclusions = []
+ with self.assertRaisesRegex(SystemExit, "possible submission"):
+ self.execute()
+
+ def test_resume_from_another_commit_stops_before_download(self):
+ self.resume_with_artifact()
+ self.previous["head_sha"] = "b" * 40
+ with self.assertRaisesRegex(SystemExit, "another source commit"):
+ self.execute()
+ self.assertFalse(self.has_command("gh", "run", "download"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/Tests/SMARTasticTests/SmartParserTests.swift b/Tests/SMARTasticTests/SmartParserTests.swift
new file mode 100644
index 0000000..4643341
--- /dev/null
+++ b/Tests/SMARTasticTests/SmartParserTests.swift
@@ -0,0 +1,148 @@
+import XCTest
+@testable import SMARTastic
+
+final class SmartParserTests: XCTestCase {
+ private func parse(_ json: String, device: String = "/dev/disk42") throws -> DiskInfo {
+ try SmartParser.parse(Data(json.utf8), device: device)
+ }
+ func testNVMeUnitsAndPartialCommandFailure() throws {
+ let disk = try parse(#"{"model_name":"Test SSD","device":{"protocol":"NVMe"},"smartctl":{"exit_status":4,"messages":[{"string":"Optional error log unavailable"}]},"smart_status":{"passed":true},"user_capacity":{"bytes":2000000000000},"nvme_smart_health_information_log":{"percentage_used":7,"data_units_written":2000000,"data_units_read":4000000,"temperature":39,"power_on_hours":1000}}"#)
+ XCTAssertEqual(disk.dataWrittenTB!, 1.024, accuracy: 0.000001)
+ XCTAssertEqual(disk.dataReadTB!, 2.048, accuracy: 0.000001)
+ XCTAssertEqual(disk.size, "2.00 TB")
+ XCTAssertEqual(disk.temperature, 39)
+ XCTAssertEqual(disk.health, .healthy)
+ XCTAssertNotNil(disk.diagnostic)
+ }
+ func testATACountersUseRawValuesAndDecodedTemperature() throws {
+ let disk = try parse(#"{"rotation_rate":7200,"temperature":{"current":32},"smart_status":{"passed":true},"ata_smart_attributes":{"table":[{"id":9,"value":99,"raw":{"value":18342}},{"id":12,"value":100,"raw":{"value":654}},{"id":194,"value":118,"raw":{"value":9999999}},{"id":5,"name":"Reallocated_Sector_Ct","raw":{"value":2}},{"id":197,"name":"Current_Pending_Sector","raw":{"value":3}},{"id":198,"name":"Offline_Uncorrectable","raw":{"value":1}}]}}"#)
+ XCTAssertEqual(disk.powerOnHours, 18342)
+ XCTAssertEqual(disk.powerCycles, 654)
+ XCTAssertEqual(disk.temperature, 32)
+ XCTAssertEqual(disk.mediaErrors, 6)
+ XCTAssertEqual(disk.driveType, .hdd)
+ XCTAssertEqual(disk.health, .warning)
+ XCTAssertNil(disk.percentageUsed)
+ XCTAssertNil(disk.dataWrittenTB)
+ }
+ func testVendorReadCounterIsNotMediaErrors() throws {
+ let disk = try parse(#"{"smart_status":{"passed":true},"ata_smart_attributes":{"table":[{"id":5,"name":"Reallocated_Sector_Ct","raw":{"value":0}},{"id":198,"name":"Host_Reads_GiB","raw":{"value":100}}]}}"#)
+ XCTAssertEqual(disk.mediaErrors, 0)
+ XCTAssertEqual(disk.health, .healthy)
+ }
+ func testFailureOverridesLowWear() throws {
+ let disk = try parse(#"{"smart_status":{"passed":false},"nvme_smart_health_information_log":{"percentage_used":0}}"#)
+ XCTAssertEqual(disk.health, .critical)
+ }
+ func testCriticalWarningAndSpareThreshold() throws {
+ XCTAssertEqual(try parse(#"{"nvme_smart_health_information_log":{"critical_warning":1}}"#).health, .critical)
+ XCTAssertEqual(try parse(#"{"nvme_smart_health_information_log":{"available_spare":3,"available_spare_threshold":10}}"#).health, .critical)
+ }
+ func testUnknownValuesDoNotBecomeHealthyZeros() throws {
+ let disk = try parse(#"{"model_name":"USB SSD","smart_support":{"available":false}}"#)
+ XCTAssertFalse(disk.smartAvailable)
+ XCTAssertEqual(disk.health, .unknown)
+ XCTAssertNil(disk.temperature)
+ XCTAssertNil(disk.mediaErrors)
+ XCTAssertNil(disk.remainingEndurance)
+ }
+ func testMissingSerialsHaveSeparateDeviceIdentities() throws {
+ XCTAssertNotEqual(try parse("{}", device: "/dev/disk21").id, try parse("{}", device: "/dev/disk22").id)
+ }
+ func testWearAbove100AndZeroHours() throws {
+ let disk = try parse(#"{"nvme_smart_health_information_log":{"percentage_used":255,"power_on_hours":0,"data_units_written":0}}"#)
+ XCTAssertEqual(disk.remainingEndurance, 0)
+ XCTAssertEqual(disk.health, .critical)
+ XCTAssertNil(disk.dailyWriteGB)
+ }
+ func testInvalidAndBooleanMetrics() throws {
+ XCTAssertThrowsError(try parse("not json"))
+ let disk = try parse(#"{"nvme_smart_health_information_log":{"percentage_used":true,"power_on_hours":1e30,"temperature":-100}}"#)
+ XCTAssertNil(disk.percentageUsed)
+ XCTAssertNil(disk.powerOnHours)
+ XCTAssertNil(disk.temperature)
+ }
+ func testReportOmitsSerialNumbersAndPreservesSampleTime() throws {
+ let date = Date(timeIntervalSince1970: 1000)
+ let report = try ReportDocument(disks: DemoData.disks, sampledAt: date, demo: true, warning: "stale")
+ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: report.data) as? [String: Any])
+ XCTAssertEqual(json["schemaVersion"] as? Int, 1)
+ XCTAssertEqual(json["demo"] as? Bool, true)
+ XCTAssertEqual(json["sampledAt"] as? String, "1970-01-01T00:16:40Z")
+ XCTAssertEqual(json["warning"] as? String, "stale")
+ let disks = try XCTUnwrap(json["disks"] as? [[String: Any]])
+ XCTAssertTrue(disks.allSatisfy { $0["serial"] == nil })
+ }
+ func testLargeOutputDoesNotDeadlock() throws {
+ let result = try CommandRunner.run("/usr/bin/awk", ["BEGIN { for (i=0; i<20000; i++) print \"abcdefghij\" }"])
+ XCTAssertEqual(result.status, 0)
+ XCTAssertGreaterThan(result.data.count, 200000)
+ }
+ func testCommandTimeout() {
+ let start = Date()
+ XCTAssertThrowsError(try CommandRunner.run("/bin/sleep", ["5"], timeout: 0.1))
+ XCTAssertLessThan(Date().timeIntervalSince(start), 2)
+ }
+}
+
+@MainActor
+final class AppModelTests: XCTestCase {
+ private func finish(_ model: AppModel) async throws {
+ for _ in 0..<100 where model.isLoading { try await Task.sleep(nanoseconds: 10_000_000) }
+ XCTAssertFalse(model.isLoading)
+ }
+ func testSelectionReconcilesAfterDisconnect() async throws {
+ let model = AppModel(isDemo: false, scanner: { ScanResult(disks: [DemoData.disks[1]], warning: nil) })
+ model.disks = DemoData.disks
+ model.selectedDiskID = DemoData.disks[0].id
+ model.refresh()
+ try await finish(model)
+ XCTAssertEqual(model.selectedDiskID, DemoData.disks[1].id)
+ XCTAssertNotNil(model.lastRefreshed)
+ }
+ func testFailurePreservesSnapshotAndExposesError() async throws {
+ let model = AppModel(isDemo: false, scanner: { throw SmartCtlError.commandFailed("test failure") })
+ model.disks = DemoData.disks
+ model.selectedDiskID = DemoData.disks[0].id
+ model.lastRefreshed = Date(timeIntervalSince1970: 123)
+ model.refresh()
+ try await finish(model)
+ XCTAssertEqual(model.disks, DemoData.disks)
+ XCTAssertEqual(model.lastRefreshed, Date(timeIntervalSince1970: 123))
+ XCTAssertEqual(model.error, "test failure")
+ }
+ func testEmptyScanClearsSelection() async throws {
+ let model = AppModel(isDemo: false, scanner: { ScanResult(disks: [], warning: nil) })
+ model.disks = DemoData.disks
+ model.selectedDiskID = DemoData.disks[0].id
+ model.refresh()
+ try await finish(model)
+ XCTAssertNil(model.selectedDiskID)
+ XCTAssertTrue(model.disks.isEmpty)
+ }
+ func testConcurrentRefreshIsSuppressed() async throws {
+ actor Counter {
+ var calls = 0
+ func scan() async throws -> ScanResult {
+ calls += 1
+ try await Task.sleep(nanoseconds: 50_000_000)
+ return ScanResult(disks: [], warning: nil)
+ }
+ }
+ let counter = Counter()
+ let model = AppModel(isDemo: false, scanner: { try await counter.scan() })
+ model.refresh(); model.refresh()
+ try await finish(model)
+ let calls = await counter.calls
+ XCTAssertEqual(calls, 1)
+ }
+ func testLiveScanWhenExplicitlyEnabled() async throws {
+ guard ProcessInfo.processInfo.environment["SMARTASTIC_LIVE_TEST"] == "1" else {
+ throw XCTSkip("Set SMARTASTIC_LIVE_TEST=1 to run the hardware integration check.")
+ }
+ let result = try await SmartCtlService.shared.scan()
+ XCTAssertFalse(result.disks.isEmpty)
+ XCTAssertTrue(result.disks.contains { $0.model.contains("APPLE") })
+ XCTAssertTrue(result.disks.contains { $0.smartAvailable })
+ }
+}
diff --git a/assets/AppIcon.icns b/assets/AppIcon.icns
new file mode 100644
index 0000000000000000000000000000000000000000..652b03368c928007d877d32b2c93830b5c1294b5
GIT binary patch
literal 526881
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